diff --git a/.github/actions/bootstrap/action.yaml b/.github/actions/bootstrap/action.yaml index 910d97f78..066dbaf52 100644 --- a/.github/actions/bootstrap/action.yaml +++ b/.github/actions/bootstrap/action.yaml @@ -45,7 +45,10 @@ runs: - name: Install project tools shell: bash if: inputs.tools == 'true' - run: make tools + run: | + make tools + .tool/binny list + .tool/binny check - name: Install go dependencies if: inputs.go-dependencies == 'true' diff --git a/.github/workflows/test-fixture-cache-publish.yaml b/.github/workflows/test-fixture-cache-publish.yaml index bcf15619d..831953dac 100644 --- a/.github/workflows/test-fixture-cache-publish.yaml +++ b/.github/workflows/test-fixture-cache-publish.yaml @@ -30,7 +30,9 @@ jobs: download-test-fixture-cache: false - name: Run all tests - run: make test + run: | + make list-tasks-targets + make --debug=verbose test env: # we want to rebuild the cache with no previous state DOWNLOAD_TEST_FIXTURE_CACHE: "false" diff --git a/Makefile b/Makefile index a1430e7f5..3f7916180 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ make-default: $(TASK) @$(TASK) # for those of us that can't seem to kick the habit of typing `make ...` lets wrap the superior `task` tool -TASKS := $(shell bash -c "test -f $(TASK) && $(TASK) -l | grep '^\* ' | cut -d' ' -f2 | tr -d ':' | tr '\n' ' '" ) $(shell bash -c "test -f $(TASK) && $(TASK) -l | grep 'aliases:' | cut -d ':' -f 3 | tr '\n' ' ' | tr -d ','") +TASKS := $(shell bash -c "test -f $(TASK) && NO_COLOR=1 $(TASK) -l | grep '^\* ' | cut -d' ' -f2 | tr -d ':' | tr '\n' ' '" ) $(shell bash -c "test -f $(TASK) && NO_COLOR=1 $(TASK) -l | grep 'aliases:' | cut -d ':' -f 3 | tr '\n' ' ' | tr -d ','") .PHONY: $(TASKS) $(TASKS): $(TASK) diff --git a/cmd/syft/internal/test/integration/package_deduplication_test.go b/cmd/syft/internal/test/integration/package_deduplication_test.go index f2586a911..82783400c 100644 --- a/cmd/syft/internal/test/integration/package_deduplication_test.go +++ b/cmd/syft/internal/test/integration/package_deduplication_test.go @@ -1,7 +1,6 @@ package integration import ( - "fmt" "testing" "github.com/stretchr/testify/assert" @@ -11,47 +10,52 @@ import ( ) func TestPackageDeduplication(t *testing.T) { + // this test verifies that package deduplication works correctly across layers. + // The test fixture installs/upgrades packages in multiple stages, creating + // duplicate RPM DB entries across layers. Without deduplication, we'd see ~600 packages. + // + // Note: we index by package name (not name-version) to be resilient to Rocky Linux + // repo updates. Location counts are summed across all versions of each package. tests := []struct { scope source.Scope packageCount int - instanceCount map[string]int - locationCount map[string]int + instanceCount map[string]int // how many distinct package instances (by name) + locationCount map[string]int // total locations across ALL versions of each package }{ { scope: source.AllLayersScope, - packageCount: 175, // without deduplication this would be ~600 + packageCount: 176, // without deduplication this would be ~600 instanceCount: map[string]int{ "basesystem": 1, "wget": 1, - "curl-minimal": 2, // upgraded in the image + "curl-minimal": 2, // base + upgraded (2 different versions) "vsftpd": 1, - "httpd": 1, // rpm, - we exclude binary + "httpd": 1, }, locationCount: map[string]int{ - "basesystem-11-13.el9": 5, // in all layers - "curl-minimal-7.76.1-26.el9_3.2.0.1": 2, // base + wget layer - "curl-minimal-7.76.1-31.el9_6.1": 3, // curl upgrade layer + all above layers - "wget-1.21.1-8.el9_4": 4, // wget + all above layers - "vsftpd-3.0.5-6.el9": 2, // vsftpd + all above layers - "httpd-2.4.62-4.el9_6.4": 1, // last layer + "basesystem": 5, // in all layers + "curl-minimal": 5, // total across both versions (2 + 3) + "wget": 4, // wget + all above layers + "vsftpd": 2, // vsftpd + all above layers + "httpd": 1, // last layer }, }, { scope: source.SquashedScope, - packageCount: 169, + packageCount: 170, instanceCount: map[string]int{ "basesystem": 1, "wget": 1, - "curl-minimal": 1, // upgraded, but the most recent + "curl-minimal": 1, // deduped to latest "vsftpd": 1, - "httpd": 1, // rpm, binary is now excluded by overlap + "httpd": 1, }, locationCount: map[string]int{ - "basesystem-11-13.el9": 1, - "curl-minimal-7.76.1-31.el9_6.1": 1, // upgrade - "wget-1.21.1-8.el9_4": 1, - "vsftpd-3.0.5-6.el9": 1, - "httpd-2.4.62-4.el9_6.4": 1, + "basesystem": 1, + "curl-minimal": 1, + "wget": 1, + "vsftpd": 1, + "httpd": 1, }, }, } @@ -59,35 +63,32 @@ func TestPackageDeduplication(t *testing.T) { for _, tt := range tests { t.Run(string(tt.scope), func(t *testing.T) { sbom, _ := catalogFixtureImage(t, "image-vertical-package-dups", tt.scope) + + // verify binary packages have names for _, p := range sbom.Artifacts.Packages.Sorted() { if p.Type == pkg.BinaryPkg { assert.NotEmpty(t, p.Name) } } + // verify exact package count assert.Equal(t, tt.packageCount, sbom.Artifacts.Packages.PackageCount()) - for name, expectedInstanceCount := range tt.instanceCount { + + // verify instance count by package name + for name, expectedCount := range tt.instanceCount { pkgs := sbom.Artifacts.Packages.PackagesByName(name) - - // with multiple packages with the same name, something is wrong (or this is the wrong fixture) - if assert.Len(t, pkgs, expectedInstanceCount, "unexpected package count for %s", name) { - for _, p := range pkgs { - nameVersion := fmt.Sprintf("%s-%s", name, p.Version) - expectedLocationCount, ok := tt.locationCount[nameVersion] - if !ok { - t.Errorf("missing name-version: %s", nameVersion) - continue - } - - // we should see merged locations (assumption, there was 1 location for each package) - assert.Len(t, p.Locations.ToSlice(), expectedLocationCount, "unexpected location count for %s", nameVersion) - - // all paths should match - assert.Len(t, p.Locations.CoordinateSet().Paths(), 1, "unexpected location count for %s", nameVersion) - } - } + assert.Len(t, pkgs, expectedCount, "unexpected instance count for %s", name) } + // verify total location count across all versions of each package + for name, expectedLocCount := range tt.locationCount { + pkgs := sbom.Artifacts.Packages.PackagesByName(name) + totalLocations := 0 + for _, p := range pkgs { + totalLocations += len(p.Locations.ToSlice()) + } + assert.Equal(t, expectedLocCount, totalLocations, "unexpected total location count for %s", name) + } }) } } diff --git a/cmd/syft/internal/test/integration/test-fixtures/image-vertical-package-dups/Dockerfile b/cmd/syft/internal/test/integration/test-fixtures/image-vertical-package-dups/Dockerfile index 6fb68e98e..28f95ba59 100644 --- a/cmd/syft/internal/test/integration/test-fixtures/image-vertical-package-dups/Dockerfile +++ b/cmd/syft/internal/test/integration/test-fixtures/image-vertical-package-dups/Dockerfile @@ -7,16 +7,16 @@ FROM --platform=linux/amd64 rockylinux:9.3.20231119@sha256:d644d203142cd5b54ad2a # copying the RPM DB from each stage to a final stage in separate layers. This will result in a much smaller image. FROM base AS stage1 -RUN dnf install -y wget-1.21.1-8.el9_4 +RUN dnf install -y wget FROM stage1 AS stage2 -RUN dnf update -y curl-minimal-7.76.1-31.el9_6.1 +RUN dnf update -y curl-minimal FROM stage2 AS stage3 -RUN dnf install -y vsftpd-3.0.5-6.el9 +RUN dnf install -y vsftpd FROM stage3 AS stage4 -RUN dnf install -y httpd-2.4.62-4.el9_6.4 +RUN dnf install -y httpd FROM scratch