fix: make ArchivePath part of file ordering and Location identity

syft-json output wasn't reproducible for nested archives. `AllCoordinates()` comes from map iteration, and the coordinate and location sorters ignored `ArchivePath`, so the same path in two archives (e.g. `META-INF/MANIFEST.MF` in two jars in a war) compared equal and came out in random order. Package locations had the same problem.

- `ArchivePath` is now the final tiebreak in `LocationSorter`, `CoordinatesSorter` and the `CoordinateSet.ToSlice` fallback
- `Location.Equals` and `Location.String` include `ArchivePath`, which matches `Coordinates`, since the same path in different archives is a different file

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2026-09-24 11:22:52 -04:00
parent ccc4832ff6
commit 78c4485757
6 changed files with 76 additions and 2 deletions
+3
View File
@@ -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
+5
View File
@@ -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
}
+9
View File
@@ -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"`)
}
+12 -2
View File
@@ -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)
}
}
+22
View File
@@ -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")
}
}
+25
View File
@@ -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