From f5bac4a0266031375a4700eb8d0e47408912545f Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Thu, 24 Sep 2026 11:26:38 -0400 Subject: [PATCH] fix: build ArchivePath from real paths, not access paths each `ArchivePath` segment came from `loc.Path()`, so it was the link path when an archive was reached through a symlink and the real path otherwise. That depended on how discovery happened to find the archive. The package location said `store/3f9a1c` while the files inside the same jar were recorded under `...:app/lib/json-simple-1.1.1.jar`, so a consumer couldn't join a package to its files by prefix. The chain for extracted files is now built from `RealPath`. `Traversal.Location` still carries the access path, so java's name check and `VirtualPath` metadata behave the same as before. The field comment and schema now say every segment has links resolved. Signed-off-by: Alex Goodman --- internal/archive/traversal.go | 6 ++++-- internal/task/archive_tasks.go | 5 +++-- schema/json/schema-16.1.11.json | 4 ++-- schema/json/schema-latest.json | 4 ++-- syft/archive_cataloger_test.go | 5 +++++ syft/file/coordinates.go | 2 +- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/internal/archive/traversal.go b/internal/archive/traversal.go index f0f6852bf..595f61f43 100644 --- a/internal/archive/traversal.go +++ b/internal/archive/traversal.go @@ -11,8 +11,10 @@ import ( // cataloger task puts one on the context before running catalogers inside an archive, so a cataloger // such as java can describe the archive it is inside without owning the recursion. type Traversal struct { - // Location is the archive file's location within its parent filesystem. Its ArchivePath names the - // archives above it, so VirtualPath(Location) is the full chain from the scan root. + // Location is the archive file's location within its parent filesystem, including the access path + // it was reached by. Its ArchivePath names the archives above it, so VirtualPath(Location) is the + // full chain from the scan root as seen through that access path. The ArchivePath of the files + // inside it is built from the real path instead. Location file.Location // Digests are of the archive file itself, taken during extraction; a cataloger running inside the diff --git a/internal/task/archive_tasks.go b/internal/task/archive_tasks.go index d3c29808b..34e548b97 100644 --- a/internal/task/archive_tasks.go +++ b/internal/task/archive_tasks.go @@ -161,8 +161,9 @@ func (c *archiveCataloger) processArchive(ctx context.Context, parentResolver fi started := time.Now() // extracted files keep the filesystem ID of where the archive was found; the nesting chain is - // carried separately as the archive path - archivePath := archive.VirtualPath(location) + // carried separately as the archive path. The chain names real paths, not the link the archive was + // reached by, so it is stable and joins to the archive's package location by prefix. + archivePath := archive.VirtualPath(file.NewLocationFromCoordinates(location.Coordinates)) extracted, err := archive.Extract(ctx, archiveContent, location.FileSystemID, archivePath, c.limiter, c.exclusions) if errors.Is(err, archive.ErrDiskLimitReached) { // nothing is released while this archive waits, so skip it rather than block the scan diff --git a/schema/json/schema-16.1.11.json b/schema/json/schema-16.1.11.json index a6092eefe..4beb155c3 100644 --- a/schema/json/schema-16.1.11.json +++ b/schema/json/schema-16.1.11.json @@ -724,7 +724,7 @@ }, "archivePath": { "type": "string", - "description": "ArchivePath is the colon-delimited chain of archive paths, from the scan root, traversed to reach this file (e.g. \"app.war:WEB-INF/lib/dep.jar\"). Blank for files not found within an archive. It disambiguates identically-named files in different archives on the same filesystem. Within each segment after the first, '%' is escaped as \"%25\" and ':' as \"%3A\", so splitting on ':' recovers the archive boundaries." + "description": "ArchivePath is the colon-delimited chain of archive paths, from the scan root, traversed to reach this file (e.g. \"app.war:WEB-INF/lib/dep.jar\"). Blank for files not found within an archive. It disambiguates identically-named files in different archives on the same filesystem. Within each segment after the first, '%' is escaped as \"%25\" and ':' as \"%3A\", so splitting on ':' recovers the archive boundaries. Each segment is an archive's real path (links resolved), relative to the segment before it." } }, "type": "object", @@ -2490,7 +2490,7 @@ }, "archivePath": { "type": "string", - "description": "ArchivePath is the colon-delimited chain of archive paths, from the scan root, traversed to reach this file (e.g. \"app.war:WEB-INF/lib/dep.jar\"). Blank for files not found within an archive. It disambiguates identically-named files in different archives on the same filesystem. Within each segment after the first, '%' is escaped as \"%25\" and ':' as \"%3A\", so splitting on ':' recovers the archive boundaries." + "description": "ArchivePath is the colon-delimited chain of archive paths, from the scan root, traversed to reach this file (e.g. \"app.war:WEB-INF/lib/dep.jar\"). Blank for files not found within an archive. It disambiguates identically-named files in different archives on the same filesystem. Within each segment after the first, '%' is escaped as \"%25\" and ':' as \"%3A\", so splitting on ':' recovers the archive boundaries. Each segment is an archive's real path (links resolved), relative to the segment before it." }, "accessPath": { "type": "string", diff --git a/schema/json/schema-latest.json b/schema/json/schema-latest.json index a6092eefe..4beb155c3 100644 --- a/schema/json/schema-latest.json +++ b/schema/json/schema-latest.json @@ -724,7 +724,7 @@ }, "archivePath": { "type": "string", - "description": "ArchivePath is the colon-delimited chain of archive paths, from the scan root, traversed to reach this file (e.g. \"app.war:WEB-INF/lib/dep.jar\"). Blank for files not found within an archive. It disambiguates identically-named files in different archives on the same filesystem. Within each segment after the first, '%' is escaped as \"%25\" and ':' as \"%3A\", so splitting on ':' recovers the archive boundaries." + "description": "ArchivePath is the colon-delimited chain of archive paths, from the scan root, traversed to reach this file (e.g. \"app.war:WEB-INF/lib/dep.jar\"). Blank for files not found within an archive. It disambiguates identically-named files in different archives on the same filesystem. Within each segment after the first, '%' is escaped as \"%25\" and ':' as \"%3A\", so splitting on ':' recovers the archive boundaries. Each segment is an archive's real path (links resolved), relative to the segment before it." } }, "type": "object", @@ -2490,7 +2490,7 @@ }, "archivePath": { "type": "string", - "description": "ArchivePath is the colon-delimited chain of archive paths, from the scan root, traversed to reach this file (e.g. \"app.war:WEB-INF/lib/dep.jar\"). Blank for files not found within an archive. It disambiguates identically-named files in different archives on the same filesystem. Within each segment after the first, '%' is escaped as \"%25\" and ':' as \"%3A\", so splitting on ':' recovers the archive boundaries." + "description": "ArchivePath is the colon-delimited chain of archive paths, from the scan root, traversed to reach this file (e.g. \"app.war:WEB-INF/lib/dep.jar\"). Blank for files not found within an archive. It disambiguates identically-named files in different archives on the same filesystem. Within each segment after the first, '%' is escaped as \"%25\" and ':' as \"%3A\", so splitting on ':' recovers the archive boundaries. Each segment is an archive's real path (links resolved), relative to the segment before it." }, "accessPath": { "type": "string", diff --git a/syft/archive_cataloger_test.go b/syft/archive_cataloger_test.go index 634b86491..129ada767 100644 --- a/syft/archive_cataloger_test.go +++ b/syft/archive_cataloger_test.go @@ -349,10 +349,15 @@ func TestArchiveCataloger_jarReachedThroughALinkIsIdentified(t *testing.T) { cfg := DefaultCreateSBOMConfig(). WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")). + WithFilesConfig(filecataloging.DefaultConfig().WithSelection(file.AllFilesSelection)). WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(2)) s := scanDirWithExclusions(t, dir, cfg) assert.Equal(t, []string{"bundle.tar.gz|store/3f9a1c"}, packageLocations(s, "json-simple")) + + // the jar's own files are addressed by its real path, the same one its package location names + manifest := file.Coordinates{RealPath: "META-INF/MANIFEST.MF", ArchivePath: "bundle.tar.gz:store/3f9a1c"} + assert.Contains(t, s.Artifacts.FileMetadata, manifest) } func TestArchiveCataloger_descriptorRecordsArchiveConfig(t *testing.T) { diff --git a/syft/file/coordinates.go b/syft/file/coordinates.go index 64e03d250..792d1c4e9 100644 --- a/syft/file/coordinates.go +++ b/syft/file/coordinates.go @@ -15,7 +15,7 @@ type Coordinates struct { // FileSystemID is an ID representing an entire filesystem. For container images, this is a layer digest. For directories or a root filesystem, this is blank. A file inside an archive carries the FileSystemID of the filesystem the archive was found in. FileSystemID string `json:"layerID,omitempty" cyclonedx:"layerID"` - // ArchivePath is the colon-delimited chain of archive paths, from the scan root, traversed to reach this file (e.g. "app.war:WEB-INF/lib/dep.jar"). Blank for files not found within an archive. It disambiguates identically-named files in different archives on the same filesystem. Within each segment after the first, '%' is escaped as "%25" and ':' as "%3A", so splitting on ':' recovers the archive boundaries. + // ArchivePath is the colon-delimited chain of archive paths, from the scan root, traversed to reach this file (e.g. "app.war:WEB-INF/lib/dep.jar"). Blank for files not found within an archive. It disambiguates identically-named files in different archives on the same filesystem. Within each segment after the first, '%' is escaped as "%25" and ':' as "%3A", so splitting on ':' recovers the archive boundaries. Each segment is an archive's real path (links resolved), relative to the segment before it. ArchivePath string `json:"archivePath,omitempty" cyclonedx:"archivePath"` }