From f0129112be30a52084bde4334686ae6bfb45f833 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Wed, 21 Oct 2026 11:32:50 -0400 Subject: [PATCH] fix: normalize decoded `FullPath` Nothing checked that a decoded `FullPath` actually extends `RealPath` with an archive chain. The SPDX decoder sets `FullPath` to the file name even when the name doesn't end with the comment's chain, and syft-json takes whatever is in the document. `{RealPath: "/x", FullPath: "/x"}` and `{RealPath: "/x"}` share an ID but are different map keys, so decoded files, unknowns and locations could fail to line up with the same coordinates built by a resolver. The syft-json and SPDX decoders now clear a `FullPath` that equals `RealPath` or doesn't start with `RealPath` + `!/`. Signed-off-by: Alex Goodman --- .../common/spdxhelpers/to_syft_model.go | 2 +- .../common/spdxhelpers/to_syft_model_test.go | 9 ++++++ syft/format/internal/coordinates.go | 25 +++++++++++++++ syft/format/syftjson/to_syft_model.go | 8 ++--- syft/format/syftjson/to_syft_model_test.go | 32 +++++++++++++++++++ 5 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 syft/format/internal/coordinates.go diff --git a/syft/format/common/spdxhelpers/to_syft_model.go b/syft/format/common/spdxhelpers/to_syft_model.go index ad79dbc01..b3a281570 100644 --- a/syft/format/common/spdxhelpers/to_syft_model.go +++ b/syft/format/common/spdxhelpers/to_syft_model.go @@ -549,7 +549,7 @@ func coordinatesFromFile(name, comment, id string) file.Coordinates { if v, ok := strings.CutPrefix(id, layerIDCommentPrefix); ok { c.FileSystemID = v } - return c + return internal.NormalizeCoordinates(c) } func toSyftLocation(f *spdx.File) file.Location { diff --git a/syft/format/common/spdxhelpers/to_syft_model_test.go b/syft/format/common/spdxhelpers/to_syft_model_test.go index ca94f9cc8..d77d48b79 100644 --- a/syft/format/common/spdxhelpers/to_syft_model_test.go +++ b/syft/format/common/spdxhelpers/to_syft_model_test.go @@ -789,3 +789,12 @@ func Test_skipsPackagesWithGeneratedFromRelationship(t *testing.T) { assert.NotNil(t, s.Artifacts.Packages.Package("1")) assert.Nil(t, s.Artifacts.Packages.Package("1-src")) } + +func Test_coordinatesFromFile_nonCanonicalChainIsDropped(t *testing.T) { + // a chain the name doesn't end with leaves FullPath equal to RealPath, which must decode as a plain file + got := coordinatesFromFile("/app.jar", "archivePath: other.jar!/x", "") + assert.Equal(t, file.NewCoordinates("/app.jar", ""), got) + + nested := coordinatesFromFile("/app.war!/lib/a.jar", "archivePath: lib/a.jar", "") + assert.Equal(t, file.Coordinates{RealPath: "/app.war", FullPath: "/app.war!/lib/a.jar"}, nested) +} diff --git a/syft/format/internal/coordinates.go b/syft/format/internal/coordinates.go new file mode 100644 index 000000000..5114b1cd3 --- /dev/null +++ b/syft/format/internal/coordinates.go @@ -0,0 +1,25 @@ +package internal + +import ( + "strings" + + "github.com/anchore/syft/syft/file" +) + +// NormalizeCoordinates clears a decoded FullPath that adds no archive chain to RealPath (one equal to +// RealPath, or one not starting with RealPath and "!/"), so decoded coordinates compare equal to the +// ones a resolver builds. +func NormalizeCoordinates(c file.Coordinates) file.Coordinates { + if !strings.HasPrefix(c.FullPath, c.RealPath+file.ArchivePathSeparator) { + c.FullPath = "" + } + return c +} + +// NormalizeLocations applies NormalizeCoordinates to each location in place. +func NormalizeLocations(locations []file.Location) []file.Location { + for i := range locations { + locations[i].Coordinates = NormalizeCoordinates(locations[i].Coordinates) + } + return locations +} diff --git a/syft/format/syftjson/to_syft_model.go b/syft/format/syftjson/to_syft_model.go index a66db17d0..b65ceb60a 100644 --- a/syft/format/syftjson/to_syft_model.go +++ b/syft/format/syftjson/to_syft_model.go @@ -80,7 +80,7 @@ func toSyftFiles(files []model.File) sbom.Artifacts { } for _, f := range files { - coord := f.Location + coord := internal.NormalizeCoordinates(f.Location) if f.Metadata != nil { fm, err := safeFileModeConvert(f.Metadata.Mode) if err != nil { @@ -163,7 +163,7 @@ func toSyftLicenses(m []model.License) (p []pkg.License) { SPDXExpression: l.SPDXExpression, Type: l.Type, URLs: l.URLs, - Locations: file.NewLocationSet(l.Locations...), + Locations: file.NewLocationSet(internal.NormalizeLocations(l.Locations)...), Contents: l.Contents, }) } @@ -239,7 +239,7 @@ func toSyftRelationships(doc *model.Document, catalog *pkg.Collection, relations idMap[doc.Source.ID] = toSyftSource(doc.Source) for _, f := range doc.Files { - idMap[f.ID] = f.Location + idMap[f.ID] = internal.NormalizeCoordinates(f.Location) } var out []artifact.Relationship @@ -346,7 +346,7 @@ func toSyftPackage(p model.Package, idAliases map[string]string) pkg.Package { Name: p.Name, Version: p.Version, FoundBy: p.FoundBy, - Locations: file.NewLocationSet(p.Locations...), + Locations: file.NewLocationSet(internal.NormalizeLocations(p.Locations)...), Licenses: pkg.NewLicenseSet(toSyftLicenses(p.Licenses)...), Language: p.Language, Type: p.Type, diff --git a/syft/format/syftjson/to_syft_model_test.go b/syft/format/syftjson/to_syft_model_test.go index 6e4b4cc68..884950983 100644 --- a/syft/format/syftjson/to_syft_model_test.go +++ b/syft/format/syftjson/to_syft_model_test.go @@ -599,3 +599,35 @@ func Test_safeFileModeConvert(t *testing.T) { }) } } + +func Test_toSyftModel_fullPathIsNormalized(t *testing.T) { + plain := file.NewCoordinates("/app.jar", "") + nested := file.Coordinates{RealPath: "/app.war", FullPath: "/app.war!/lib/a.jar"} + decoded := []file.Coordinates{ + {RealPath: "/app.jar", FullPath: "/app.jar"}, + {RealPath: "/app.jar", FullPath: "/elsewhere!/app.jar"}, + nested, + } + want := []file.Coordinates{plain, plain, nested} + + var files []model.File + for _, c := range decoded { + files = append(files, model.File{ID: string(c.ID()), Location: c, Unknowns: []string{"x"}}) + } + got := toSyftFiles(files) + for _, c := range want { + assert.Contains(t, got.Unknowns, c) + } + assert.Len(t, got.Unknowns, 2) + + var locations []file.Location + for _, c := range decoded { + locations = append(locations, file.NewLocationFromCoordinates(c)) + } + p := toSyftPackage(model.Package{PackageBasicData: model.PackageBasicData{Locations: locations}}, nil) + var gotCoordinates []file.Coordinates + for _, l := range p.Locations.ToSlice() { + gotCoordinates = append(gotCoordinates, l.Coordinates) + } + assert.ElementsMatch(t, []file.Coordinates{plain, plain, nested}, gotCoordinates) +}