diff --git a/syft/file/coordinate_set.go b/syft/file/coordinate_set.go index 5342f6de2..086184e30 100644 --- a/syft/file/coordinate_set.go +++ b/syft/file/coordinate_set.go @@ -78,6 +78,9 @@ func (s CoordinateSet) ToSlice(sorters ...func(a, b Coordinates) int) []Coordina if !sorted { sort.SliceStable(coordinates, func(i, j int) bool { if coordinates[i].FileSystemID == coordinates[j].FileSystemID { + if coordinates[i].RealPath == coordinates[j].RealPath { + return coordinates[i].ArchivePath < coordinates[j].ArchivePath + } return coordinates[i].RealPath < coordinates[j].RealPath } return coordinates[i].FileSystemID < coordinates[j].FileSystemID diff --git a/syft/file/location.go b/syft/file/location.go index 76678c40f..0cd3c6998 100644 --- a/syft/file/location.go +++ b/syft/file/location.go @@ -224,6 +224,10 @@ func (l Location) String() string { str += fmt.Sprintf(" AccessPath=%q", l.AccessPath) } + if l.ArchivePath != "" { + str += fmt.Sprintf(" ArchivePath=%q", l.ArchivePath) + } + if l.FileSystemID != "" { str += fmt.Sprintf(" Layer=%q", l.FileSystemID) } @@ -233,5 +237,6 @@ func (l Location) String() string { func (l Location) Equals(other Location) bool { return l.RealPath == other.RealPath && l.AccessPath == other.AccessPath && + l.ArchivePath == other.ArchivePath && l.FileSystemID == other.FileSystemID } diff --git a/syft/file/location_test.go b/syft/file/location_test.go index 36626955b..7bf999637 100644 --- a/syft/file/location_test.go +++ b/syft/file/location_test.go @@ -48,3 +48,12 @@ func TestLocation_ID(t *testing.T) { } } + +func TestLocation_archivePathIsIdentity(t *testing.T) { + a := NewLocationFromCoordinates(Coordinates{RealPath: "META-INF/MANIFEST.MF", ArchivePath: "/a.jar"}) + b := NewLocationFromCoordinates(Coordinates{RealPath: "META-INF/MANIFEST.MF", ArchivePath: "/b.jar"}) + + assert.False(t, a.Equals(b)) + assert.NotEqual(t, a.String(), b.String()) + assert.Contains(t, a.String(), `ArchivePath="/a.jar"`) +} diff --git a/syft/file/locations.go b/syft/file/locations.go index 3f7be072d..df5a281bb 100644 --- a/syft/file/locations.go +++ b/syft/file/locations.go @@ -83,7 +83,12 @@ func LocationSorter(layers []string) func(a, b Location) int { //nolint:gocognit return strings.Compare(a.AccessPath, b.AccessPath) } - return strings.Compare(a.RealPath, b.RealPath) + if a.RealPath != b.RealPath { + return strings.Compare(a.RealPath, b.RealPath) + } + + // the same path in different archives is a different file + return strings.Compare(a.ArchivePath, b.ArchivePath) } } @@ -118,6 +123,11 @@ func CoordinatesSorter(layers []string) func(a, b Coordinates) int { } } - return strings.Compare(a.RealPath, b.RealPath) + if a.RealPath != b.RealPath { + return strings.Compare(a.RealPath, b.RealPath) + } + + // the same path in different archives is a different file + return strings.Compare(a.ArchivePath, b.ArchivePath) } } diff --git a/syft/file/locations_test.go b/syft/file/locations_test.go index 07e903d90..6c57753c7 100644 --- a/syft/file/locations_test.go +++ b/syft/file/locations_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" "github.com/anchore/syft/internal/evidence" ) @@ -555,3 +556,24 @@ func TestLocationAndCoordinatesSorters(t *testing.T) { }) } } + +func TestSorters_archivePathBreaksTies(t *testing.T) { + // the same path in two archives must sort the same way whatever order the inputs arrive in, or + // output built from map iteration is not reproducible + a := Coordinates{RealPath: "META-INF/MANIFEST.MF", ArchivePath: "/app.war:WEB-INF/lib/a.jar"} + b := Coordinates{RealPath: "META-INF/MANIFEST.MF", ArchivePath: "/app.war:WEB-INF/lib/b.jar"} + want := []string{a.ArchivePath, b.ArchivePath} + + for _, in := range [][]Coordinates{{a, b}, {b, a}} { + coords := slices.Clone(in) + slices.SortFunc(coords, CoordinatesSorter(nil)) + assert.Equal(t, want, []string{coords[0].ArchivePath, coords[1].ArchivePath}, "CoordinatesSorter") + + locs := []Location{NewLocationFromCoordinates(in[0]), NewLocationFromCoordinates(in[1])} + slices.SortFunc(locs, LocationSorter(nil)) + assert.Equal(t, want, []string{locs[0].ArchivePath, locs[1].ArchivePath}, "LocationSorter") + + got := NewCoordinateSet(in...).ToSlice() + assert.Equal(t, want, []string{got[0].ArchivePath, got[1].ArchivePath}, "CoordinateSet.ToSlice") + } +} diff --git a/syft/format/syftjson/encoder_test.go b/syft/format/syftjson/encoder_test.go index c07581884..4e6ce11ca 100644 --- a/syft/format/syftjson/encoder_test.go +++ b/syft/format/syftjson/encoder_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "flag" + "fmt" "strings" "testing" @@ -89,6 +90,30 @@ func TestEscapeHTML(t *testing.T) { }) } +func TestEncode_sameFileInManyArchivesIsReproducible(t *testing.T) { + // files and package locations that differ only by archive path come out of map iteration, so the + // output is reproducible only if the sort breaks that tie + s := testutil.DirectoryInput(t, t.TempDir()) + s.Artifacts.FileMetadata = map[file.Coordinates]file.Metadata{} + p := pkg.Package{Name: "shared", Version: "1.0"} + for i := range 20 { + c := file.Coordinates{RealPath: "META-INF/MANIFEST.MF", ArchivePath: fmt.Sprintf("/app.war:WEB-INF/lib/%02d.jar", i)} + s.Artifacts.FileMetadata[c] = file.Metadata{} + p.Locations.Add(file.NewLocationFromCoordinates(c)) + } + s.Artifacts.Packages.Add(p) + + encode := func() string { + var buffer bytes.Buffer + require.NoError(t, NewFormatEncoder().Encode(&buffer, s)) + return buffer.String() + } + first := encode() + for range 10 { + require.Equal(t, first, encode()) + } +} + func TestDirectoryEncoder(t *testing.T) { cfg := DefaultEncoderConfig() cfg.Pretty = true