diff --git a/internal/archive/traversal.go b/internal/archive/traversal.go index 20a21f303..6d9d63705 100644 --- a/internal/archive/traversal.go +++ b/internal/archive/traversal.go @@ -52,12 +52,20 @@ func VirtualPath(loc file.Location) string { return loc.ArchivePath + ":" + strings.TrimPrefix(escapeEntryPath(loc.Path()), "/") } -var entryPathEscaper = strings.NewReplacer("%", "%25", ":", "%3A") +var ( + entryPathEscaper = strings.NewReplacer("%", "%25", ":", "%3A") + entryPathUnescaper = strings.NewReplacer("%3A", ":", "%25", "%") +) func escapeEntryPath(p string) string { return entryPathEscaper.Replace(p) } +// UnescapeEntryPath reverses the escaping VirtualPath applies to one segment of the chain. +func UnescapeEntryPath(p string) string { + return entryPathUnescaper.Replace(p) +} + type traversalCtxKey struct{} func WithTraversal(ctx context.Context, t *Traversal) context.Context { diff --git a/syft/format/common/spdxhelpers/to_format_model.go b/syft/format/common/spdxhelpers/to_format_model.go index 12e67ec5c..41b193700 100644 --- a/syft/format/common/spdxhelpers/to_format_model.go +++ b/syft/format/common/spdxhelpers/to_format_model.go @@ -14,6 +14,7 @@ import ( "github.com/spdx/tools-golang/spdx" "github.com/anchore/packageurl-go" + "github.com/anchore/syft/internal/archive" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/mimetype" "github.com/anchore/syft/internal/relationship" @@ -677,6 +678,12 @@ func lookupRelationship(ty artifact.RelationshipType) (bool, helpers.Relationshi return false, "", "" } +// file comments carry what an SPDX file has no field for, one "key: value" per line +const ( + layerIDCommentPrefix = "layerID: " + archivePathCommentPrefix = "archivePath: " +) + func toFiles(s sbom.SBOM) (results []*spdx.File) { artifacts := s.Artifacts @@ -707,17 +714,27 @@ func toFiles(s sbom.SBOM) (results []*spdx.File) { // TODO: add file classifications (?) and content as a snippet - var comment string + var comments []string if c.FileSystemID != "" { - comment = fmt.Sprintf("layerID: %s", c.FileSystemID) + comments = append(comments, layerIDCommentPrefix+c.FileSystemID) } - relativePath, err := convertAbsoluteToRelative(c.RealPath) - if err != nil { - log.Debugf("unable to convert relative path '%s' to absolute path: %s", c.RealPath, err) - relativePath = c.RealPath + // a file inside an archive is named by its full chain ("app.war:WEB-INF/lib/a.jar:META-INF/MANIFEST.MF"), + // so a consumer reading only the name does not mistake it for a file at that path in the scan + // root. The archive path is also kept in the comment so decoding need not guess where it ends. + name := c.RealPath + if c.ArchivePath != "" { + name = archive.VirtualPath(file.NewLocationFromCoordinates(c)) + comments = append(comments, archivePathCommentPrefix+c.ArchivePath) } + relativePath, err := convertAbsoluteToRelative(name) + if err != nil { + log.Debugf("unable to convert relative path '%s' to absolute path: %s", name, err) + relativePath = name + } + comment := strings.Join(comments, "\n") + results = append(results, &spdx.File{ FileSPDXIdentifier: toSPDXID(c), FileComment: comment, diff --git a/syft/format/common/spdxhelpers/to_format_model_test.go b/syft/format/common/spdxhelpers/to_format_model_test.go index 57ca27db2..91325aa26 100644 --- a/syft/format/common/spdxhelpers/to_format_model_test.go +++ b/syft/format/common/spdxhelpers/to_format_model_test.go @@ -1131,3 +1131,29 @@ func Test_otherLicenses(t *testing.T) { }) } } + +func Test_toFiles_nestedFilesAreNamedByTheirChainAndRoundTrip(t *testing.T) { + // two archives' META-INF/MANIFEST.MF must not come out as two files with the same name and nothing + // saying which archive each is in + root := file.Coordinates{RealPath: "/etc/os-release", FileSystemID: "sha256:layer"} + inA := file.Coordinates{RealPath: "META-INF/MANIFEST.MF", ArchivePath: "/app.war:WEB-INF/lib/a.jar", FileSystemID: "sha256:layer"} + inB := file.Coordinates{RealPath: "odd:na%me.txt", ArchivePath: "/app.war:WEB-INF/lib/b.jar"} + s := sbom.SBOM{Artifacts: sbom.Artifacts{FileMetadata: map[file.Coordinates]file.Metadata{root: {}, inA: {}, inB: {}}}} + + byName := map[string]*spdx.File{} + for _, f := range toFiles(s) { + byName[f.FileName] = f + } + require.Len(t, byName, 3) + + assert.Equal(t, "layerID: sha256:layer", byName["etc/os-release"].FileComment, "files outside archives are unchanged") + + a := byName["app.war:WEB-INF/lib/a.jar:META-INF/MANIFEST.MF"] + require.NotNil(t, a) + assert.Equal(t, "layerID: sha256:layer\narchivePath: /app.war:WEB-INF/lib/a.jar", a.FileComment) + assert.Equal(t, inA, toSyftCoordinates(a)) + + b := byName["app.war:WEB-INF/lib/b.jar:odd%3Ana%25me.txt"] + require.NotNil(t, b, "names: %v", byName) + assert.Equal(t, inB, toSyftCoordinates(b), "the entry path's escaping is undone on decode") +} diff --git a/syft/format/common/spdxhelpers/to_syft_model.go b/syft/format/common/spdxhelpers/to_syft_model.go index 59a40957d..4941a7898 100644 --- a/syft/format/common/spdxhelpers/to_syft_model.go +++ b/syft/format/common/spdxhelpers/to_syft_model.go @@ -15,6 +15,7 @@ import ( "github.com/spdx/tools-golang/spdx/v2/common" "github.com/anchore/packageurl-go" + "github.com/anchore/syft/internal/archive" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/spdxlicense" "github.com/anchore/syft/syft/artifact" @@ -470,23 +471,28 @@ func collectPackageFileRelationships(spdxIDMap map[string]any, doc *spdx.Documen } func toSyftCoordinates(f *spdx.File) file.Coordinates { - const layerIDPrefix = "layerID: " - var fileSystemID string - if strings.HasPrefix(f.FileComment, layerIDPrefix) { - fileSystemID = strings.TrimPrefix(f.FileComment, layerIDPrefix) + c := file.Coordinates{RealPath: f.FileName} + for _, line := range strings.Split(f.FileComment, "\n") { + if v, ok := strings.CutPrefix(line, layerIDCommentPrefix); ok { + c.FileSystemID = v + } + if v, ok := strings.CutPrefix(line, archivePathCommentPrefix); ok { + c.ArchivePath = v + } } - if strings.HasPrefix(string(f.FileSPDXIdentifier), layerIDPrefix) { - fileSystemID = strings.TrimPrefix(string(f.FileSPDXIdentifier), layerIDPrefix) + if v, ok := strings.CutPrefix(string(f.FileSPDXIdentifier), layerIDCommentPrefix); ok { + c.FileSystemID = v } - return file.Coordinates{ - RealPath: f.FileName, - FileSystemID: fileSystemID, + if c.ArchivePath != "" { + // the name is the chain; the file's own path is its last, escaped, segment + c.RealPath = archive.UnescapeEntryPath(f.FileName[strings.LastIndexByte(f.FileName, ':')+1:]) } + return c } func toSyftLocation(f *spdx.File) file.Location { - l := file.NewVirtualLocationFromCoordinates(toSyftCoordinates(f), f.FileName) - return l + c := toSyftCoordinates(f) + return file.NewVirtualLocationFromCoordinates(c, c.RealPath) } func requireAndTrimPrefix(val any, prefix string) string {