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 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 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 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`. `MaxDepth`. See the bomb tests in `extract_test.go`.
## Lookup ## Lookup
Paths are archive-relative with no leading slash; a leading slash on a query is tolerated. Directories Entries are indexed in a stereoscope `filetree.FileTree` + `filetree.Index` and searched with
implied by entry paths exist for `HasPath` but are never files, and every node is linked to its `filetree.SearchContext`, the same index the directory and image resolvers use. Paths are
parent and children. The first entry at a path wins over later duplicates. 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 Links resolve inside the archive only, as the file tree resolves them: a hard link names its target
path lookup matching a link and its target returns one location: the file's own path when it from the archive root. A lookup matching a link and its target returns one location, with
matched, otherwise the lowest-sorting link, with `AccessPath` naming the path matched and `RealPath` `AccessPath` naming the path matched and `RealPath` the file read. Dangling and cyclic links are not
the file read. A dangling link keeps its own path and reads as empty. `FilesByMIMEType` answers only files. `AllLocations` enumerates every non-directory path, links included. Answers are sorted by path.
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.
## Other pieces ## 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)) r.truncate(fmt.Errorf("extraction stopped, possible decompression bomb: %w", err))
return nil 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 // 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)) r.truncate(fmt.Errorf("extraction stopped early: %w", err))
return nil return nil
+9 -8
View File
@@ -19,6 +19,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/syft/syft/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) r, root := extractBytes(t, data, "test.tar.gz", charge)
assert.Error(t, r.Truncated) 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") assert.Greater(t, r.written, int64(40*1024), "the first entry landed whole and the second part way")
_, disk := charge.held() _, disk := charge.held()
assert.LessOrEqual(t, disk, int64(budget)) assert.LessOrEqual(t, disk, int64(budget))
@@ -177,7 +178,7 @@ func TestResolver_extract_entryNamesAreCleanedAndNothingIsWrittenUnderThem(t *te
r, root := extractBytes(t, data, name, nil) r, root := extractBytes(t, data, name, nil)
require.NoError(t, r.Truncated) 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)) storedNames(t, r))
assert.Empty(t, filesIn(t, root), "entries live in the store, never under their own names") 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")) assert.NoFileExists(t, filepath.Join(t.TempDir(), "..", "etc", "passwd"))
@@ -185,13 +186,13 @@ func TestResolver_extract_entryNamesAreCleanedAndNothingIsWrittenUnderThem(t *te
got := readStore(t, r) got := readStore(t, r)
assert.Equal(t, "evil", got["etc/passwd"].body) assert.Equal(t, "evil", got["etc/passwd"].body)
assert.Equal(t, "pwned", got["etc/hosts"].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, fs.FileMode(0o555), got["META-INF"].mode.Perm())
assert.Equal(t, "Manifest-Version: 1.0", got["META-INF/MANIFEST.MF"].body) 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 // links are headers carrying their target verbatim; the index resolves them inside the archive
for _, link := range []string{"passwd", "shadow", "bin/link.txt"} { 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.Empty(t, got[link].body)
} }
assert.Equal(t, "../../../../etc/passwd", got["passwd"].linkTarget) 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) { func TestResolver_extract_dataEdgeCases(t *testing.T) {
t.Run("an empty archive is not an error", func(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) 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) assert.NoError(t, r.Truncated)
}) })
@@ -551,8 +552,8 @@ func TestExtract_entryCountBombIsBoundedByTheMemoryLimit(t *testing.T) {
t.Cleanup(r.Cleanup) t.Cleanup(r.Cleanup)
assert.Error(t, r.Truncated) assert.Error(t, r.Truncated)
assert.Less(t, len(r.files), 20_000) assert.Less(t, r.entries, 20_000)
assert.LessOrEqual(t, int64(len(r.byPath))*approxIndexBytesPerEntry, limits.MaxMemoryBytes+approxIndexBytesPerEntry, assert.LessOrEqual(t, int64(nodeCount(r))*approxIndexBytesPerEntry, limits.MaxMemoryBytes+approxIndexBytesPerEntry,
"nodes held must not exceed what the memory limit paid for") "nodes held must not exceed what the memory limit paid for")
peakMemory, _ := limiter.Peak() peakMemory, _ := limiter.Peak()
assert.LessOrEqual(t, peakMemory, limits.MaxMemoryBytes) assert.LessOrEqual(t, peakMemory, limits.MaxMemoryBytes)
@@ -579,7 +580,7 @@ func TestExtract_deepPathBombIsBoundedByTheMemoryLimit(t *testing.T) {
t.Cleanup(r.Cleanup) t.Cleanup(r.Cleanup)
assert.Error(t, r.Truncated) 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() peakMemory, _ := limiter.Peak()
assert.LessOrEqual(t, peakMemory, limits.MaxMemoryBytes) 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" "github.com/stretchr/testify/require"
stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/syft/internal/tmpdir" "github.com/anchore/syft/internal/tmpdir"
"github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/file"
) )
@@ -260,22 +261,21 @@ type storedEntry struct {
body string body string
linkTarget string linkTarget string
mode fs.FileMode 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 { func readStore(t testing.TB, r *Resolver) map[string]storedEntry {
t.Helper() t.Helper()
out := map[string]storedEntry{} out := map[string]storedEntry{}
for _, n := range r.byPath { for _, ref := range r.tree.AllFiles(stereoscopeFile.AllTypes()...) {
if n.header == nil { entry, err := r.index.Get(ref)
continue require.NoError(t, err)
} out[entry.Metadata.Path] = storedEntry{
out[n.header.Name] = storedEntry{ body: readEntry(t, r.open(r.content[ref.ID()])),
body: readEntry(t, r.open(&n.content)), linkTarget: entry.Metadata.LinkDestination,
linkTarget: n.header.Linkname, mode: entry.Metadata.FileInfo.Mode(),
mode: n.header.FileInfo().Mode(), kind: entry.Metadata.Type,
typeflag: n.header.Typeflag,
} }
} }
return out return out
@@ -285,15 +285,27 @@ func readStore(t testing.TB, r *Resolver) map[string]storedEntry {
func storedNames(t testing.TB, r *Resolver) []string { func storedNames(t testing.TB, r *Resolver) []string {
t.Helper() t.Helper()
var names []string var names []string
for _, n := range r.byPath { for name := range readStore(t, r) {
if n.header != nil { names = append(names, name)
names = append(names, n.header.Name)
}
} }
sort.Strings(names) sort.Strings(names)
return 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. // memCharge is a charge against a memory bound with disk unbounded.
func memCharge(maxMemory int64) *charge { func memCharge(maxMemory int64) *charge {
return NewLimiter(Limits{MaxMemoryBytes: maxMemory, MaxDiskBytes: -1}).charge() return NewLimiter(Limits{MaxMemoryBytes: maxMemory, MaxDiskBytes: -1}).charge()
+148 -220
View File
@@ -17,19 +17,18 @@ import (
"github.com/mholt/archives" "github.com/mholt/archives"
stereoscopeFile "github.com/anchore/stereoscope/pkg/file" stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/stereoscope/pkg/filetree"
intFile "github.com/anchore/syft/internal/file" 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/log"
"github.com/anchore/syft/internal/tmpdir" "github.com/anchore/syft/internal/tmpdir"
"github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/file"
) )
// Resolver is one archive opened as its own filesystem: a file.Resolver over the entries Extract fed // 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 // Paths are archive-relative with no leading slash.
// linked into a tree, so a glob is narrowed to the names its segments fix and verified up the parent
// chain; see glob.go.
type Resolver struct { type Resolver struct {
// Digests are of the archive file itself. // Digests are of the archive file itself.
Digests []file.Digest Digests []file.Digest
@@ -53,34 +52,11 @@ type Resolver struct {
written int64 written int64
chunk []byte chunk []byte
// byPath holds every file and directory by rooted path, including directories the archive never tree *filetree.FileTree
// listed but that entry paths imply index filetree.Index
byPath map[string]*node search filetree.Searcher // built by finish, once every entry is in the tree
content map[stereoscopeFile.ID]*blob
// files holds every file the archive listed, in path order once finished entries int // non-directory entries added
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
} }
var _ file.Resolver = (*Resolver)(nil) var _ file.Resolver = (*Resolver)(nil)
@@ -94,7 +70,9 @@ func newResolver(ctx context.Context, fileSystemID, archivePath string, charge *
charge: charge, charge: charge,
budget: budgetFromContext(ctx), budget: budgetFromContext(ctx),
tempDir: tmpdir.FromContext(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() r.charge.release()
} }
// approxIndexBytesPerEntry estimates what holding one node costs beyond its content: the node, its // approxIndexBytesPerEntry estimates what holding one tree 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, // header and metadata, and its index slots. It is charged for every node an entry creates, including
// including the directories its path implies, so an archive of many tiny entries or of a few entries // the directories its path implies, so an archive of many tiny entries or of a few entries with very
// with very deep paths is still bounded by the limits. // deep paths is still bounded by the limits.
const approxIndexBytesPerEntry = 2 * 1024 const approxIndexBytesPerEntry = 2 * 1024
func approxIndexBytes(hdr tar.Header) int64 { func approxIndexBytes(hdr tar.Header) int64 {
@@ -126,42 +104,37 @@ func (r *Resolver) add(hdr tar.Header, content io.Reader) error {
if entryPath == "/" { if entryPath == "/" {
return nil return nil
} }
if exists, res, _ := r.tree.File(stereoscopeFile.Path(entryPath)); exists && res.HasReference() {
// 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 {
return nil 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 { 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 // 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)) err := r.put(b, io.LimitReader(content, maxEntryBytes+1))
if err == nil && n.content.size > maxEntryBytes { if err == nil && b.size > maxEntryBytes {
err = errEntryTooLarge err = errEntryTooLarge
} }
if err != nil { if err != nil {
r.discard(&n.content) r.discard(b)
if !existed {
delete(r.byPath, entryPath)
}
if errors.Is(err, errEntryTooLarge) { if errors.Is(err, errEntryTooLarge) {
r.truncate(fmt.Errorf("an entry was skipped: %w of %s", err, humanize.IBytes(uint64(maxEntryBytes)))) r.truncate(fmt.Errorf("an entry was skipped: %w of %s", err, humanize.IBytes(uint64(maxEntryBytes))))
return nil return nil
@@ -175,18 +148,45 @@ func (r *Resolver) add(hdr tar.Header, content io.Reader) error {
} }
} }
n.header = &hdr if err := r.store(entryPath, hdr, b); err != nil {
n.metadata = r.metadataOf(n) // a path the tree cannot hold, such as a file beneath another file's name
if n.isDir { r.discard(b)
log.WithFields("entry", hdr.Name, "error", err).Trace("skipping archive entry")
return nil return nil
} }
r.files = append(r.files, n) if hdr.Typeflag != tar.TypeDir {
r.names.Update(path.Base(entryPath), func(current *syftindex.Node[[]*node]) []*node { r.entries++
return append(current.Value(), n) }
})
return nil 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 // 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. // a single entry of an overlapping-entry zip bomb can decompress up to the disk limit.
var maxEntryBytes int64 = intFile.PerFileReadLimit var maxEntryBytes int64 = intFile.PerFileReadLimit
@@ -212,76 +212,9 @@ func (r *Resolver) chargeIndex(n int64) bool {
return r.charge.index(n) return r.charge.index(n)
} }
// nodeAt returns the node at a rooted path, creating it under its parent, which must exist already. // finish builds the search context once every entry is added.
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.
func (r *Resolver) finish() { func (r *Resolver) finish() {
sort.Slice(r.files, func(i, j int) bool { return r.files[i].path < r.files[j].path }) r.search = filetree.NewSearchContext(r.tree, r.index)
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
} }
// reportedPath is how paths leave the resolver: archive-relative with no leading slash. // 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) return path.Clean("/" + userPath)
} }
func (r *Resolver) coordinates(n *node) file.Coordinates { // location names a resolution: a link is reported at its target's path with the link's own path as
return file.Coordinates{ // the access path.
RealPath: reportedPath(n.path), func (r *Resolver) location(res stereoscopeFile.Resolution) file.Location {
coordinates := file.Coordinates{
RealPath: reportedPath(string(res.RealPath)),
FileSystemID: r.fileSystemID, FileSystemID: r.fileSystemID,
ArchivePath: r.archivePath, ArchivePath: r.archivePath,
} }
accessPath := reportedPath(string(res.RequestPath))
if accessPath == "" || accessPath == coordinates.RealPath {
return file.NewLocationFromCoordinates(coordinates)
}
return file.NewVirtualLocationFromCoordinates(coordinates, accessPath)
} }
// locationOf names a node by its own path. // resolve finds the file a path names, following links, and reports false for directories and
func (r *Resolver) locationOf(n *node) file.Location { // paths the archive never listed.
return file.NewLocationFromCoordinates(r.coordinates(n)) 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)
// resolvedLocation names the file behind a node: a link is reported at its target's path with the if err != nil || entry.IsDir() {
// link's own path as the access path, and a link leading nowhere is reported as itself. return nil, false
func (r *Resolver) resolvedLocation(n *node) file.Location {
if n.target == nil || n.target == n {
return r.locationOf(n)
} }
return file.NewVirtualLocationFromCoordinates(r.coordinates(n.target), reportedPath(n.path)) return res, true
} }
func (r *Resolver) FileContentsByLocation(location file.Location) (io.ReadCloser, error) { func (r *Resolver) FileContentsByLocation(location file.Location) (io.ReadCloser, error) {
n, ok := r.byPath[rootedPath(location.RealPath)] res, ok := r.resolve(location.RealPath)
if !ok || n.header == nil { if !ok {
return nil, fmt.Errorf("no archive entry for path=%q", location.RealPath) 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) { func (r *Resolver) FileMetadataByLocation(location file.Location) (file.Metadata, error) {
n, ok := r.byPath[rootedPath(location.RealPath)] exists, res, err := r.tree.File(stereoscopeFile.Path(rootedPath(location.RealPath)))
if !ok { if err != nil || !exists || !res.HasReference() {
return file.Metadata{}, fmt.Errorf("no archive entry for path=%q", location.RealPath) 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 { func (r *Resolver) HasPath(userPath string) bool {
_, ok := r.byPath[rootedPath(userPath)] return r.tree.HasPath(stereoscopeFile.Path(rootedPath(userPath)))
return ok
} }
func (r *Resolver) FilesByPath(paths ...string) ([]file.Location, error) { func (r *Resolver) FilesByPath(paths ...string) ([]file.Location, error) {
var out []file.Location var out []file.Location
seen := map[*node]struct{}{} seen := stereoscopeFile.NewFileReferenceSet()
for _, userPath := range paths { for _, userPath := range paths {
n, ok := r.byPath[rootedPath(userPath)] res, ok := r.resolve(userPath)
if !ok || n.isDir || n.header == nil { if !ok || seen.Contains(*res.Reference) {
continue continue
} }
if _, dup := seen[n]; dup { seen.Add(*res.Reference)
continue out = append(out, r.location(*res))
}
seen[n] = struct{}{}
out = append(out, r.resolvedLocation(n))
} }
return out, nil return out, nil
} }
func (r *Resolver) FilesByGlob(patterns ...string) ([]file.Location, error) { func (r *Resolver) FilesByGlob(patterns ...string) ([]file.Location, error) {
matched := map[*node]struct{}{} var out []file.Location
seen := stereoscopeFile.NewFileReferenceSet()
for _, pattern := range patterns { for _, pattern := range patterns {
for _, segments := range parseGlob(pattern) { results, err := r.searchByGlob(rootedPath(pattern))
candidates, exact := r.globCandidates(segments)
for _, n := range candidates {
if !exact {
ok, err := matchNode(segments, n)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid glob %q: %w", pattern, err) return nil, fmt.Errorf("invalid glob %q: %w", pattern, err)
} }
if !ok { for _, res := range results {
if !res.HasReference() || seen.Contains(*res.Reference) {
continue continue
} }
entry, err := r.index.Get(*res.Reference)
if err != nil || entry.IsDir() {
continue
} }
matched[n] = struct{}{} seen.Add(*res.Reference)
out = append(out, r.location(res))
} }
} }
} sortLocations(out)
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
}
return out[i].AccessPath < out[j].AccessPath
})
return out, nil return out, nil
} }
// onePathPerFile keeps one matched path per file. Several paths can name the same content, such as // searchByGlob is the index search, except that stereoscope answers "<dir>/*" by looking the directory
// a link and its target, and a resolver answers with one: the file's own path when it matched, // up by name and the root has none, so "/*" walks the tree instead.
// otherwise the lowest-sorting link. A link leading nowhere stands as its own file. func (r *Resolver) searchByGlob(pattern string) ([]stereoscopeFile.Resolution, error) {
func onePathPerFile(matched map[*node]struct{}) []*node { if pattern == "/*" {
best := map[*node]*node{} return r.tree.FilesByGlob(pattern, filetree.FollowBasenameLinks)
for n := range matched {
key := n.target
if key == nil {
key = n
} }
current, seen := best[key] return r.search.SearchByGlob(pattern, filetree.FollowBasenameLinks)
if !seen || n == key || (current != key && n.path < current.path) {
best[key] = n
} }
// 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
} }
out := make([]*node, 0, len(best)) return locations[i].AccessPath < locations[j].AccessPath
for _, n := range best { })
out = append(out, n)
}
return out
} }
func (r *Resolver) FilesByMIMEType(types ...string) ([]file.Location, error) { func (r *Resolver) FilesByMIMEType(types ...string) ([]file.Location, error) {
wanted := map[string]struct{}{} results, err := r.search.SearchByMIMEType(types...)
for _, t := range types { if err != nil {
wanted[t] = struct{}{} return nil, err
} }
var out []file.Location var out []file.Location
for _, n := range r.files { for _, res := range results {
// only a file holding bytes has a type of its own; a link's target answers for itself if res.HasReference() {
if n.target != n { out = append(out, r.location(res))
continue
}
if _, ok := wanted[n.metadata.MIMEType]; ok {
out = append(out, r.locationOf(n))
} }
} }
sortLocations(out)
return out, nil return out, nil
} }
@@ -439,9 +365,11 @@ func (r *Resolver) AllLocations(ctx context.Context) <-chan file.Location {
out := make(chan file.Location) out := make(chan file.Location)
go func() { go func() {
defer close(out) 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 { select {
case out <- r.locationOf(n): case out <- r.location(stereoscopeFile.Resolution{RequestPath: ref.RealPath, Reference: &ref}):
case <-ctx.Done(): case <-ctx.Done():
return return
} }
+33 -73
View File
@@ -6,13 +6,14 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"math/rand"
"sort" "sort"
"strings" "strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/file"
) )
var jarEntriesFixture = map[string]string{ var jarEntriesFixture = map[string]string{
@@ -130,7 +131,7 @@ func TestResolver_add_firstEntryAtAPathWins(t *testing.T) {
r.finish() r.finish()
assert.Equal(t, "first", readStore(t, r)["a.txt"].body) 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) { func TestResolver_pathsAndContents(t *testing.T) {
@@ -252,6 +253,22 @@ func TestResolver_impliedDirectoriesArePathsButNotFiles(t *testing.T) {
assert.Empty(t, locations) 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) { func TestResolver_invalidGlobIsAnError(t *testing.T) {
r := resolverOver(t, 1<<20, jarEntriesFixture) r := resolverOver(t, 1<<20, jarEntriesFixture)
_, err := r.FilesByGlob("**/[") _, err := r.FilesByGlob("**/[")
@@ -311,14 +328,14 @@ func TestResolver_linksCollapseToOnePath(t *testing.T) {
reason: "collapsing is per file, not per pattern", 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{ entries: []testEntry{
{name: "opt/real.jar", body: "PK\x03\x04"}, {name: "opt/real.jar", body: "PK\x03\x04"},
{name: "opt/gone.jar", link: "missing.jar"}, {name: "opt/gone.jar", link: "missing.jar"},
}, },
pattern: "**/*.jar", pattern: "**/*.jar",
want: []string{"opt/gone.jar", "opt/real.jar"}, want: []string{"opt/real.jar"},
reason: "it names no content, so it belongs to no other file's group", reason: "it names no content",
}, },
{ {
name: "two dangling links are two paths, not one", 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"}, {name: "opt/b.jar", link: "missing.jar"},
}, },
pattern: "**/*.jar", pattern: "**/*.jar",
want: []string{"opt/a.jar", "opt/b.jar"}, want: nil,
reason: "nothing resolves them to a common file, so neither may absorb the other", reason: "a link to nothing is not a file",
}, },
{ {
name: "a chain of links collapses onto the file at its end", 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"}, {name: "opt/b.jar", link: "a.jar"},
}, },
pattern: "**/*.jar", pattern: "**/*.jar",
want: []string{"opt/a.jar", "opt/b.jar"}, want: nil,
reason: "neither reaches content, so each stands under its own path", reason: "neither reaches a file",
}, },
{ {
name: "a hard link resolves from the archive root, not from its own directory", 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) { func TestResolver_collapsedLocationReadsTheTarget(t *testing.T) {
r := resolverFrom(t, r := resolverFrom(t,
testEntry{name: "opt/real.bin", body: "PK\x03\x04 real contents"}, 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) { func TestResolver_danglingLinkHasNoContent(t *testing.T) {
r := resolverFrom(t, testEntry{name: "opt/gone.jar", link: "missing.jar"}) r := resolverFrom(t, testEntry{name: "opt/gone.jar", link: "missing.jar"})
locations, err := r.FilesByGlob("**/*.jar") var locations []file.Location
require.NoError(t, err) for l := range r.AllLocations(context.Background()) {
locations = append(locations, l)
}
require.Len(t, locations, 1) require.Len(t, locations, 1)
assert.Equal(t, "opt/gone.jar", locations[0].RealPath) assert.Equal(t, "opt/gone.jar", locations[0].RealPath)
reader, err := r.FileContentsByLocation(locations[0]) _, err := r.FileContentsByLocation(locations[0])
require.NoError(t, err) assert.Error(t, err, "a link to nothing has no content to read")
body, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Empty(t, string(body), "a link to nothing holds nothing")
metadata, err := r.FileMetadataByLocation(locations[0]) metadata, err := r.FileMetadataByLocation(locations[0])
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "opt/missing.jar", metadata.LinkDestination, assert.Equal(t, "missing.jar", metadata.LinkDestination, "where it pointed is still reported, as the archive named it")
"where it pointed is still reported, resolved against the link's own directory")
} }
// 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) { func TestResolver_mimeTypeAnswersOnlyWithFiles(t *testing.T) {
r := resolverFrom(t, r := resolverFrom(t,
testEntry{name: "opt/real.jar", body: "PK\x03\x04 zip contents here"}, 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, smallRoot), 1)
assert.Len(t, filesIn(t, largeRoot), 1, "5000 entries cost the same filesystem entries as 2") assert.Len(t, filesIn(t, largeRoot), 1, "5000 entries cost the same filesystem entries as 2")
assert.Len(t, small.files, 2) assert.Equal(t, 2, small.entries)
assert.Len(t, large.files, 5000) assert.Equal(t, 5000, large.entries)
// entries cost their content and nothing else on disk: no headers, no padding // entries cost their content and nothing else on disk: no headers, no padding
assert.Equal(t, int64(2), small.written) assert.Equal(t, int64(2), small.written)
@@ -264,10 +264,7 @@ func TestStorage_anEntryAtTheEndIsReadBySeeking(t *testing.T) {
offset := int64(bytes.Index(body, []byte(wanted))) 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") 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"] reader := r.open(contentAt(t, r, "/zz-last.txt"))
require.NotNil(t, n)
reader := r.open(&n.content)
end, err := reader.Seek(0, io.SeekEnd) end, err := reader.Seek(0, io.SeekEnd)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, int64(len(wanted)), end) 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"))
}