fix: unknowns, better full vs local path handling

Signed-off-by: Keith Zantow <kzantow@gmail.com>
This commit is contained in:
Keith Zantow
2026-10-20 11:15:17 -04:00
parent 3cdf97ebad
commit a1f7c52d89
25 changed files with 619 additions and 225 deletions
+29 -11
View File
@@ -16,10 +16,13 @@ import (
"github.com/anchore/syft/syft/file"
)
// ErrUnsupportedFormat is returned for an archive in a format that is not extracted (see supportedExtractor).
var ErrUnsupportedFormat = errors.New("archive format is not supported")
// Extract opens the archive read from r as its own filesystem, holding its bytes and entries within
// the limiter's bounds. It returns nil when the content is not an archive it can read, and
// ErrDiskLimitReached when the archive's own bytes cannot be placed within the disk limit. The caller
// must Cleanup the result.
// the limiter's bounds. It returns nil when the content is not an archive, ErrUnsupportedFormat when it
// is one that is not extracted, and ErrDiskLimitReached when the archive's own bytes cannot be placed
// within the disk limit. The caller must Cleanup the result.
//
// archiveCoordinates are those of the archive file itself; every location the resolver returns
// extends them with its entry path (see file.Coordinates).
@@ -43,9 +46,9 @@ func Extract(ctx context.Context, r io.Reader, archiveCoordinates file.Coordinat
// the archive's own bytes are needed only until they are extracted and digested
defer release()
format := identifyFormat(ctx, path.Base(archiveCoordinates.LocalPath()), content)
if format == nil {
return nil, nil
format, err := identifyFormat(ctx, path.Base(archiveCoordinates.LocalPath()), content)
if format == nil || err != nil {
return nil, err
}
if err := resolver.extract(ctx, format, content, exclusions); err != nil {
@@ -81,20 +84,35 @@ func (r *Resolver) acquire(content io.Reader) (ReaderAtSeeker, func(), error) {
// directory, and reading the tar as a zip would lose the rest. The end of the stream is consulted
// only when the head identifies nothing supported, which is how a zip appended to a launcher script (a
// Spring Boot executable jar) is found, and how a java resource adapter (a zip named .rar) is read.
func identifyFormat(ctx context.Context, name string, content ReaderAtSeeker) archives.Extractor {
func identifyFormat(ctx context.Context, name string, content ReaderAtSeeker) (archives.Extractor, error) {
if _, err := content.Seek(0, io.SeekStart); err != nil {
return nil
return nil, nil
}
format, _, err := intFile.IdentifyArchive(ctx, name, content)
if err == nil {
if extractor := supportedExtractor(format); extractor != nil {
return extractor
return extractor, nil
}
}
if HasZipEndOfCentralDirectory(content) {
return archives.Zip{}
return archives.Zip{}, nil
}
return nil
if err == nil && holdsFiles(format) {
return nil, fmt.Errorf("%w: %s", ErrUnsupportedFormat, format.MediaType())
}
return nil, nil
}
// holdsFiles reports whether the format is an archive of files rather than a bare compression format,
// such as a gzipped man page, which holds one stream and is not cataloged as an archive.
func holdsFiles(format archives.Format) bool {
switch f := format.(type) {
case archives.CompressedArchive:
return f.Extraction != nil
case archives.Extractor:
return true
}
return false
}
// supportedExtractor admits only the zip and tar families, the tar family optionally compressed. Other
+26 -16
View File
@@ -33,27 +33,35 @@ func Test_identifyFormat(t *testing.T) {
launcher := append([]byte("#!/bin/bash\nexec java -jar \"$0\" \"$@\"\nexit 0\n"), zipBytes(t, sampleFiles)...)
tests := []struct {
name string
data []byte
want archives.Extractor
name string
data []byte
want archives.Extractor
unsupported bool
}{
{"test.zip", zipBytes(t, sampleFiles), archives.Zip{}},
{"test.tar.gz", tarGzBytes(t, sampleFiles), archives.CompressedArchive{}},
{"plain.txt", []byte("just text"), nil},
{"test.zip", zipBytes(t, sampleFiles), archives.Zip{}, false},
{"test.tar.gz", tarGzBytes(t, sampleFiles), archives.CompressedArchive{}, false},
{"plain.txt", []byte("just text"), nil, false},
// identified by mholt, but not opened until their decoders are deliberately supported
{"test.7z", []byte("7z\xbc\xaf\x27\x1c\x00\x04"), nil},
{"test.rar", []byte("Rar!\x1a\x07\x01\x00"), nil},
{"test.7z", []byte("7z\xbc\xaf\x27\x1c\x00\x04"), nil, true},
{"test.rar", []byte("Rar!\x1a\x07\x01\x00"), nil, true},
// a bare compression format holds one stream, not files
{"page.1.gz", gzBytes(t, "a man page"), nil, false},
// a java resource adapter archive is a zip that shares rar's extension
{"adapter.rar", zipBytes(t, sampleFiles), archives.Zip{}},
{"adapter.rar", zipBytes(t, sampleFiles), archives.Zip{}, false},
// the name can be claimed by identification, so the probe alone must not admit prose
{"app.jar", []byte("plain text, no central directory anywhere in it"), nil},
{"bundle", []byte("plain text, no central directory anywhere in it"), nil},
{"app.jar", []byte("plain text, no central directory anywhere in it"), nil, false},
{"bundle", []byte("plain text, no central directory anywhere in it"), nil, false},
// content sniffing types this as a shell script; only its tail says zip
{"app.jar", launcher, archives.Zip{}},
{"app.jar", launcher, archives.Zip{}, false},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%s/%d bytes", tt.name, len(tt.data)), func(t *testing.T) {
got := identifyFormat(ctx, tt.name, bytes.NewReader(tt.data))
got, err := identifyFormat(ctx, tt.name, bytes.NewReader(tt.data))
if tt.unsupported {
assert.ErrorIs(t, err, ErrUnsupportedFormat)
} else {
assert.NoError(t, err)
}
if tt.want == nil {
assert.Nil(t, got)
return
@@ -227,7 +235,9 @@ func TestResolver_extract_excludedEntriesAreNeverStored(t *testing.T) {
r, _ := resolverIn(t, limiter.charge())
content := bytes.NewReader(data)
err := r.extract(context.Background(), identifyFormat(context.Background(), "test.zip", content), content,
format, err := identifyFormat(context.Background(), "test.zip", content)
require.NoError(t, err)
err = r.extract(context.Background(), format, content,
NewExclusions([]string{"**/vendor", "**/*.rpm"}))
require.NoError(t, err)
require.NoError(t, r.Truncated)
@@ -314,7 +324,7 @@ func TestExtract_aTruncatedStreamKeepsWhatWasRead(t *testing.T) {
assert.Error(t, extracted.Truncated)
locations, err := extracted.FilesByGlob("**")
require.NoError(t, err)
assert.Equal(t, []string{"a.txt"}, localPaths(locations))
assert.Equal(t, []string{"/a.txt"}, localPaths(locations))
}
func TestExtract_aPanicStillReleasesEverything(t *testing.T) {
@@ -536,7 +546,7 @@ func TestExtract_locationsKeepTheTopLevelRealPath(t *testing.T) {
require.NoError(t, err)
require.Len(t, locations, 1)
assert.Equal(t, file.Coordinates{RealPath: "/app.zip", FullPath: "/app.zip!/dir/hello.txt"}, locations[0].Coordinates)
assert.Equal(t, "dir/hello.txt", locations[0].LocalPath())
assert.Equal(t, "/dir/hello.txt", locations[0].LocalPath())
for _, loc := range []file.Location{
locations[0],
+12 -1
View File
@@ -109,6 +109,16 @@ func zipFromEntries(t testing.TB, entries []testEntry) []byte {
return buf.Bytes()
}
func gzBytes(t testing.TB, body string) []byte {
t.Helper()
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
_, err := gw.Write([]byte(body))
require.NoError(t, err)
require.NoError(t, gw.Close())
return buf.Bytes()
}
func tarGzBytes(t testing.TB, files map[string]string) []byte {
return tarGzFromEntries(t, fileEntries(files))
}
@@ -216,7 +226,8 @@ func extractBytes(t testing.TB, data []byte, name string, charge *charge) (*Reso
t.Helper()
r, root := resolverIn(t, charge)
content := bytes.NewReader(data)
format := identifyFormat(context.Background(), name, content)
format, err := identifyFormat(context.Background(), name, content)
require.NoError(t, err)
require.NotNil(t, format, "fixture must identify as an archive")
require.NoError(t, r.extract(context.Background(), format, content, nil))
return r, root
+48 -15
View File
@@ -9,6 +9,7 @@ import (
"io/fs"
"os"
"path"
"slices"
"sort"
"strings"
"time"
@@ -234,19 +235,39 @@ func entryPath(rooted string) string {
// fullPath is how paths leave the resolver: the archive's own full path, then the entry path.
func (r *Resolver) fullPath(rooted string) string {
return r.archiveCoordinates.FullNestedPath() + "!/" + entryPath(rooted)
return r.archiveCoordinates.FullNestedPath() + file.ArchivePathSeparator + entryPath(rooted)
}
// rootedPath is the tree path a query names, which is either form a path takes: a full path is trimmed to
// the entry path within this archive, and anything else (a LocalPath) is taken as archive-relative
// already. The archive's full path with a trailing "!" (what path.Dir gives for an entry at the root)
// names the root.
func (r *Resolver) rootedPath(userPath string) string {
archivePath := r.archiveCoordinates.FullNestedPath() + "!"
if userPath == archivePath {
return "/"
// names the root. A full path into another archive in the scan names nothing here and reports false.
func (r *Resolver) rootedPath(userPath string) (string, bool) {
if rest, ok := strings.CutPrefix(userPath, r.archiveCoordinates.FullNestedPath()); ok {
if rest == file.ArchivePathSeparator[:1] {
return "/", true
}
if strings.HasPrefix(rest, file.ArchivePathSeparator) {
// keep the separator's slash as the rooted path's
return path.Clean(rest[len(file.ArchivePathSeparator)-1:]), true
}
}
return path.Clean("/" + strings.TrimPrefix(userPath, archivePath+"/"))
if r.outside(userPath) {
return "", false
}
if !strings.HasPrefix(userPath, "/") {
userPath = "/" + userPath
}
return path.Clean(userPath), true
}
// outside reports whether a path not under this archive's full path is a full path from the scan
// nonetheless: every full path in this archive tree starts with the outermost archive's real path and a
// separator. The real path alone is taken as an entry path, so an entry named like the outermost archive
// is found by its LocalPath.
func (r *Resolver) outside(userPath string) bool {
rest, ok := strings.CutPrefix(userPath, r.archiveCoordinates.RealPath)
return ok && strings.HasPrefix(rest, file.ArchivePathSeparator)
}
// coordinates name an entry by the outermost archive's real path, which never includes a path inside an
@@ -272,7 +293,11 @@ func (r *Resolver) location(res stereoscopeFile.Resolution) file.Location {
// resolve finds the file a path names, following links, and reports false for directories and
// paths the archive never listed.
func (r *Resolver) resolve(userPath string) (*stereoscopeFile.Resolution, bool) {
res, err := r.search.SearchByPath(r.rootedPath(userPath), filetree.FollowBasenameLinks)
rooted, ok := r.rootedPath(userPath)
if !ok {
return nil, false
}
res, err := r.search.SearchByPath(rooted, filetree.FollowBasenameLinks)
if err != nil || res == nil || !res.HasReference() {
return nil, false
}
@@ -292,7 +317,10 @@ func (r *Resolver) FileContentsByLocation(location file.Location) (io.ReadCloser
}
func (r *Resolver) FileMetadataByLocation(location file.Location) (file.Metadata, error) {
rooted := r.rootedPath(location.FullNestedPath())
rooted, ok := r.rootedPath(location.FullNestedPath())
if !ok {
return file.Metadata{}, fmt.Errorf("no archive entry for path=%q", location.FullNestedPath())
}
exists, res, err := r.tree.File(stereoscopeFile.Path(rooted))
if err != nil || !exists || !res.HasReference() {
return file.Metadata{}, fmt.Errorf("no archive entry for path=%q", location.FullNestedPath())
@@ -311,18 +339,19 @@ func (r *Resolver) FileMetadataByLocation(location file.Location) (file.Metadata
}
func (r *Resolver) HasPath(userPath string) bool {
return r.tree.HasPath(stereoscopeFile.Path(r.rootedPath(userPath)))
rooted, ok := r.rootedPath(userPath)
return ok && r.tree.HasPath(stereoscopeFile.Path(rooted))
}
func (r *Resolver) FilesByPath(paths ...string) ([]file.Location, error) {
var out []file.Location
seen := stereoscopeFile.NewFileReferenceSet()
var seen []stereoscopeFile.ID // few paths are asked for at once
for _, userPath := range paths {
res, ok := r.resolve(userPath)
if !ok || seen.Contains(*res.Reference) {
if !ok || slices.Contains(seen, res.ID()) {
continue
}
seen.Add(*res.Reference)
seen = append(seen, res.ID())
out = append(out, r.location(*res))
}
return out, nil
@@ -332,7 +361,11 @@ func (r *Resolver) FilesByGlob(patterns ...string) ([]file.Location, error) {
var out []file.Location
seen := stereoscopeFile.NewFileReferenceSet()
for _, pattern := range patterns {
results, err := r.searchByGlob(r.rootedPath(pattern))
rooted, ok := r.rootedPath(pattern)
if !ok {
continue
}
results, err := r.searchByGlob(rooted)
if err != nil {
return nil, fmt.Errorf("invalid glob %q: %w", pattern, err)
}
@@ -387,7 +420,7 @@ func (r *Resolver) FilesByMIMEType(types ...string) ([]file.Location, error) {
return out, nil
}
// RelativeFileByPath resolves against the archive root, the only frame an archive has.
// RelativeFileByPath resolves against the archive root: an archive has no layers to be relative to.
func (r *Resolver) RelativeFileByPath(_ file.Location, userPath string) *file.Location {
locations, _ := r.FilesByPath(userPath)
if len(locations) == 0 {
+47 -19
View File
@@ -113,6 +113,34 @@ func TestResolver_pathsAndContents(t *testing.T) {
assert.Equal(t, "Manifest-Version: 1.0\n", string(body))
}
func TestResolver_fullPathsOutsideTheArchiveNameNothing(t *testing.T) {
r, _ := resolverIn(t, memCharge(1<<20))
r.archiveCoordinates = file.Coordinates{RealPath: "/app.war", FullPath: "/app.war!/WEB-INF/lib/dep.jar"}
addAll(t, r, fileEntries(map[string]string{"/app.war": "same name", "/META-INF/MANIFEST.MF": "m"}))
locations, err := r.FilesByPath("/app.war!/WEB-INF/lib/dep.jar!/META-INF/MANIFEST.MF")
require.NoError(t, err)
assert.Len(t, locations, 1, "a full path in this archive")
for _, p := range []string{"/app.war!/WEB-INF/lib/dep.jar", "/app.war!/WEB-INF/lib/other.jar!/META-INF/MANIFEST.MF"} {
locations, err = r.FilesByPath(p)
require.NoError(t, err)
assert.Empty(t, locations, "%s is outside the archive", p)
assert.False(t, r.HasPath(p), p)
}
for _, p := range []string{"app.war", "/app.war"} {
locations, err = r.FilesByPath(p)
require.NoError(t, err)
require.Len(t, locations, 1, "%s is an entry path named like the outermost archive", p)
assert.Equal(t, "/app.war!/WEB-INF/lib/dep.jar!/app.war", locations[0].FullPath, p)
}
again, err := r.FilesByPath(locations[0].LocalPath())
require.NoError(t, err)
assert.Equal(t, locations, again, "a LocalPath finds its own entry")
}
func TestResolver_mimeTypesAreSniffedFromContent(t *testing.T) {
r := resolverOver(t, 1<<20, map[string]string{
"notes.txt": "just some text",
@@ -121,7 +149,7 @@ func TestResolver_mimeTypesAreSniffedFromContent(t *testing.T) {
locations, err := r.FilesByMIMEType("text/x-shellscript")
require.NoError(t, err)
assert.Equal(t, []string{"run.sh"}, localPaths(locations))
assert.Equal(t, []string{"/run.sh"}, localPaths(locations))
}
func TestResolver_answersAreOrdered(t *testing.T) {
@@ -187,11 +215,11 @@ func TestResolver_globsForADirectorysChildren(t *testing.T) {
"META-INF/maven/g/a/pom.xml": "deeper",
})
assert.Equal(t, []string{"LICENSE"}, globPaths(t, r, "/*"))
assert.Equal(t, []string{"LICENSE"}, globPaths(t, r, "*"))
assert.Equal(t, []string{"META-INF/LICENSE.txt"}, globPaths(t, r, "/META-INF/*"))
assert.Equal(t, []string{"META-INF/LICENSE.txt"}, globPaths(t, r, "META-INF/*"))
assert.Equal(t, []string{"META-INF/maven/g/a/pom.xml"}, globPaths(t, r, "**/a/*"))
assert.Equal(t, []string{"/LICENSE"}, globPaths(t, r, "/*"))
assert.Equal(t, []string{"/LICENSE"}, globPaths(t, r, "*"))
assert.Equal(t, []string{"/META-INF/LICENSE.txt"}, globPaths(t, r, "/META-INF/*"))
assert.Equal(t, []string{"/META-INF/LICENSE.txt"}, globPaths(t, r, "META-INF/*"))
assert.Equal(t, []string{"/META-INF/maven/g/a/pom.xml"}, globPaths(t, r, "**/a/*"))
}
func TestResolver_invalidGlobIsAnError(t *testing.T) {
@@ -226,7 +254,7 @@ func TestResolver_linksCollapseToOnePath(t *testing.T) {
{name: "opt/hard.jar", link: "opt/real.jar", hard: true},
},
pattern: "**/*.jar",
want: []string{"opt/real.jar"},
want: []string{"/opt/real.jar"},
reason: "one file, so one location - and it is named by the path that holds it",
},
{
@@ -238,7 +266,7 @@ func TestResolver_linksCollapseToOnePath(t *testing.T) {
{name: "opt/m-sym.jar", link: "real.bin"},
},
pattern: "**/*.jar",
want: []string{"opt/real.bin"},
want: []string{"/opt/real.bin"},
reason: "the content is the target's, so that is the real path the answer carries",
},
{
@@ -249,7 +277,7 @@ func TestResolver_linksCollapseToOnePath(t *testing.T) {
{name: "opt/one-sym.jar", link: "one.jar"},
},
pattern: "**/*.jar",
want: []string{"opt/one.jar", "opt/two.jar"},
want: []string{"/opt/one.jar", "/opt/two.jar"},
reason: "collapsing is per file, not per pattern",
},
{
@@ -259,7 +287,7 @@ func TestResolver_linksCollapseToOnePath(t *testing.T) {
{name: "opt/gone.jar", link: "missing.jar"},
},
pattern: "**/*.jar",
want: []string{"opt/real.jar"},
want: []string{"/opt/real.jar"},
reason: "it names no content",
},
{
@@ -280,7 +308,7 @@ func TestResolver_linksCollapseToOnePath(t *testing.T) {
{name: "opt/outer.jar", link: "mid.jar"},
},
pattern: "**/*.jar",
want: []string{"opt/real.jar"},
want: []string{"/opt/real.jar"},
reason: "following one hop at a time reaches the same file from every path",
},
{
@@ -300,7 +328,7 @@ func TestResolver_linksCollapseToOnePath(t *testing.T) {
{name: "opt/nested/hard.jar", link: "opt/real.jar", hard: true},
},
pattern: "**/*.jar",
want: []string{"opt/real.jar"},
want: []string{"/opt/real.jar"},
reason: "read as relative it would resolve to opt/nested/opt/real.jar and dangle",
},
}
@@ -324,7 +352,7 @@ func TestResolver_collapsedLocationReadsTheTarget(t *testing.T) {
require.NoError(t, err)
require.Len(t, locations, 1)
assert.Equal(t, "opt/real.bin", locations[0].LocalPath(), "content comes from the file")
assert.Equal(t, "/opt/real.bin", locations[0].LocalPath(), "content comes from the file")
assert.Equal(t, "outer.jar!/opt/a-sym.jar", locations[0].AccessPath, "the path searched by is the one matched")
reader, err := r.FileContentsByLocation(locations[0])
@@ -367,7 +395,7 @@ func TestResolver_danglingLinkHasNoContent(t *testing.T) {
locations = append(locations, l)
}
require.Len(t, locations, 1)
assert.Equal(t, "opt/gone.jar", locations[0].LocalPath())
assert.Equal(t, "/opt/gone.jar", locations[0].LocalPath())
_, err := r.FileContentsByLocation(locations[0])
assert.Error(t, err, "a link to nothing has no content to read")
@@ -394,12 +422,12 @@ func TestResolver_mimeTypeAnswersOnlyWithFiles(t *testing.T) {
return localPaths(locations)
}
assert.Equal(t, []string{"opt/real.jar"}, byMIME("application/zip"),
assert.Equal(t, []string{"/opt/real.jar"}, byMIME("application/zip"),
"the file answers, not the two links to it")
assert.Equal(t, []string{"opt/notes.txt"}, byMIME("text/plain"))
assert.Equal(t, []string{"/opt/notes.txt"}, byMIME("text/plain"))
assert.Empty(t, byMIME(""),
"a link sniffs to no type, and must not be reachable by asking for that")
assert.Equal(t, []string{"opt/notes.txt", "opt/real.jar"}, byMIME("application/zip", "text/plain"))
assert.Equal(t, []string{"/opt/notes.txt", "/opt/real.jar"}, byMIME("application/zip", "text/plain"))
}
// AllLocations enumerates what the archive holds rather than searching for files: the file metadata
@@ -415,7 +443,7 @@ func TestResolver_allLocationsKeepsEveryPath(t *testing.T) {
for l := range r.AllLocations(context.Background()) {
got = append(got, l.LocalPath())
}
assert.Equal(t, []string{"opt/gone.jar", "opt/real.jar", "opt/sym.jar"}, got,
assert.Equal(t, []string{"/opt/gone.jar", "/opt/real.jar", "/opt/sym.jar"}, got,
"every path the archive names, links included")
}
@@ -437,7 +465,7 @@ func TestResolver_add_anEntryOverTheCapIsSkippedAndReadNoFurther(t *testing.T) {
assert.ErrorContains(t, r.Truncated, "per-entry size cap of 1.0 KiB")
locations, err := r.FilesByGlob("**")
require.NoError(t, err)
assert.Equal(t, []string{"ok.txt"}, localPaths(locations))
assert.Equal(t, []string{"/ok.txt"}, localPaths(locations))
}
type countingReader struct {
+55 -1
View File
@@ -2,6 +2,8 @@ package archive
import (
"context"
"path"
"slices"
"strings"
"github.com/anchore/syft/syft/file"
@@ -30,6 +32,52 @@ type Traversal struct {
Packages []pkg.Package
}
// FullPathIn is the full path of the entry at entryPath inside the archive whose full path is
// containerPath, or entryPath itself for a blank containerPath (the scan root).
func FullPathIn(containerPath, entryPath string) string {
if containerPath == "" {
return entryPath
}
if !strings.HasPrefix(entryPath, "/") {
entryPath = "/" + entryPath
}
// cleaning an already clean path allocates nothing
return containerPath + file.ArchivePathSeparator + path.Clean(entryPath)[1:]
}
// OwnerContainers are the containers a package owns files within: the container of each of its
// locations, each once, as coordinates whose FullPath is the container's full path (blank at the scan
// root). A package without locations owns files at the scan root. See OwnedCoordinates.
func OwnerContainers(p pkg.Package) []file.Coordinates {
var out []file.Coordinates
for _, l := range p.Locations.ToUnorderedSlice() {
c := file.Coordinates{FileSystemID: l.FileSystemID}
if container := ContainerPath(l.Coordinates); container != "" {
c.RealPath, c.FullPath = l.RealPath, container
}
if !slices.Contains(out, c) {
out = append(out, c)
}
}
if len(out) == 0 {
out = append(out, file.Coordinates{})
}
return out
}
// OwnedCoordinates are the coordinates of the file at ownedPath within container, one of
// OwnerContainers. At the scan root the path may still need resolving (it may pass through links).
func OwnedCoordinates(container file.Coordinates, ownedPath string) file.Coordinates {
if container.FullPath == "" {
return file.Coordinates{RealPath: ownedPath, FileSystemID: container.FileSystemID}
}
return file.Coordinates{
RealPath: container.RealPath,
FileSystemID: container.FileSystemID,
FullPath: FullPathIn(container.FullPath, ownedPath),
}
}
// FullTraversalArchivePath is the archive's own full path, which is the ContainerPath of every file inside it. Outside
// any archive (a nil Traversal) it is blank, as for files on the scanned filesystem.
func (t *Traversal) FullTraversalArchivePath() string {
@@ -75,7 +123,10 @@ func VirtualPath(loc file.Location) string {
return accessPath
}
segments := strings.Split(entries, file.ArchivePathSeparator)
segments[len(segments)-1] = strings.TrimPrefix(accessPath, ContainerPath(loc.Coordinates)+file.ArchivePathSeparator)
if rest, ok := strings.CutPrefix(accessPath, ContainerPath(loc.Coordinates)); ok && strings.HasPrefix(rest, file.ArchivePathSeparator) {
accessPath = rest[len(file.ArchivePathSeparator):]
}
segments[len(segments)-1] = accessPath
for i, s := range segments {
segments[i] = strings.TrimPrefix(escapeEntryPath(s), "/")
}
@@ -85,6 +136,9 @@ func VirtualPath(loc file.Location) string {
var entryPathEscaper = strings.NewReplacer("%", "%25", ":", "%3A")
func escapeEntryPath(p string) string {
if !strings.ContainsAny(p, "%:") {
return p
}
return entryPathEscaper.Replace(p)
}