chore: remove custom index in favor of existing filetree.Index

Signed-off-by: Keith Zantow <kzantow@gmail.com>
This commit is contained in:
Keith Zantow
2026-10-13 16:22:32 -04:00
parent a707a7bfd9
commit 17d109e8b5
14 changed files with 243 additions and 1505 deletions
+10 -20
View File
@@ -59,31 +59,21 @@ Nothing an archive declares is trusted. Content is charged chunk by chunk as it
deflate or gzip bomb stops at the disk limit with the entry dropped whole. Every node is charged
against the memory limit, including directories implied by deep paths, so an archive of millions of
tiny entries or of a few entries with 4 KiB names stops at the memory limit. Names longer than
PATH_MAX are refused, link chains stop after `maxLinkHops`, and nesting stops at the task's
PATH_MAX are refused, and nesting stops at the task's
`MaxDepth`. See the bomb tests in `extract_test.go`.
## Lookup
Paths are archive-relative with no leading slash; a leading slash on a query is tolerated. Directories
implied by entry paths exist for `HasPath` but are never files, and every node is linked to its
parent and children. The first entry at a path wins over later duplicates.
Entries are indexed in a stereoscope `filetree.FileTree` + `filetree.Index` and searched with
`filetree.SearchContext`, the same index the directory and image resolvers use. Paths are
archive-relative with no leading slash; a leading slash on a query is tolerated. Directories implied
by entry paths exist for `HasPath` but are never files. The first entry at a path wins over later
duplicates.
Links resolve inside the archive only, up to `maxLinkHops` hops, which also ends a cycle. A glob or
path lookup matching a link and its target returns one location: the file's own path when it
matched, otherwise the lowest-sorting link, with `AccessPath` naming the path matched and `RealPath`
the file read. A dangling link keeps its own path and reads as empty. `FilesByMIMEType` answers only
with files holding bytes, since a link has nothing to sniff; `AllLocations` enumerates every path,
links included. Answers are sorted by path.
`FilesByGlob` (glob.go) parses a pattern once, cached (bounded by `maxCachedGlobs`), into segments.
A brace group inside a segment, `g{o,o.exe}`, makes that segment a set of alternatives; only a group
spanning `/` expands the whole pattern. The literal text at either end of every alternative is looked
up in a base-name index (`internal/index`): files for the last segment, directories for the rest, so
`**/one/two/**/[a-z]thing` is narrowed to what lies under directories named `two` or to files ending
in `thing`, whichever is smaller (a file-name lookup of `narrowEnough` or fewer is taken as is). Each
candidate is then verified segment by segment up its parent chain with `path.Match`. `**/<name>`,
`**/*<suffix>`, `**/<prefix>*` and brace sets of those are answered from the index alone. Only a
pattern with no literal text anywhere, such as `**/[lm]*`, is matched against every file.
Links resolve inside the archive only, as the file tree resolves them: a hard link names its target
from the archive root. A lookup matching a link and its target returns one location, with
`AccessPath` naming the path matched and `RealPath` the file read. Dangling and cyclic links are not
files. `AllLocations` enumerates every non-directory path, links included. Answers are sorted by path.
## Other pieces
+1 -1
View File
@@ -156,7 +156,7 @@ func (r *Resolver) extract(ctx context.Context, format archives.Extractor, conte
r.truncate(fmt.Errorf("extraction stopped, possible decompression bomb: %w", err))
return nil
}
if err != nil && ctx.Err() == nil && len(r.files) > 0 {
if err != nil && ctx.Err() == nil && r.entries > 0 {
// the stream broke partway (a truncated .tar.gz); what was read before that is still good
r.truncate(fmt.Errorf("extraction stopped early: %w", err))
return nil
+9 -8
View File
@@ -19,6 +19,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/syft/syft/file"
)
@@ -106,7 +107,7 @@ func TestResolver_extract_diskLimitBitesMidEntry(t *testing.T) {
r, root := extractBytes(t, data, "test.tar.gz", charge)
assert.Error(t, r.Truncated)
assert.Equal(t, []string{"a/first.txt"}, storedNames(t, r))
assert.Equal(t, []string{"a", "a/first.txt", "b"}, storedNames(t, r), "the directory a refused entry implied stays")
assert.Greater(t, r.written, int64(40*1024), "the first entry landed whole and the second part way")
_, disk := charge.held()
assert.LessOrEqual(t, disk, int64(budget))
@@ -177,7 +178,7 @@ func TestResolver_extract_entryNamesAreCleanedAndNothingIsWrittenUnderThem(t *te
r, root := extractBytes(t, data, name, nil)
require.NoError(t, r.Truncated)
assert.Equal(t, []string{"META-INF", "META-INF/MANIFEST.MF", "bin/link.txt", "etc/hosts", "etc/passwd", "passwd", "shadow"},
assert.Equal(t, []string{"META-INF", "META-INF/MANIFEST.MF", "bin", "bin/link.txt", "etc", "etc/hosts", "etc/passwd", "passwd", "shadow"},
storedNames(t, r))
assert.Empty(t, filesIn(t, root), "entries live in the store, never under their own names")
assert.NoFileExists(t, filepath.Join(t.TempDir(), "..", "etc", "passwd"))
@@ -185,13 +186,13 @@ func TestResolver_extract_entryNamesAreCleanedAndNothingIsWrittenUnderThem(t *te
got := readStore(t, r)
assert.Equal(t, "evil", got["etc/passwd"].body)
assert.Equal(t, "pwned", got["etc/hosts"].body)
assert.Equal(t, byte(tar.TypeDir), got["META-INF"].typeflag)
assert.Equal(t, stereoscopeFile.TypeDirectory, got["META-INF"].kind)
assert.Equal(t, fs.FileMode(0o555), got["META-INF"].mode.Perm())
assert.Equal(t, "Manifest-Version: 1.0", got["META-INF/MANIFEST.MF"].body)
// links are headers carrying their target verbatim; the index resolves them inside the archive
for _, link := range []string{"passwd", "shadow", "bin/link.txt"} {
assert.Equal(t, byte(tar.TypeSymlink), got[link].typeflag)
assert.Equal(t, stereoscopeFile.TypeSymLink, got[link].kind)
assert.Empty(t, got[link].body)
}
assert.Equal(t, "../../../../etc/passwd", got["passwd"].linkTarget)
@@ -204,7 +205,7 @@ func TestResolver_extract_entryNamesAreCleanedAndNothingIsWrittenUnderThem(t *te
func TestResolver_extract_dataEdgeCases(t *testing.T) {
t.Run("an empty archive is not an error", func(t *testing.T) {
r, _ := extractBytes(t, zipBytes(t, map[string]string{}), "test.zip", nil)
assert.Empty(t, r.files)
assert.Zero(t, r.entries)
assert.NoError(t, r.Truncated)
})
@@ -551,8 +552,8 @@ func TestExtract_entryCountBombIsBoundedByTheMemoryLimit(t *testing.T) {
t.Cleanup(r.Cleanup)
assert.Error(t, r.Truncated)
assert.Less(t, len(r.files), 20_000)
assert.LessOrEqual(t, int64(len(r.byPath))*approxIndexBytesPerEntry, limits.MaxMemoryBytes+approxIndexBytesPerEntry,
assert.Less(t, r.entries, 20_000)
assert.LessOrEqual(t, int64(nodeCount(r))*approxIndexBytesPerEntry, limits.MaxMemoryBytes+approxIndexBytesPerEntry,
"nodes held must not exceed what the memory limit paid for")
peakMemory, _ := limiter.Peak()
assert.LessOrEqual(t, peakMemory, limits.MaxMemoryBytes)
@@ -579,7 +580,7 @@ func TestExtract_deepPathBombIsBoundedByTheMemoryLimit(t *testing.T) {
t.Cleanup(r.Cleanup)
assert.Error(t, r.Truncated)
assert.LessOrEqual(t, int64(len(r.byPath))*approxIndexBytesPerEntry, limits.MaxMemoryBytes+approxIndexBytesPerEntry)
assert.LessOrEqual(t, int64(nodeCount(r))*approxIndexBytesPerEntry, limits.MaxMemoryBytes+approxIndexBytesPerEntry)
peakMemory, _ := limiter.Peak()
assert.LessOrEqual(t, peakMemory, limits.MaxMemoryBytes)
}
-390
View File
@@ -1,390 +0,0 @@
package archive
import (
"path"
"strings"
"sync"
"sync/atomic"
syftindex "github.com/anchore/syft/internal/index"
)
// A glob is parsed once into segments, cached by pattern since catalogers ask the same few hundred
// globs of every archive. A brace group inside a segment, `g{o,o.exe}`, makes that segment a set of
// alternatives, each looked up on its own; only a group spanning `/` expands the whole pattern.
// The literal text at either end of every alternative is looked up in the base-name indexes: files
// for the last segment, directories for the rest. The smallest set found is the candidate set, and
// each candidate is verified segment by segment up its parent chain. Only a pattern with no literal
// text anywhere falls back to every file.
// globSegment is one `/`-delimited piece of a pattern.
type globSegment struct {
alts []globAlt // the texts a name may match; one unless the segment has a brace group
any bool // `**`: zero or more directories
// indexed reports every alternative has literal text to look up; exact that every lookup is the
// whole answer for its alternative: a plain name, `prefix*` or `*suffix`
indexed, exact bool
}
// globAlt is one alternative text of a segment.
type globAlt struct {
text string // ready for path.Match
// prefix and suffix are the literal text before the first and after the last metacharacter, the
// necessary conditions a name index can check. For a plain text prefix is the whole of it.
prefix, suffix string
plain bool
}
// maxCachedGlobs bounds the parse cache, which is sized for the catalogers' own patterns.
const maxCachedGlobs = 4096
var (
parsedGlobs sync.Map // pattern -> [][]globSegment
parsedGlobCount atomic.Int64
)
// parseGlob returns the segment lists a pattern spells out: one, unless a brace group spans `/`.
// Leading slashes are dropped since paths are archive-relative; an empty pattern has none.
func parseGlob(pattern string) [][]globSegment {
if cached, ok := parsedGlobs.Load(pattern); ok {
return cached.([][]globSegment)
}
var out [][]globSegment
if braceSpansSeparator(pattern) {
for _, alternative := range expandBraces(pattern) {
out = append(out, parseSegments(alternative))
}
} else if segments := parseSegments(pattern); segments != nil {
out = [][]globSegment{segments}
}
if parsedGlobCount.Add(1) <= maxCachedGlobs {
parsedGlobs.Store(pattern, out)
}
return out
}
func parseSegments(pattern string) []globSegment {
pattern = strings.TrimLeft(pattern, "/")
if pattern == "" {
return nil
}
var segments []globSegment
for _, text := range strings.Split(pattern, "/") {
s := globSegment{any: text == "**", indexed: true, exact: true}
if !s.any {
texts := []string{text}
if strings.IndexByte(text, '{') >= 0 {
texts = expandBraces(text)
}
for _, t := range texts {
a := globAlt{text: strings.ReplaceAll(t, "[!", "[^")} // doublestar negation, for path.Match
a.prefix, a.suffix, a.plain = literalEnds(a.text)
s.indexed = s.indexed && (a.prefix != "" || a.suffix != "")
s.exact = s.exact && (a.plain || a.text == a.prefix+"*" || a.text == "*"+a.suffix)
s.alts = append(s.alts, a)
}
}
segments = append(segments, s)
}
return segments
}
// braceSpansSeparator reports whether a `/` sits inside a brace group, so the group's alternatives
// have different segment counts.
func braceSpansSeparator(pattern string) bool {
depth := 0
for i := 0; i < len(pattern); i++ {
switch pattern[i] {
case '\\':
i++
case '{':
depth++
case '}':
depth = max(depth-1, 0)
case '/':
if depth > 0 {
return true
}
}
}
return false
}
// literalEnds returns the literal text before the first metacharacter and after the last one, and
// whether there were none. A character class is one metacharacter however long, as is an escape pair.
func literalEnds(seg string) (prefix, suffix string, plain bool) {
first, lastEnd := -1, -1
mark := func(start, end int) {
if first < 0 {
first = start
}
lastEnd = end
}
for i := 0; i < len(seg); {
switch seg[i] {
case '*', '?':
mark(i, i+1)
i++
case '\\':
mark(i, min(i+2, len(seg)))
i += 2
case '[':
// a `]` right after `[` or `[^` is a member of the class, not its end
j := i + 1
if j < len(seg) && seg[j] == '^' {
j++
}
if j < len(seg) && seg[j] == ']' {
j++
}
end := strings.IndexByte(seg[j:], ']')
if end < 0 {
mark(i, len(seg))
i = len(seg)
} else {
mark(i, j+end+1)
i = j + end + 1
}
default:
i++
}
}
if first < 0 {
return seg, "", true
}
return seg[:first], seg[lastEnd:], false
}
// expandBraces returns every pattern a brace group spells out, nested groups included. A pattern
// without a complete group is returned as is.
func expandBraces(pattern string) []string {
start, depth := -1, 0
for i := 0; i < len(pattern); i++ {
switch pattern[i] {
case '\\':
i++
case '{':
if depth == 0 {
start = i
}
depth++
case '}':
if depth == 0 {
continue
}
depth--
if depth == 0 {
var out []string
for _, alt := range splitAlternatives(pattern[start+1 : i]) {
out = append(out, expandBraces(pattern[:start]+alt+pattern[i+1:])...)
}
return out
}
}
}
return []string{pattern}
}
// splitAlternatives splits a brace group's body on the commas at its top level.
func splitAlternatives(body string) []string {
var out []string
depth, last := 0, 0
for i := 0; i < len(body); i++ {
switch body[i] {
case '\\':
i++
case '{':
depth++
case '}':
depth--
case ',':
if depth == 0 {
out = append(out, body[last:i])
last = i + 1
}
}
}
return append(out, body[last:])
}
// lookup finds the nodes a segment admits in a base-name index: for each alternative the smaller of
// what its prefix and suffix find, or the name itself when it is plain. It reports false for a
// segment with an alternative that has no literal text, which admits every name.
func lookup(idx *syftindex.PrefixSuffix[[]*node], s globSegment) ([]*node, bool) {
if !s.indexed {
return nil, false
}
var out []*node
for _, a := range s.alts {
switch {
case a.plain:
out = append(out, idx.Get(a.text)...)
case a.suffix == "":
out = append(out, flatten(idx.ByPrefix(a.prefix))...)
case a.prefix == "":
out = append(out, flatten(idx.BySuffix(a.suffix))...)
default:
byPrefix, bySuffix := flatten(idx.ByPrefix(a.prefix)), flatten(idx.BySuffix(a.suffix))
if len(bySuffix) < len(byPrefix) {
byPrefix = bySuffix
}
out = append(out, byPrefix...)
}
}
return out, true
}
func flatten(groups [][]*node) []*node {
var out []*node
for _, group := range groups {
out = append(out, group...)
}
return out
}
// exactByName reports whether the pattern is `**/<segment>` with a segment the file-name index answers
// completely, so its lookup needs no verification.
func exactByName(segments []globSegment) bool {
return len(segments) == 2 && segments[0].any && segments[1].exact
}
// narrowEnough is a candidate count small enough that looking for a smaller set is not worth it.
const narrowEnough = 64
// globCandidates returns the files a pattern can match, and whether they are exactly the answer.
func (r *Resolver) globCandidates(segments []globSegment) (candidates []*node, exact bool) {
last := segments[len(segments)-1]
best := r.files
if !last.any {
if files, ok := lookup(&r.names, last); ok {
if exactByName(segments) || len(files) <= narrowEnough {
return files, exactByName(segments)
}
best = files
}
}
// a directory segment narrows to what lies under the directories it names; take it only when
// that is smaller than what the file name gave, stopping the walk as soon as it is not
for _, s := range segments[:len(segments)-1] {
if s.any {
continue
}
dirs, ok := lookup(&r.dirs, s)
if !ok || len(dirs) >= len(best) {
continue
}
if under := filesUnder(dirs, len(best)-1); under != nil {
best = under
}
}
return best, false
}
// filesUnder collects the files anywhere beneath the given directories, or nil once more than limit
// are found.
func filesUnder(dirs []*node, limit int) []*node {
var out []*node
stack := append([]*node(nil), dirs...)
for len(stack) > 0 {
n := stack[len(stack)-1]
stack = stack[:len(stack)-1]
for _, child := range n.children {
if child.isDir {
stack = append(stack, child)
continue
}
if child.header != nil {
if len(out) >= limit {
return nil
}
out = append(out, child)
}
}
}
return out
}
// matchNode reports whether the segments match the node's path, read up its parent chain.
func matchNode(segments []globSegment, n *node) (bool, error) {
var buf [16]string
names := buf[:0]
for m := n; m.parent != nil; m = m.parent {
names = append(names, m.name)
}
for i, j := 0, len(names)-1; i < j; i, j = i+1, j-1 {
names[i], names[j] = names[j], names[i]
}
return matchSegments(segments, names)
}
// matchSegments matches segments against path components. The segments after the last `**` sit at
// fixed positions from the end, so they are matched against the tail first, which settles the common
// `**/x` in one comparison; what remains ends in `**` and is matched greedily, backing up to the most
// recent `**` on a mismatch.
func matchSegments(segments []globSegment, names []string) (bool, error) {
lastAny := -1
for i, s := range segments {
if s.any {
lastAny = i
}
}
tail := segments[lastAny+1:]
if len(names) < len(tail) {
return false, nil
}
names, tailNames := names[:len(names)-len(tail)], names[len(names)-len(tail):]
for i, s := range tail {
if ok, err := s.match(tailNames[i]); !ok || err != nil {
return false, err
}
}
if lastAny < 0 {
return len(names) == 0, nil
}
segments = segments[:lastAny+1]
si, ni := 0, 0
starSeg, starName := -1, 0
for ni < len(names) {
if si < len(segments) {
if segments[si].any {
starSeg, starName = si, ni
si++
continue
}
ok, err := segments[si].match(names[ni])
if err != nil {
return false, err
}
if ok {
si++
ni++
continue
}
}
if starSeg < 0 {
return false, nil
}
starName++
si, ni = starSeg+1, starName
}
for ; si < len(segments); si++ {
if !segments[si].any {
return false, nil
}
}
return true, nil
}
// match reports whether any of the segment's alternatives matches a name.
func (s globSegment) match(name string) (bool, error) {
for _, a := range s.alts {
if ok, err := path.Match(a.text, name); ok || err != nil {
return ok, err
}
}
return false, nil
}
-330
View File
@@ -1,330 +0,0 @@
package archive
import (
"fmt"
"sort"
"strings"
"testing"
"github.com/bmatcuk/doublestar/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var globFixture = map[string]string{
"one/two/athing": "a",
"one/two/deep/er/zthing": "z",
"one/two/Athing": "A",
"two/athing": "not under one",
"x/one/two/bthing": "b",
"onefoo/bartwo/deep/something": "s",
"onefoo/bartwo/nothing": "n",
"one/bartwo/something": "no one* dir",
"usr/local/go/bin/go": "bin",
"usr/local/go/bin/go.exe": "bin",
"usr/lib/rustlib/libstd-0123.so": "lib",
"usr/lib/libstd-0123.a": "lib",
"var/lib/dpkg/status": "dpkg",
"var/lib/dpkg/status.d/base": "dpkg",
"var/lib/dpkg/status.d/deep/thing": "dpkg",
"opt/a.jar": "PK",
"opt/b.war": "PK",
"opt/c.ear": "PK",
"opt/notes.txt": "prose",
"LICENSE": "root file",
".hidden/secret.jar": "dotdir",
"META-INF/maven/g/a/pom.xml": "pom",
"META-INF/maven/g/a/pom.properties": "pom",
"weird/a]b/x": "class edge",
"weird/a-b/x": "class edge",
"java/openjdk-21/release": "jvm",
"jvm/zulu-17/release": "jvm",
"go": "root binary",
"gopher.txt": "prose",
"opt/c.rar": "not a jar",
"opt/app/lib/inner.jar": "PK",
"usr/lib/libstd-short.so": "lib",
"usr/lib/libz.so": "lib",
"usr/bin/mariadb": "bin",
"usr/bin/mysql": "bin",
"usr/bin/postgres": "bin",
"var/lib/rpm/Packages": "rpm",
"usr/lib/sysimage/rpm/rpmdb.sqlite": "rpm",
"usr/lib/firefox/firefox": "bin",
"META-INF/MANIFEST.MF": "manifest",
"WEB-INF/lib/dep.jar": "PK",
".hidden.jar": "dotfile",
"xyz": "class edge",
"xaz": "class edge",
"lib.so": "root lib",
"log.txt": "prose",
"mn.txt": "prose",
}
// every shape the catalogers use, plus the ones that exercise narrowing and its edges
var globPatterns = []string{
"**/one/two/**/[a-z]thing",
"**/one*/*two/**/[s]omething",
"**/one/two/*",
"**/one/two/**",
"**/two/**",
"/one/**",
"one/two/athing",
"/*",
"*",
"**/*",
"**",
"**/{go,go.exe}",
"**/*.{jar,war}",
"**/{opt,usr}/**/*.{jar,so}",
"**/var/lib/dpkg/{status,status.d/**}",
"**/{a,{b,c}}.{jar,war,ear}",
"**/g{o,o.exe}",
"**/{[a]b,cd}",
"**/*{,.exe}",
"**/{one,x/one}/two/*thing",
"**/*.[jw]ar",
"**/[!a]thing",
"**/[^a]thing",
"**/libstd-????.so",
"**/libstd-*",
"**/*thing",
"**/pom.*",
"**/maven/*/*/pom.xml",
"**/{java,jvm}/*/release",
"**/*-17/release",
"**/a]b/x",
"**/.hidden/*.jar",
"**/*.jar",
"**/status",
"**/status.d/**",
"**/deep/**/*thing",
"**/nothing/**",
"nope/**",
"",
"/",
"**/one//two/athing",
"**/a\\]b/x",
"**/pom*",
"/opt/*",
"/META-INF/*",
"**/*.?ar",
"**/*o*",
"**/?o",
"**/{mariadb,mysql}",
"**/[lm]*",
"**/pom.{xml,properties}",
"**/{lib,libz}.so",
"**/[a-c]*",
"**/libstd-[s0]*.so",
"**/x[!y]z",
"**/{go,*}",
"**/{lib,l}*",
"**/{lo,m}*",
"**/{*.jar,pom.xml}",
"/{META-INF,WEB-INF}/**/*.xml",
"/usr/{bin,lib}/*",
"/opt/*/lib/*.jar",
"/us?/lib/libstd-*.so",
"**/lib/**/*.jar",
// the name looked up also exists outside the selected directory
"/opt/**/*.jar",
"/opt/**/lib/*.jar",
"/usr/**/go",
"/{opt,WEB-INF}/**/*.jar",
"/META-INF/**/pom.*",
"/usr/**/lib*.so",
// alternations whose branches carry separators, as syft's catalogers write them
"**/var/lib/dpkg/{status,status.d/*}",
"**/{var/lib,usr/share,usr/lib/sysimage}/rpm/{Packages,Packages.db,rpmdb.sqlite}",
"**/{firefox,firefox.exe}",
"**/**",
"/**/*.so",
}
// every pattern shape is checked against matching every path the slow way, since a name-index
// shortcut that is too eager fails silently by returning fewer files
func TestGlob_agreesWithDoublestar(t *testing.T) {
r := resolverOver(t, 1<<20, globFixture)
var all []string
for _, n := range r.files {
all = append(all, reportedPath(n.path))
}
for _, pattern := range globPatterns {
t.Run(pattern, func(t *testing.T) {
// paths are archive-relative, so a leading slash anchors at the archive root
var want []string
for _, p := range all {
ok, err := doublestar.Match(strings.TrimLeft(pattern, "/"), p)
require.NoError(t, err)
if ok {
want = append(want, p)
}
}
sort.Strings(want)
locations, err := r.FilesByGlob(pattern)
require.NoError(t, err)
assert.Equal(t, want, realPaths(locations))
})
}
}
func TestGlob_narrowsByDirectory(t *testing.T) {
// enough files share the last segment's suffix that the directories in the pattern narrow better
entries := map[string]string{}
for k, v := range globFixture {
entries[k] = v
}
for i := range 100 {
entries[fmt.Sprintf("noise/f%03dthing", i)] = "x"
entries[fmt.Sprintf("noise/d%03d/something", i)] = "x"
}
r := resolverOver(t, 1<<20, entries)
tests := []struct {
pattern string
want int // candidates considered
}{
{"**/one/two/**/[a-z]thing", 5}, // everything under a directory named `one`
{"**/one*/*two/**/[s]omething", 7},
{"**/dpkg/**", 3},
{"**/[a-z]thing", 209}, // the file-name suffix alone
{"**/[lm]*", len(r.files)},
{"**/*", len(r.files)},
{"**/g{o,o.exe}", 3},
{"**/{d00*,f00*}/**", 10}, // ten directories d000-d009 narrow; f00* names no directory
{"**/{*,cd}", len(r.files)},
}
for _, tt := range tests {
t.Run(tt.pattern, func(t *testing.T) {
candidates, _ := r.globCandidates(parseGlob(tt.pattern)[0])
assert.Len(t, candidates, tt.want)
})
}
}
func TestGlob_exactLookupsSkipVerification(t *testing.T) {
r := resolverOver(t, 1<<20, globFixture)
for pattern, exact := range map[string]bool{
"**/pom.xml": true,
"**/*.jar": true,
"**/libstd-*": true,
"**/pom.*": true,
"**/p*m.xml": false, // narrowed by an end, but the middle still has to be checked
"**/g{o,o.exe}": true,
"**/*.{jar,war}": true,
"**/{[a]b,cd}": false,
"**/{*,cd}": false,
"**/*thing": true,
"**/[a-z]thing": false,
"/opt/*.jar": false,
"**/maven/pom.xml": false,
} {
_, got := r.globCandidates(parseGlob(pattern)[0])
assert.Equal(t, exact, got, pattern)
}
}
func Test_expandBraces(t *testing.T) {
tests := []struct {
pattern string
want []string
}{
{"**/*.jar", []string{"**/*.jar"}},
{"**/{go,go.exe}", []string{"**/go", "**/go.exe"}},
{"**/{a,{b,c}}.{jar,war}", []string{"**/a.jar", "**/a.war", "**/b.jar", "**/b.war", "**/c.jar", "**/c.war"}},
{"**/dpkg/{status,status.d/**}", []string{"**/dpkg/status", "**/dpkg/status.d/**"}},
{"**/{lone", []string{"**/{lone"}},
{"**/lone}", []string{"**/lone}"}},
{"**/\\{not,a,group\\}", []string{"**/\\{not,a,group\\}"}},
{"{,a}", []string{"", "a"}},
}
for _, tt := range tests {
assert.Equal(t, tt.want, expandBraces(tt.pattern), tt.pattern)
}
}
func Test_literalEnds(t *testing.T) {
tests := []struct {
seg string
prefix, suffix string
plain bool
}{
{"pom.xml", "pom.xml", "", true},
{"*.jar", "", ".jar", false},
{"libstd-*", "libstd-", "", false},
{"[a-z]thing", "", "thing", false},
{"libstd-????.so", "libstd-", ".so", false},
{"a*b*c", "a", "c", false},
{"*", "", "", false},
{"a]b", "a]b", "", true},
{"a[]-]b", "a", "b", false},
{"a[^]]b", "a", "b", false},
{"a\\*b", "a", "b", false},
{"a\\", "a", "", false},
{"a[b", "a", "", false},
}
for _, tt := range tests {
prefix, suffix, plain := literalEnds(tt.seg)
assert.Equal(t, []any{tt.prefix, tt.suffix, tt.plain}, []any{prefix, suffix, plain}, tt.seg)
}
}
// every pattern but the last two is one syft's own catalogers register
func BenchmarkResolver_FilesByGlob(b *testing.B) {
r := benchArchive(b)
patterns := map[string]string{
"exact-name": "**/go",
"extension": "**/*.jar",
"alternation": "**/{go,go.exe}",
"question-marks": "**/libstd-????????????????.so",
"alternation-suffix": "**/*.{jar,war}",
"alternation-with-sep": "**/var/lib/dpkg/{status,status.d/**}",
"alternation-both-ends": "**/{var/lib,usr/share,usr/lib/sysimage}/rpm/" +
"{Packages,Packages.db,rpmdb.sqlite}",
"alternation-mid-segment": "**/{java,jvm}/*/release",
"alternation-mid-path": "/{usr,opt}/**/*.jar",
"class-in-extension": "**/*.[jw]ar",
"many-matches": "**/[lm]*",
}
for name, pattern := range patterns {
b.Run(name, func(b *testing.B) {
for b.Loop() {
if _, err := r.FilesByGlob(pattern); err != nil {
b.Fatal(err)
}
}
})
}
}
// benchArchive is shaped like a fat application image layer: thousands of entries with distinct base
// names, plus decoys sharing the prefixes and suffixes the patterns fix, so no lookup looks free.
func benchArchive(tb testing.TB) *Resolver {
tb.Helper()
entries := map[string]string{
"usr/local/go/bin/go": "binary",
"usr/lib/rustlib/libstd-0123456789abcdef.so": "binary",
"usr/bin/mariadb": "binary",
"opt/app/lib/app.jar": "PK\x03\x04",
"opt/app/lib/dep.war": "PK\x03\x04",
"var/lib/dpkg/status": "dpkg",
"var/lib/dpkg/status.d/base": "dpkg",
"var/lib/rpm/Packages": "rpm",
"usr/lib/jvm/java-21/release": "jvm",
}
for i := range 1500 {
entries[fmt.Sprintf("var/cache/archive%04d.tar", i)] = "data"
entries[fmt.Sprintf("var/cache/bundle%04d.rar", i)] = "data"
entries[fmt.Sprintf("usr/share/cal%04dendar", i)] = "data"
entries[fmt.Sprintf("usr/share/gopher%04d.md", i)] = "docs"
entries[fmt.Sprintf("usr/lib/libstd-%04d.a", i)] = "archive"
entries[fmt.Sprintf("usr/lib/mod%04d.py", i)] = "code"
entries[fmt.Sprintf("usr/share/doc/pkg%04d/NOTES%04d.md", i, i)] = "docs"
}
return resolverOver(tb, 1<<26, entries)
}
+27 -15
View File
@@ -16,6 +16,7 @@ import (
"github.com/stretchr/testify/require"
stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/syft/internal/tmpdir"
"github.com/anchore/syft/syft/file"
)
@@ -260,22 +261,21 @@ type storedEntry struct {
body string
linkTarget string
mode fs.FileMode
typeflag byte
kind stereoscopeFile.Type
}
// readStore reads every listed entry, files and directories alike, by name.
// readStore reads every stored entry by name: files, links, and directories listed or implied.
func readStore(t testing.TB, r *Resolver) map[string]storedEntry {
t.Helper()
out := map[string]storedEntry{}
for _, n := range r.byPath {
if n.header == nil {
continue
}
out[n.header.Name] = storedEntry{
body: readEntry(t, r.open(&n.content)),
linkTarget: n.header.Linkname,
mode: n.header.FileInfo().Mode(),
typeflag: n.header.Typeflag,
for _, ref := range r.tree.AllFiles(stereoscopeFile.AllTypes()...) {
entry, err := r.index.Get(ref)
require.NoError(t, err)
out[entry.Metadata.Path] = storedEntry{
body: readEntry(t, r.open(r.content[ref.ID()])),
linkTarget: entry.Metadata.LinkDestination,
mode: entry.Metadata.FileInfo.Mode(),
kind: entry.Metadata.Type,
}
}
return out
@@ -285,15 +285,27 @@ func readStore(t testing.TB, r *Resolver) map[string]storedEntry {
func storedNames(t testing.TB, r *Resolver) []string {
t.Helper()
var names []string
for _, n := range r.byPath {
if n.header != nil {
names = append(names, n.header.Name)
}
for name := range readStore(t, r) {
names = append(names, name)
}
sort.Strings(names)
return names
}
// nodeCount is every path the tree holds, including directories the archive never listed.
func nodeCount(r *Resolver) int {
return len(r.tree.AllRealPaths())
}
// contentAt is the blob held for the entry at a rooted path.
func contentAt(t testing.TB, r *Resolver, p string) *blob {
t.Helper()
_, res, err := r.tree.File(stereoscopeFile.Path(p))
require.NoError(t, err)
require.NotNil(t, res)
return r.content[res.Reference.ID()]
}
// memCharge is a charge against a memory bound with disk unbounded.
func memCharge(maxMemory int64) *charge {
return NewLimiter(Limits{MaxMemoryBytes: maxMemory, MaxDiskBytes: -1}).charge()
+160 -232
View File
@@ -17,19 +17,18 @@ import (
"github.com/mholt/archives"
stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/stereoscope/pkg/filetree"
intFile "github.com/anchore/syft/internal/file"
syftindex "github.com/anchore/syft/internal/index"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/tmpdir"
"github.com/anchore/syft/syft/file"
)
// Resolver is one archive opened as its own filesystem: a file.Resolver over the entries Extract fed
// into it, holding their content within the scan's limits.
// into it, holding their content within the scan's limits. Entries are indexed in a stereoscope file
// tree, the same index the directory and image resolvers search.
//
// Paths are archive-relative with no leading slash. Files and directories are indexed by base name and
// linked into a tree, so a glob is narrowed to the names its segments fix and verified up the parent
// chain; see glob.go.
// Paths are archive-relative with no leading slash.
type Resolver struct {
// Digests are of the archive file itself.
Digests []file.Digest
@@ -53,34 +52,11 @@ type Resolver struct {
written int64
chunk []byte
// byPath holds every file and directory by rooted path, including directories the archive never
// listed but that entry paths imply
byPath map[string]*node
// files holds every file the archive listed, in path order once finished
files []*node
// names and dirs index files and directories by base name, for globs
names syftindex.PrefixSuffix[[]*node]
dirs syftindex.PrefixSuffix[[]*node]
}
type node struct {
path string // rooted, e.g. "/META-INF/MANIFEST.MF"
name string // the last component of path
isDir bool
header *tar.Header // nil for a directory the archive never listed
content blob
metadata file.Metadata
// the tree the paths spell out, so a glob is matched up the chain and a directory's files are
// found beneath it
parent *node
children []*node
// target is the node whose content this node reads: itself for a regular file, the resolved node
// for a link, and nil for a link that leads nowhere inside this archive
target *node
tree *filetree.FileTree
index filetree.Index
search filetree.Searcher // built by finish, once every entry is in the tree
content map[stereoscopeFile.ID]*blob
entries int // non-directory entries added
}
var _ file.Resolver = (*Resolver)(nil)
@@ -94,7 +70,9 @@ func newResolver(ctx context.Context, fileSystemID, archivePath string, charge *
charge: charge,
budget: budgetFromContext(ctx),
tempDir: tmpdir.FromContext(ctx),
byPath: map[string]*node{"/": {path: "/", isDir: true}},
tree: filetree.New(),
index: filetree.NewIndex(),
content: map[stereoscopeFile.ID]*blob{},
}
}
@@ -108,10 +86,10 @@ func (r *Resolver) Cleanup() {
r.charge.release()
}
// approxIndexBytesPerEntry estimates what holding one node costs beyond its content: the node, its
// header, and its path map and name index slots. It is charged for every node an entry creates,
// including the directories its path implies, so an archive of many tiny entries or of a few entries
// with very deep paths is still bounded by the limits.
// approxIndexBytesPerEntry estimates what holding one tree node costs beyond its content: the node, its
// header and metadata, and its index slots. It is charged for every node an entry creates, including
// the directories its path implies, so an archive of many tiny entries or of a few entries with very
// deep paths is still bounded by the limits.
const approxIndexBytesPerEntry = 2 * 1024
func approxIndexBytes(hdr tar.Header) int64 {
@@ -126,42 +104,37 @@ func (r *Resolver) add(hdr tar.Header, content io.Reader) error {
if entryPath == "/" {
return nil
}
// directories the path implies that do not exist yet are nodes this entry pays for
var newDirs int64
for i := 1; i < len(entryPath); i++ {
if entryPath[i] == '/' {
if _, ok := r.byPath[entryPath[:i]]; !ok {
newDirs++
}
}
}
if !r.chargeIndex(approxIndexBytes(hdr) + newDirs*approxIndexBytesPerEntry) {
return ErrDiskLimitReached
}
for i := 1; i < len(entryPath); i++ {
if entryPath[i] == '/' {
r.nodeAt(entryPath[:i], true)
}
}
_, existed := r.byPath[entryPath]
n := r.nodeAt(entryPath, hdr.FileInfo().IsDir())
if n.header != nil {
if exists, res, _ := r.tree.File(stereoscopeFile.Path(entryPath)); exists && res.HasReference() {
return nil
}
// directories the path implies that do not exist yet are nodes this entry pays for
var newDirs []string
for i := 1; i < len(entryPath); i++ {
if entryPath[i] == '/' && !r.tree.HasPath(stereoscopeFile.Path(entryPath[:i])) {
newDirs = append(newDirs, entryPath[:i])
}
}
if !r.chargeIndex(approxIndexBytes(hdr) + int64(len(newDirs))*approxIndexBytesPerEntry) {
return ErrDiskLimitReached
}
// indexed as if listed, since a search for a directory's children looks the directory up by name
for _, dir := range newDirs {
if err := r.store(dir, tar.Header{Name: dir, Typeflag: tar.TypeDir, Mode: 0o755}, &blob{}); err != nil {
log.WithFields("entry", hdr.Name, "error", err).Trace("skipping archive entry")
return nil
}
}
b := &blob{}
if hdr.Typeflag == tar.TypeReg && content != nil {
// reading one byte past the cap is how an entry over it is told apart from one exactly at it
err := r.put(&n.content, io.LimitReader(content, maxEntryBytes+1))
if err == nil && n.content.size > maxEntryBytes {
err := r.put(b, io.LimitReader(content, maxEntryBytes+1))
if err == nil && b.size > maxEntryBytes {
err = errEntryTooLarge
}
if err != nil {
r.discard(&n.content)
if !existed {
delete(r.byPath, entryPath)
}
r.discard(b)
if errors.Is(err, errEntryTooLarge) {
r.truncate(fmt.Errorf("an entry was skipped: %w of %s", err, humanize.IBytes(uint64(maxEntryBytes))))
return nil
@@ -175,18 +148,45 @@ func (r *Resolver) add(hdr tar.Header, content io.Reader) error {
}
}
n.header = &hdr
n.metadata = r.metadataOf(n)
if n.isDir {
if err := r.store(entryPath, hdr, b); err != nil {
// a path the tree cannot hold, such as a file beneath another file's name
r.discard(b)
log.WithFields("entry", hdr.Name, "error", err).Trace("skipping archive entry")
return nil
}
r.files = append(r.files, n)
r.names.Update(path.Base(entryPath), func(current *syftindex.Node[[]*node]) []*node {
return append(current.Value(), n)
})
if hdr.Typeflag != tar.TypeDir {
r.entries++
}
return nil
}
func (r *Resolver) store(entryPath string, hdr tar.Header, b *blob) error {
ref, err := r.addToTree(entryPath, hdr)
if err != nil {
return err
}
metadata := stereoscopeFile.NewMetadata(hdr, r.open(b))
metadata.Path = reportedPath(entryPath)
r.index.Add(*ref, metadata)
r.content[ref.ID()] = b
return nil
}
func (r *Resolver) addToTree(entryPath string, hdr tar.Header) (*stereoscopeFile.Reference, error) {
p := stereoscopeFile.Path(entryPath)
switch hdr.Typeflag {
case tar.TypeDir:
return r.tree.AddDir(p)
case tar.TypeSymlink:
return r.tree.AddSymLink(p, stereoscopeFile.Path(hdr.Linkname))
case tar.TypeLink:
// a hard link names its target from the archive root
return r.tree.AddHardLink(p, stereoscopeFile.Path(path.Join("/", hdr.Linkname)))
default:
return r.tree.AddFile(p)
}
}
// maxEntryBytes caps any one entry, as the legacy java extraction did (see intFile.SafeCopy): without it
// a single entry of an overlapping-entry zip bomb can decompress up to the disk limit.
var maxEntryBytes int64 = intFile.PerFileReadLimit
@@ -212,76 +212,9 @@ func (r *Resolver) chargeIndex(n int64) bool {
return r.charge.index(n)
}
// nodeAt returns the node at a rooted path, creating it under its parent, which must exist already.
func (r *Resolver) nodeAt(p string, isDir bool) *node {
if n, ok := r.byPath[p]; ok {
return n
}
slash := strings.LastIndexByte(p, '/')
parentPath := p[:slash]
if parentPath == "" {
parentPath = "/"
}
parent := r.byPath[parentPath]
n := &node{path: p, name: p[slash+1:], isDir: isDir, parent: parent}
parent.children = append(parent.children, n)
r.byPath[p] = n
if isDir {
r.dirs.Update(n.name, func(current *syftindex.Node[[]*node]) []*node {
return append(current.Value(), n)
})
}
return n
}
// finish orders the files and resolves links once every entry is added.
// finish builds the search context once every entry is added.
func (r *Resolver) finish() {
sort.Slice(r.files, func(i, j int) bool { return r.files[i].path < r.files[j].path })
for _, n := range r.byPath {
n.target = r.linkTarget(n)
}
}
// maxLinkHops bounds a chain of links pointing at links, which is also what ends a cycle.
const maxLinkHops = 8
func (r *Resolver) linkTarget(n *node) *node {
for hops := 0; hops <= maxLinkHops; hops++ {
if n.header == nil || n.isDir {
return nil
}
if n.header.Linkname == "" {
return n
}
next, ok := r.byPath[linkDestination(n.path, n.header.Linkname, n.header.Typeflag == tar.TypeLink)]
if !ok {
return nil
}
n = next
}
return nil
}
// linkDestination is the rooted path a link points at inside the archive. A hard link names its
// target from the archive root, as does an absolute symlink, so "/etc/passwd" is an entry of this
// archive and never the host's. A relative symlink is joined onto the link's own directory, and
// cleaning against the root keeps "../../x" inside the archive.
func linkDestination(linkPath, linkname string, hard bool) string {
if hard || path.IsAbs(linkname) {
return path.Join("/", linkname)
}
return path.Join(path.Dir(linkPath), linkname)
}
func (r *Resolver) metadataOf(n *node) file.Metadata {
metadata := stereoscopeFile.NewMetadata(*n.header, r.open(&n.content))
// NewMetadata copies the header and points FileInfo into the copy; share the one the node holds
metadata.FileInfo = n.header.FileInfo()
metadata.Path = reportedPath(n.path)
if n.header.Linkname != "" {
metadata.LinkDestination = reportedPath(linkDestination(n.path, n.header.Linkname, n.header.Typeflag == tar.TypeLink))
}
return metadata
r.search = filetree.NewSearchContext(r.tree, r.index)
}
// reportedPath is how paths leave the resolver: archive-relative with no leading slash.
@@ -293,136 +226,129 @@ func rootedPath(userPath string) string {
return path.Clean("/" + userPath)
}
func (r *Resolver) coordinates(n *node) file.Coordinates {
return file.Coordinates{
RealPath: reportedPath(n.path),
// location names a resolution: a link is reported at its target's path with the link's own path as
// the access path.
func (r *Resolver) location(res stereoscopeFile.Resolution) file.Location {
coordinates := file.Coordinates{
RealPath: reportedPath(string(res.RealPath)),
FileSystemID: r.fileSystemID,
ArchivePath: r.archivePath,
}
}
// locationOf names a node by its own path.
func (r *Resolver) locationOf(n *node) file.Location {
return file.NewLocationFromCoordinates(r.coordinates(n))
}
// resolvedLocation names the file behind a node: a link is reported at its target's path with the
// link's own path as the access path, and a link leading nowhere is reported as itself.
func (r *Resolver) resolvedLocation(n *node) file.Location {
if n.target == nil || n.target == n {
return r.locationOf(n)
accessPath := reportedPath(string(res.RequestPath))
if accessPath == "" || accessPath == coordinates.RealPath {
return file.NewLocationFromCoordinates(coordinates)
}
return file.NewVirtualLocationFromCoordinates(r.coordinates(n.target), reportedPath(n.path))
return file.NewVirtualLocationFromCoordinates(coordinates, accessPath)
}
// 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(rootedPath(userPath), filetree.FollowBasenameLinks)
if err != nil || res == nil || !res.HasReference() {
return nil, false
}
entry, err := r.index.Get(*res.Reference)
if err != nil || entry.IsDir() {
return nil, false
}
return res, true
}
func (r *Resolver) FileContentsByLocation(location file.Location) (io.ReadCloser, error) {
n, ok := r.byPath[rootedPath(location.RealPath)]
if !ok || n.header == nil {
res, ok := r.resolve(location.RealPath)
if !ok {
return nil, fmt.Errorf("no archive entry for path=%q", location.RealPath)
}
return entryReader{r.open(&n.content)}, nil
return entryReader{r.open(r.content[res.ID()])}, nil
}
func (r *Resolver) FileMetadataByLocation(location file.Location) (file.Metadata, error) {
n, ok := r.byPath[rootedPath(location.RealPath)]
if !ok {
exists, res, err := r.tree.File(stereoscopeFile.Path(rootedPath(location.RealPath)))
if err != nil || !exists || !res.HasReference() {
return file.Metadata{}, fmt.Errorf("no archive entry for path=%q", location.RealPath)
}
return n.metadata, nil
entry, err := r.index.Get(*res.Reference)
if err != nil {
return file.Metadata{}, err
}
return entry.Metadata, nil
}
func (r *Resolver) HasPath(userPath string) bool {
_, ok := r.byPath[rootedPath(userPath)]
return ok
return r.tree.HasPath(stereoscopeFile.Path(rootedPath(userPath)))
}
func (r *Resolver) FilesByPath(paths ...string) ([]file.Location, error) {
var out []file.Location
seen := map[*node]struct{}{}
seen := stereoscopeFile.NewFileReferenceSet()
for _, userPath := range paths {
n, ok := r.byPath[rootedPath(userPath)]
if !ok || n.isDir || n.header == nil {
res, ok := r.resolve(userPath)
if !ok || seen.Contains(*res.Reference) {
continue
}
if _, dup := seen[n]; dup {
continue
}
seen[n] = struct{}{}
out = append(out, r.resolvedLocation(n))
seen.Add(*res.Reference)
out = append(out, r.location(*res))
}
return out, nil
}
func (r *Resolver) FilesByGlob(patterns ...string) ([]file.Location, error) {
matched := map[*node]struct{}{}
for _, pattern := range patterns {
for _, segments := range parseGlob(pattern) {
candidates, exact := r.globCandidates(segments)
for _, n := range candidates {
if !exact {
ok, err := matchNode(segments, n)
if err != nil {
return nil, fmt.Errorf("invalid glob %q: %w", pattern, err)
}
if !ok {
continue
}
}
matched[n] = struct{}{}
}
}
}
var out []file.Location
for _, n := range onePathPerFile(matched) {
out = append(out, r.resolvedLocation(n))
}
sort.Slice(out, func(i, j int) bool {
if out[i].RealPath != out[j].RealPath {
return out[i].RealPath < out[j].RealPath
seen := stereoscopeFile.NewFileReferenceSet()
for _, pattern := range patterns {
results, err := r.searchByGlob(rootedPath(pattern))
if err != nil {
return nil, fmt.Errorf("invalid glob %q: %w", pattern, err)
}
return out[i].AccessPath < out[j].AccessPath
})
for _, res := range results {
if !res.HasReference() || seen.Contains(*res.Reference) {
continue
}
entry, err := r.index.Get(*res.Reference)
if err != nil || entry.IsDir() {
continue
}
seen.Add(*res.Reference)
out = append(out, r.location(res))
}
}
sortLocations(out)
return out, nil
}
// onePathPerFile keeps one matched path per file. Several paths can name the same content, such as
// a link and its target, and a resolver answers with one: the file's own path when it matched,
// otherwise the lowest-sorting link. A link leading nowhere stands as its own file.
func onePathPerFile(matched map[*node]struct{}) []*node {
best := map[*node]*node{}
for n := range matched {
key := n.target
if key == nil {
key = n
}
current, seen := best[key]
if !seen || n == key || (current != key && n.path < current.path) {
best[key] = n
}
// searchByGlob is the index search, except that stereoscope answers "<dir>/*" by looking the directory
// up by name and the root has none, so "/*" walks the tree instead.
func (r *Resolver) searchByGlob(pattern string) ([]stereoscopeFile.Resolution, error) {
if pattern == "/*" {
return r.tree.FilesByGlob(pattern, filetree.FollowBasenameLinks)
}
out := make([]*node, 0, len(best))
for _, n := range best {
out = append(out, n)
}
return out
return r.search.SearchByGlob(pattern, filetree.FollowBasenameLinks)
}
// sortLocations orders answers by path, which decides how locations group into packages when results
// merge into the SBOM.
func sortLocations(locations []file.Location) {
sort.Slice(locations, func(i, j int) bool {
if locations[i].RealPath != locations[j].RealPath {
return locations[i].RealPath < locations[j].RealPath
}
return locations[i].AccessPath < locations[j].AccessPath
})
}
func (r *Resolver) FilesByMIMEType(types ...string) ([]file.Location, error) {
wanted := map[string]struct{}{}
for _, t := range types {
wanted[t] = struct{}{}
results, err := r.search.SearchByMIMEType(types...)
if err != nil {
return nil, err
}
var out []file.Location
for _, n := range r.files {
// only a file holding bytes has a type of its own; a link's target answers for itself
if n.target != n {
continue
}
if _, ok := wanted[n.metadata.MIMEType]; ok {
out = append(out, r.locationOf(n))
for _, res := range results {
if res.HasReference() {
out = append(out, r.location(res))
}
}
sortLocations(out)
return out, nil
}
@@ -439,9 +365,11 @@ func (r *Resolver) AllLocations(ctx context.Context) <-chan file.Location {
out := make(chan file.Location)
go func() {
defer close(out)
for _, n := range r.files {
refs := r.tree.AllFiles(stereoscopeFile.TypeRegular, stereoscopeFile.TypeSymLink, stereoscopeFile.TypeHardLink)
sort.Slice(refs, func(i, j int) bool { return refs[i].RealPath < refs[j].RealPath })
for _, ref := range refs {
select {
case out <- r.locationOf(n):
case out <- r.location(stereoscopeFile.Resolution{RequestPath: ref.RealPath, Reference: &ref}):
case <-ctx.Done():
return
}
+33 -73
View File
@@ -6,13 +6,14 @@ import (
"context"
"fmt"
"io"
"math/rand"
"sort"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/file"
)
var jarEntriesFixture = map[string]string{
@@ -130,7 +131,7 @@ func TestResolver_add_firstEntryAtAPathWins(t *testing.T) {
r.finish()
assert.Equal(t, "first", readStore(t, r)["a.txt"].body)
assert.Len(t, r.files, 1)
assert.Equal(t, 1, r.entries)
}
func TestResolver_pathsAndContents(t *testing.T) {
@@ -252,6 +253,22 @@ func TestResolver_impliedDirectoriesArePathsButNotFiles(t *testing.T) {
assert.Empty(t, locations)
}
// stereoscope finds a directory's children by looking the directory up by name: directories the archive
// never listed must be indexed, and the root, which has no name, answered another way
func TestResolver_globsForADirectorysChildren(t *testing.T) {
r := resolverOver(t, 1<<20, map[string]string{
"LICENSE": "root",
"META-INF/LICENSE.txt": "nested",
"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/*"))
}
func TestResolver_invalidGlobIsAnError(t *testing.T) {
r := resolverOver(t, 1<<20, jarEntriesFixture)
_, err := r.FilesByGlob("**/[")
@@ -311,14 +328,14 @@ func TestResolver_linksCollapseToOnePath(t *testing.T) {
reason: "collapsing is per file, not per pattern",
},
{
name: "a dangling link keeps its own path",
name: "a dangling link is not a file",
entries: []testEntry{
{name: "opt/real.jar", body: "PK\x03\x04"},
{name: "opt/gone.jar", link: "missing.jar"},
},
pattern: "**/*.jar",
want: []string{"opt/gone.jar", "opt/real.jar"},
reason: "it names no content, so it belongs to no other file's group",
want: []string{"opt/real.jar"},
reason: "it names no content",
},
{
name: "two dangling links are two paths, not one",
@@ -327,8 +344,8 @@ func TestResolver_linksCollapseToOnePath(t *testing.T) {
{name: "opt/b.jar", link: "missing.jar"},
},
pattern: "**/*.jar",
want: []string{"opt/a.jar", "opt/b.jar"},
reason: "nothing resolves them to a common file, so neither may absorb the other",
want: nil,
reason: "a link to nothing is not a file",
},
{
name: "a chain of links collapses onto the file at its end",
@@ -348,8 +365,8 @@ func TestResolver_linksCollapseToOnePath(t *testing.T) {
{name: "opt/b.jar", link: "a.jar"},
},
pattern: "**/*.jar",
want: []string{"opt/a.jar", "opt/b.jar"},
reason: "neither reaches content, so each stands under its own path",
want: nil,
reason: "neither reaches a file",
},
{
name: "a hard link resolves from the archive root, not from its own directory",
@@ -371,60 +388,6 @@ func TestResolver_linksCollapseToOnePath(t *testing.T) {
}
}
// the choice must not depend on the order the search found the candidates in
func TestResolver_collapseIgnoresTheOrderItFinds(t *testing.T) {
r := resolverFrom(t,
testEntry{name: "opt/real.jar", body: "PK\x03\x04"},
testEntry{name: "opt/a-sym.jar", link: "real.jar"},
testEntry{name: "opt/m-sym.jar", link: "real.jar"},
testEntry{name: "opt/z-sym.jar", link: "real.jar"},
)
candidates := []*node{
r.byPath["/opt/real.jar"], r.byPath["/opt/a-sym.jar"],
r.byPath["/opt/m-sym.jar"], r.byPath["/opt/z-sym.jar"],
}
for _, n := range candidates {
require.NotNil(t, n)
}
rng := rand.New(rand.NewSource(1))
for range 64 {
rng.Shuffle(len(candidates), func(i, j int) {
candidates[i], candidates[j] = candidates[j], candidates[i]
})
found := map[*node]struct{}{}
for _, n := range candidates {
found[n] = struct{}{}
}
best := onePathPerFile(found)
require.Len(t, best, 1, "four paths, one file")
assert.Equal(t, "/opt/real.jar", best[0].path, "the file itself must win from any order")
}
// and with the file itself not among the candidates, the lowest-sorting link wins from any order
links := candidates[:0:0]
for _, n := range candidates {
if n.path != "/opt/real.jar" {
links = append(links, n)
}
}
for range 64 {
rng.Shuffle(len(links), func(i, j int) { links[i], links[j] = links[j], links[i] })
found := map[*node]struct{}{}
for _, n := range links {
found[n] = struct{}{}
}
best := onePathPerFile(found)
require.Len(t, best, 1)
assert.Equal(t, "/opt/a-sym.jar", best[0].path, "the lowest-sorting link must win from any order")
}
}
func TestResolver_collapsedLocationReadsTheTarget(t *testing.T) {
r := resolverFrom(t,
testEntry{name: "opt/real.bin", body: "PK\x03\x04 real contents"},
@@ -452,24 +415,21 @@ func TestResolver_collapsedLocationReadsTheTarget(t *testing.T) {
func TestResolver_danglingLinkHasNoContent(t *testing.T) {
r := resolverFrom(t, testEntry{name: "opt/gone.jar", link: "missing.jar"})
locations, err := r.FilesByGlob("**/*.jar")
require.NoError(t, err)
var locations []file.Location
for l := range r.AllLocations(context.Background()) {
locations = append(locations, l)
}
require.Len(t, locations, 1)
assert.Equal(t, "opt/gone.jar", locations[0].RealPath)
reader, err := r.FileContentsByLocation(locations[0])
require.NoError(t, err)
body, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Empty(t, string(body), "a link to nothing holds nothing")
_, err := r.FileContentsByLocation(locations[0])
assert.Error(t, err, "a link to nothing has no content to read")
metadata, err := r.FileMetadataByLocation(locations[0])
require.NoError(t, err)
assert.Equal(t, "opt/missing.jar", metadata.LinkDestination,
"where it pointed is still reported, resolved against the link's own directory")
assert.Equal(t, "missing.jar", metadata.LinkDestination, "where it pointed is still reported, as the archive named it")
}
// a link has no content to sniff a type from; the file it points at answers under its own path
func TestResolver_mimeTypeAnswersOnlyWithFiles(t *testing.T) {
r := resolverFrom(t,
testEntry{name: "opt/real.jar", body: "PK\x03\x04 zip contents here"},
+3 -6
View File
@@ -230,8 +230,8 @@ func TestStorage_filesystemCostDoesNotTrackEntryCount(t *testing.T) {
assert.Len(t, filesIn(t, smallRoot), 1)
assert.Len(t, filesIn(t, largeRoot), 1, "5000 entries cost the same filesystem entries as 2")
assert.Len(t, small.files, 2)
assert.Len(t, large.files, 5000)
assert.Equal(t, 2, small.entries)
assert.Equal(t, 5000, large.entries)
// entries cost their content and nothing else on disk: no headers, no padding
assert.Equal(t, int64(2), small.written)
@@ -264,10 +264,7 @@ func TestStorage_anEntryAtTheEndIsReadBySeeking(t *testing.T) {
offset := int64(bytes.Index(body, []byte(wanted)))
require.Greater(t, offset, int64(len(body))*9/10, "the last entry must really be at the end of the file")
n := r.byPath["/zz-last.txt"]
require.NotNil(t, n)
reader := r.open(&n.content)
reader := r.open(contentAt(t, r, "/zz-last.txt"))
end, err := reader.Seek(0, io.SeekEnd)
require.NoError(t, err)
assert.Equal(t, int64(len(wanted)), end)
-36
View File
@@ -1,36 +0,0 @@
# internal/index
A key-split (radix) index over strings, and a forward/reverse pair of them that answers prefix and
suffix lookups. Ported from a prototype directory-scan package, minus its locking and JSON support: the
only consumer builds an index once and then only reads it, so it is not safe for concurrent writes.
## Types
- `KeySplitIndex[T]` / `Node[T]`: a radix tree. Each node holds at most one value and splits its
children on the longest common prefix of their keys, so `pom.xml` and `pom.properties` share a
`pom.` node.
- `PrefixSuffix[T]`: two `KeySplitIndex` values, one keyed by the string and one by its reverse, so
`ByPrefix("pom")` and `BySuffix(".jar")` are both a single descent followed by a collect.
## Operations
| call | answers |
|---|---|
| `Get(key)` | the value at exactly `key` |
| `Set(key, v)` / `Update(key, fn)` | store, or read-modify-write, the value at `key` |
| `ByPrefix(p)` | every value whose key starts with `p` |
| `BySuffix(s)` (PrefixSuffix only) | every value whose key ends with `s` |
Values are whatever the caller stores; `internal/archive` stores `[]*node` per base name because a
name repeats across directories.
## Where it is used
`internal/archive.Resolver` keeps one `PrefixSuffix[[]*node]` of files by base name, and one of directories. A cataloger glob
whose last segment is a literal, `*suffix` or `prefix*` (nearly all of them: `**/*.jar`,
`**/pom.properties`) is answered from it instead of by walking every path.
## Notes
- Lookups are byte-wise; `reverse` works on runes so a multi-byte name reverses correctly.
- `_find`, `_collect` and `_makeNodeP` are the prototype's internals and are left with its naming.
-181
View File
@@ -1,181 +0,0 @@
// Package index provides a key-split (radix) index, and a forward/reverse pair over it that answers
// prefix and suffix lookups such as "which names end in .jar". It is not safe for concurrent writes: the
// only consumer builds an index once and then only reads it.
package index
import (
"strings"
)
// ----------------------- KeySplitIndex -----------------------
type KeySplitIndex[T any] struct {
Node[T]
}
// ----------------------- Node -----------------------
type NodeMap[T any] map[string]*Node[T]
type Node[T any] struct {
set bool
value T
keyMap NodeMap[T]
keyByChar map[rune]string
}
type NodeUpdateFunc[T any] func(current *Node[T]) (newValue T)
func (n *Node[T]) Value() T {
return n.value
}
func (n *Node[T]) Get(s string) (out T) {
v, equal := n._find(s)
if !equal || v == nil {
return
}
out = v.value
return
}
func (n *Node[T]) Set(name string, value T) {
n._makeNodeP(name, nil).SetValue(value)
}
func (n *Node[T]) ByPrefix(s string) []T {
v, _ := n._find(s)
if v == nil {
return nil
}
return v.Collect()
}
func (n *Node[T]) Update(name string, f NodeUpdateFunc[T]) {
node := n._makeNodeP(name, nil)
node.SetValue(f(node))
}
func (n *Node[T]) SetValue(value T) {
n.value = value
n.set = true
}
func (n *Node[T]) Collect() (values []T) {
n._collect(&values)
return values
}
func (n *Node[T]) _collect(values *[]T) {
if n.set {
*values = append(*values, n.value)
}
for _, v := range n.keyMap {
v._collect(values)
}
}
// _find returns the node for s and whether it matched exactly.
func (n *Node[T]) _find(s string) (node *Node[T], equal bool) {
if s == "" {
return n, true
}
ch := rune(s[0])
key := n.keyByChar[ch]
if key == "" {
return nil, false
}
offset := len(key)
// check our part of the key matches
if offset > 1 {
for i := 1; i < len(s) && i < offset; i++ {
if s[i] != key[i] {
return nil, false
}
}
}
next := n.keyMap[key]
// our key matched, it's equal -- just return the node
if offset == len(s) {
return next, true
}
// our key matched, it's longer -- just return the node as a non-equality match
if offset >= len(s) {
return next, false
}
// our key matched, but it's shorter -- return what we find for the next portion
return next._find(s[offset:])
}
// _makeNodeP returns the node for name, creating it (as nodeIfEmpty when given) and splitting existing
// keys on their longest common prefix as needed.
func (n *Node[T]) _makeNodeP(name string, nodeIfEmpty *Node[T]) *Node[T] {
if name == "" {
return n
}
ch := rune(name[0])
if n.keyByChar == nil {
n.keyByChar = map[rune]string{}
n.keyMap = NodeMap[T]{}
}
key, ok := n.keyByChar[ch]
switch {
case !ok:
// no entry for the given character, create one; this is all we have to do
newNode := orNewNode(nodeIfEmpty)
n.keyMap[name] = newNode
n.keyByChar[ch] = name
return newNode
case key == name:
return n.keyMap[key]
case strings.HasPrefix(key, name):
// existing key is longer than my key, we can just use the existing and make a new sub-entry for the longer key
existingNode := n.keyMap[key]
delete(n.keyMap, key)
newNode := orNewNode(nodeIfEmpty)
n.keyMap[name] = newNode
n.keyByChar[ch] = name
newNode._makeNodeP(key[len(name):], existingNode)
return newNode
case strings.HasPrefix(name, key):
// existing key is shorter than my key, we can just take the substring to remove the
// existing string prefix and set the new node as a child of the existing node
return n.keyMap[key]._makeNodeP(name[len(key):], nodeIfEmpty)
default:
// neither the existing key nor my key contains a prefix of the other, so we find
// the longest common prefix and split BOTH entries as children of a new entry with this common prefix
commonLength := 1 // the first character already matches
for commonLength < min(len(key), len(name)) && key[commonLength] == name[commonLength] {
commonLength++
}
existingNode := n.keyMap[key]
delete(n.keyMap, key)
newNode := orNewNode(nodeIfEmpty)
parentNode := &Node[T]{}
parentNode._makeNodeP(key[commonLength:], existingNode)
parentNode._makeNodeP(name[commonLength:], newNode)
common := key[:commonLength]
n.keyMap[common] = parentNode
n.keyByChar[ch] = common
return newNode
}
}
func orNewNode[T any](n *Node[T]) *Node[T] {
if n == nil {
return &Node[T]{}
}
return n
}
-147
View File
@@ -1,147 +0,0 @@
package index
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
)
func Test_KeySplitIndex_Create(t *testing.T) {
fi := KeySplitIndex[int]{}
fi.Set("one", 1)
requireEqualValues(t, &fi, m{
"one": 1,
})
fi.Set("onesie", 101)
requireEqualValues(t, &fi, m{
"one": m{ // there is a value here
"sie": 101,
},
})
fi.Set("apple", 99)
requireEqualValues(t, &fi, m{
"one": m{
"sie": 101,
},
"apple": 99,
})
fi.Set("alice", 999)
requireEqualValues(t, &fi, m{
"one": m{
"sie": 101,
},
"a": m{
"pple": 99,
"lice": 999,
},
})
}
type m = map[string]any
func requireEqualValues[T comparable](t *testing.T, value *KeySplitIndex[T], expected m) {
_requireNodeValues(t, &value.Node, "", expected)
}
func _requireNodeValues[T comparable](t *testing.T, value *Node[T], path string, expected any) {
switch expected := expected.(type) {
case T:
if value.value != expected {
t.Fatalf("values not equal: %v != %v", value.value, value)
}
case m:
if len(value.keyMap) != len(expected) {
t.Fatalf("number of children not equal: %v != %v", len(value.keyMap), len(expected))
}
for k, v := range expected {
v2, ok := value.keyMap[k]
if !ok {
t.Fatalf("missing key: %s", k)
} else {
_requireNodeValues(t, v2, path, v)
}
}
default:
panic(fmt.Errorf("invalid type: %#v", expected))
}
}
func Test_KeySplitIndex_Get(t *testing.T) {
fi := KeySplitIndex[int]{}
const (
one = 1
two = 2
once = 11
onesie = 11
)
fi.Set("one", one)
require.Equal(t, one, fi.Get("one"))
fi.Set("two", two)
require.Equal(t, one, fi.Get("one"))
require.Equal(t, two, fi.Get("two"))
fi.Set("once", once)
require.Equal(t, one, fi.Get("one"))
require.Equal(t, two, fi.Get("two"))
require.Equal(t, once, fi.Get("once"))
fi.Set("onesie", onesie)
require.Equal(t, one, fi.Get("one"))
require.Equal(t, two, fi.Get("two"))
require.Equal(t, once, fi.Get("once"))
require.Equal(t, onesie, fi.Get("onesie"))
fi.Set("apple", 1000)
require.Equal(t, one, fi.Get("one"))
require.Equal(t, two, fi.Get("two"))
require.Equal(t, once, fi.Get("once"))
require.Equal(t, onesie, fi.Get("onesie"))
fi.Set("alice", 1001)
require.Equal(t, one, fi.Get("one"))
require.Equal(t, two, fi.Get("two"))
require.Equal(t, once, fi.Get("once"))
require.Equal(t, onesie, fi.Get("onesie"))
requireHasAll(t, []int{one, once, onesie}, fi.ByPrefix("o")...)
requireHasAll(t, []int{one, once, onesie}, fi.ByPrefix("on")...)
requireHasAll(t, []int{one, onesie}, fi.ByPrefix("one")...)
requireHasAll(t, []int{onesie}, fi.ByPrefix("ones")...)
requireHasAll(t, []int{onesie}, fi.ByPrefix("onesi")...)
requireHasAll(t, []int{onesie}, fi.ByPrefix("onesie")...)
requireHasAll(t, nil, fi.ByPrefix("onesies")...)
requireHasAll(t, []int{two}, fi.ByPrefix("t")...)
requireHasAll(t, []int{two}, fi.ByPrefix("tw")...)
requireHasAll(t, []int{two}, fi.ByPrefix("two")...)
requireHasAll(t, []int{}, fi.ByPrefix("twos")...)
require.Equal(t, 0, fi.Get("invalid")) // returns the zero value
}
// requireHasAll asserts the values are exactly the required ones, in any order. Carried over with the
// index's own tests so they run here unchanged.
func requireHasAll[T comparable](t *testing.T, values []T, required ...T) {
t.Helper()
require.Len(t, values, len(required))
nextFound:
for _, ex := range required {
for _, existing := range values {
if existing == ex {
continue nextFound
}
}
require.Fail(t, fmt.Sprintf("value %v isn't in slice %v", ex, values))
}
}
-37
View File
@@ -1,37 +0,0 @@
package index
// PrefixSuffix index combines two KeySplitIndexes to simplify indexing and searching by prefix and suffix
type PrefixSuffix[T any] struct {
forwardIndex KeySplitIndex[T]
reverseIndex KeySplitIndex[T]
}
func (f *PrefixSuffix[T]) Set(key string, value T) {
f.forwardIndex.Set(key, value)
f.reverseIndex.Set(reverse(key), value)
}
func (f *PrefixSuffix[T]) Update(key string, fn NodeUpdateFunc[T]) {
f.forwardIndex.Update(key, fn)
f.reverseIndex.Update(reverse(key), fn)
}
func (f *PrefixSuffix[T]) Get(key string) T {
return f.forwardIndex.Get(key)
}
func (f *PrefixSuffix[T]) ByPrefix(prefix string) []T {
return f.forwardIndex.ByPrefix(prefix)
}
func (f *PrefixSuffix[T]) BySuffix(suffix string) []T {
return f.reverseIndex.ByPrefix(reverse(suffix))
}
func reverse(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
-29
View File
@@ -1,29 +0,0 @@
package index
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test_PrefixSuffix(t *testing.T) {
i := PrefixSuffix[int]{}
i.Set("one", 1)
i.Set("once", 11)
i.Set("onesie", 111)
i.Set("two", 2)
i.Set("done", 99)
require.Equal(t, 1, i.Get("one"))
require.Equal(t, 11, i.Get("once"))
require.Equal(t, 111, i.Get("onesie"))
require.Equal(t, 2, i.Get("two"))
requireHasAll(t, i.ByPrefix("on"), 1, 11, 111)
requireHasAll(t, i.BySuffix("e"), 1, 11, 111, 99)
requireHasAll(t, i.BySuffix(""), 1, 2, 11, 111, 99)
requireHasAll(t, i.BySuffix("one"), 1, 99)
requireHasAll(t, i.BySuffix("sie"), 111)
}
func Test_reverse(t *testing.T) {
require.Equal(t, "case", reverse("esac"))
}