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 <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2026-09-24 11:23:54 -04:00
parent 78c4485757
commit eec86cf88d
5 changed files with 20 additions and 15 deletions
+11 -6
View File
@@ -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{}
+4 -4
View File
@@ -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) {