From eec86cf88d795868407b9e0f61733b9e99763722 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Thu, 24 Sep 2026 11:23:54 -0400 Subject: [PATCH] fix: escape % in ArchivePath segments `:` was escaped as `%3A`, but `%` itself wasn't, so an entry named `a:b.jar` and one named `a%3Ab.jar` got the same `ArchivePath`. Their contents then collide in `FileMetadata` / `FileDigests` (last writer wins) and share artifact IDs. `%` is now escaped as `%25` before `:`, which makes the encoding injective. The encoding is documented on `Coordinates.ArchivePath` and in the schema. Signed-off-by: Alex Goodman --- internal/archive/traversal.go | 17 +++++++++++------ internal/archive/traversal_test.go | 8 ++++---- schema/json/schema-16.1.11.json | 4 ++-- schema/json/schema-latest.json | 4 ++-- syft/file/coordinates.go | 2 +- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/internal/archive/traversal.go b/internal/archive/traversal.go index d4bc81d44..f0f6852bf 100644 --- a/internal/archive/traversal.go +++ b/internal/archive/traversal.go @@ -21,16 +21,21 @@ type Traversal struct { } // VirtualPath returns the colon-delimited chain of archives from the scan root to loc, ending in loc -// itself: "app.war:WEB-INF/lib/dep.jar". Outside any archive it is the location's path unchanged. A -// colon in an entry name is escaped so splitting on ':' recovers the archive boundaries; the leading -// slash is dropped to match the java cataloger, which joins slash-less zip entry names onto the -// containing archive's path. +// itself: "app.war:WEB-INF/lib/dep.jar". Outside any archive it is the location's path unchanged. In an +// entry name '%' is escaped as "%25" and then ':' as "%3A", so splitting on ':' recovers the archive +// boundaries and no two entry names share an escaped form. The leading slash is dropped to match the +// java cataloger, which joins slash-less zip entry names onto the containing archive's path. func VirtualPath(loc file.Location) string { if loc.ArchivePath == "" { return loc.Path() } - entryPath := strings.ReplaceAll(loc.Path(), ":", "%3A") - return loc.ArchivePath + ":" + strings.TrimPrefix(entryPath, "/") + return loc.ArchivePath + ":" + strings.TrimPrefix(escapeEntryPath(loc.Path()), "/") +} + +var entryPathEscaper = strings.NewReplacer("%", "%25", ":", "%3A") + +func escapeEntryPath(p string) string { + return entryPathEscaper.Replace(p) } type traversalCtxKey struct{} diff --git a/internal/archive/traversal_test.go b/internal/archive/traversal_test.go index f53f8ae26..62f48881c 100644 --- a/internal/archive/traversal_test.go +++ b/internal/archive/traversal_test.go @@ -85,14 +85,14 @@ func TestVirtualPath(t *testing.T) { } } -func TestVirtualPath_theEscapeCharacterIsNotItselfEscaped(t *testing.T) { - // an accepted limit: an entry path holding the literal text %3A is indistinguishable from one holding - // a colon. Escaping % too would move many more real identities than it protects. +func TestVirtualPath_theEscapeCharacterIsItselfEscaped(t *testing.T) { + // an entry path holding the literal text %3A must not collide with one holding a colon, or their + // contents share an ArchivePath and overwrite each other's file metadata fromColon := VirtualPath(locationIn("outer.zip", "a:b.jar")) fromLiteral := VirtualPath(locationIn("outer.zip", "a%3Ab.jar")) assert.Equal(t, "outer.zip:a%3Ab.jar", fromColon) - assert.Equal(t, fromColon, fromLiteral) + assert.Equal(t, "outer.zip:a%253Ab.jar", fromLiteral) } func TestVirtualPath_escapingComposesAcrossNestingLevels(t *testing.T) { diff --git a/schema/json/schema-16.1.11.json b/schema/json/schema-16.1.11.json index 02c8c1b3d..a6092eefe 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." + "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." } }, "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." + "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." }, "accessPath": { "type": "string", diff --git a/schema/json/schema-latest.json b/schema/json/schema-latest.json index 02c8c1b3d..a6092eefe 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." + "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." } }, "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." + "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." }, "accessPath": { "type": "string", diff --git a/syft/file/coordinates.go b/syft/file/coordinates.go index bf1bde3cd..64e03d250 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. + // 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 string `json:"archivePath,omitempty" cyclonedx:"archivePath"` }