mirror of
https://github.com/anchore/syft.git
synced 2026-11-04 16:36:40 +01:00
chore: remove custom index in favor of existing filetree.Index
Signed-off-by: Keith Zantow <kzantow@gmail.com>
This commit is contained in:
+10
-20
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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"))
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user