mirror of
https://github.com/anchore/syft.git
synced 2026-11-01 15:06:36 +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
|
||||
against the memory limit, including directories implied by deep paths, so an archive of millions of
|
||||
tiny entries or of a few entries with 4 KiB names stops at the memory limit. Names longer than
|
||||
PATH_MAX are refused, link chains stop after `maxLinkHops`, and nesting stops at the task's
|
||||
PATH_MAX are refused, and nesting stops at the task's
|
||||
`MaxDepth`. See the bomb tests in `extract_test.go`.
|
||||
|
||||
## Lookup
|
||||
|
||||
Paths are archive-relative with no leading slash; a leading slash on a query is tolerated. Directories
|
||||
implied by entry paths exist for `HasPath` but are never files, and every node is linked to its
|
||||
parent and children. The first entry at a path wins over later duplicates.
|
||||
Entries are indexed in a stereoscope `filetree.FileTree` + `filetree.Index` and searched with
|
||||
`filetree.SearchContext`, the same index the directory and image resolvers use. Paths are
|
||||
archive-relative with no leading slash; a leading slash on a query is tolerated. Directories implied
|
||||
by entry paths exist for `HasPath` but are never files. The first entry at a path wins over later
|
||||
duplicates.
|
||||
|
||||
Links resolve inside the archive only, up to `maxLinkHops` hops, which also ends a cycle. A glob or
|
||||
path lookup matching a link and its target returns one location: the file's own path when it
|
||||
matched, otherwise the lowest-sorting link, with `AccessPath` naming the path matched and `RealPath`
|
||||
the file read. A dangling link keeps its own path and reads as empty. `FilesByMIMEType` answers only
|
||||
with files holding bytes, since a link has nothing to sniff; `AllLocations` enumerates every path,
|
||||
links included. Answers are sorted by path.
|
||||
|
||||
`FilesByGlob` (glob.go) parses a pattern once, cached (bounded by `maxCachedGlobs`), into segments.
|
||||
A brace group inside a segment, `g{o,o.exe}`, makes that segment a set of alternatives; only a group
|
||||
spanning `/` expands the whole pattern. The literal text at either end of every alternative is looked
|
||||
up in a base-name index (`internal/index`): files for the last segment, directories for the rest, so
|
||||
`**/one/two/**/[a-z]thing` is narrowed to what lies under directories named `two` or to files ending
|
||||
in `thing`, whichever is smaller (a file-name lookup of `narrowEnough` or fewer is taken as is). Each
|
||||
candidate is then verified segment by segment up its parent chain with `path.Match`. `**/<name>`,
|
||||
`**/*<suffix>`, `**/<prefix>*` and brace sets of those are answered from the index alone. Only a
|
||||
pattern with no literal text anywhere, such as `**/[lm]*`, is matched against every file.
|
||||
Links resolve inside the archive only, as the file tree resolves them: a hard link names its target
|
||||
from the archive root. A lookup matching a link and its target returns one location, with
|
||||
`AccessPath` naming the path matched and `RealPath` the file read. Dangling and cyclic links are not
|
||||
files. `AllLocations` enumerates every non-directory path, links included. Answers are sorted by path.
|
||||
|
||||
## Other pieces
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ func (r *Resolver) extract(ctx context.Context, format archives.Extractor, conte
|
||||
r.truncate(fmt.Errorf("extraction stopped, possible decompression bomb: %w", err))
|
||||
return nil
|
||||
}
|
||||
if err != nil && ctx.Err() == nil && len(r.files) > 0 {
|
||||
if err != nil && ctx.Err() == nil && r.entries > 0 {
|
||||
// the stream broke partway (a truncated .tar.gz); what was read before that is still good
|
||||
r.truncate(fmt.Errorf("extraction stopped early: %w", err))
|
||||
return nil
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
)
|
||||
|
||||
@@ -106,7 +107,7 @@ func TestResolver_extract_diskLimitBitesMidEntry(t *testing.T) {
|
||||
r, root := extractBytes(t, data, "test.tar.gz", charge)
|
||||
|
||||
assert.Error(t, r.Truncated)
|
||||
assert.Equal(t, []string{"a/first.txt"}, storedNames(t, r))
|
||||
assert.Equal(t, []string{"a", "a/first.txt", "b"}, storedNames(t, r), "the directory a refused entry implied stays")
|
||||
assert.Greater(t, r.written, int64(40*1024), "the first entry landed whole and the second part way")
|
||||
_, disk := charge.held()
|
||||
assert.LessOrEqual(t, disk, int64(budget))
|
||||
@@ -177,7 +178,7 @@ func TestResolver_extract_entryNamesAreCleanedAndNothingIsWrittenUnderThem(t *te
|
||||
r, root := extractBytes(t, data, name, nil)
|
||||
require.NoError(t, r.Truncated)
|
||||
|
||||
assert.Equal(t, []string{"META-INF", "META-INF/MANIFEST.MF", "bin/link.txt", "etc/hosts", "etc/passwd", "passwd", "shadow"},
|
||||
assert.Equal(t, []string{"META-INF", "META-INF/MANIFEST.MF", "bin", "bin/link.txt", "etc", "etc/hosts", "etc/passwd", "passwd", "shadow"},
|
||||
storedNames(t, r))
|
||||
assert.Empty(t, filesIn(t, root), "entries live in the store, never under their own names")
|
||||
assert.NoFileExists(t, filepath.Join(t.TempDir(), "..", "etc", "passwd"))
|
||||
@@ -185,13 +186,13 @@ func TestResolver_extract_entryNamesAreCleanedAndNothingIsWrittenUnderThem(t *te
|
||||
got := readStore(t, r)
|
||||
assert.Equal(t, "evil", got["etc/passwd"].body)
|
||||
assert.Equal(t, "pwned", got["etc/hosts"].body)
|
||||
assert.Equal(t, byte(tar.TypeDir), got["META-INF"].typeflag)
|
||||
assert.Equal(t, stereoscopeFile.TypeDirectory, got["META-INF"].kind)
|
||||
assert.Equal(t, fs.FileMode(0o555), got["META-INF"].mode.Perm())
|
||||
assert.Equal(t, "Manifest-Version: 1.0", got["META-INF/MANIFEST.MF"].body)
|
||||
|
||||
// links are headers carrying their target verbatim; the index resolves them inside the archive
|
||||
for _, link := range []string{"passwd", "shadow", "bin/link.txt"} {
|
||||
assert.Equal(t, byte(tar.TypeSymlink), got[link].typeflag)
|
||||
assert.Equal(t, stereoscopeFile.TypeSymLink, got[link].kind)
|
||||
assert.Empty(t, got[link].body)
|
||||
}
|
||||
assert.Equal(t, "../../../../etc/passwd", got["passwd"].linkTarget)
|
||||
@@ -204,7 +205,7 @@ func TestResolver_extract_entryNamesAreCleanedAndNothingIsWrittenUnderThem(t *te
|
||||
func TestResolver_extract_dataEdgeCases(t *testing.T) {
|
||||
t.Run("an empty archive is not an error", func(t *testing.T) {
|
||||
r, _ := extractBytes(t, zipBytes(t, map[string]string{}), "test.zip", nil)
|
||||
assert.Empty(t, r.files)
|
||||
assert.Zero(t, r.entries)
|
||||
assert.NoError(t, r.Truncated)
|
||||
})
|
||||
|
||||
@@ -551,8 +552,8 @@ func TestExtract_entryCountBombIsBoundedByTheMemoryLimit(t *testing.T) {
|
||||
t.Cleanup(r.Cleanup)
|
||||
|
||||
assert.Error(t, r.Truncated)
|
||||
assert.Less(t, len(r.files), 20_000)
|
||||
assert.LessOrEqual(t, int64(len(r.byPath))*approxIndexBytesPerEntry, limits.MaxMemoryBytes+approxIndexBytesPerEntry,
|
||||
assert.Less(t, r.entries, 20_000)
|
||||
assert.LessOrEqual(t, int64(nodeCount(r))*approxIndexBytesPerEntry, limits.MaxMemoryBytes+approxIndexBytesPerEntry,
|
||||
"nodes held must not exceed what the memory limit paid for")
|
||||
peakMemory, _ := limiter.Peak()
|
||||
assert.LessOrEqual(t, peakMemory, limits.MaxMemoryBytes)
|
||||
@@ -579,7 +580,7 @@ func TestExtract_deepPathBombIsBoundedByTheMemoryLimit(t *testing.T) {
|
||||
t.Cleanup(r.Cleanup)
|
||||
|
||||
assert.Error(t, r.Truncated)
|
||||
assert.LessOrEqual(t, int64(len(r.byPath))*approxIndexBytesPerEntry, limits.MaxMemoryBytes+approxIndexBytesPerEntry)
|
||||
assert.LessOrEqual(t, int64(nodeCount(r))*approxIndexBytesPerEntry, limits.MaxMemoryBytes+approxIndexBytesPerEntry)
|
||||
peakMemory, _ := limiter.Peak()
|
||||
assert.LessOrEqual(t, peakMemory, limits.MaxMemoryBytes)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
|
||||
"github.com/anchore/syft/internal/tmpdir"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
)
|
||||
@@ -260,22 +261,21 @@ type storedEntry struct {
|
||||
body string
|
||||
linkTarget string
|
||||
mode fs.FileMode
|
||||
typeflag byte
|
||||
kind stereoscopeFile.Type
|
||||
}
|
||||
|
||||
// readStore reads every listed entry, files and directories alike, by name.
|
||||
// readStore reads every stored entry by name: files, links, and directories listed or implied.
|
||||
func readStore(t testing.TB, r *Resolver) map[string]storedEntry {
|
||||
t.Helper()
|
||||
out := map[string]storedEntry{}
|
||||
for _, n := range r.byPath {
|
||||
if n.header == nil {
|
||||
continue
|
||||
}
|
||||
out[n.header.Name] = storedEntry{
|
||||
body: readEntry(t, r.open(&n.content)),
|
||||
linkTarget: n.header.Linkname,
|
||||
mode: n.header.FileInfo().Mode(),
|
||||
typeflag: n.header.Typeflag,
|
||||
for _, ref := range r.tree.AllFiles(stereoscopeFile.AllTypes()...) {
|
||||
entry, err := r.index.Get(ref)
|
||||
require.NoError(t, err)
|
||||
out[entry.Metadata.Path] = storedEntry{
|
||||
body: readEntry(t, r.open(r.content[ref.ID()])),
|
||||
linkTarget: entry.Metadata.LinkDestination,
|
||||
mode: entry.Metadata.FileInfo.Mode(),
|
||||
kind: entry.Metadata.Type,
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -285,15 +285,27 @@ func readStore(t testing.TB, r *Resolver) map[string]storedEntry {
|
||||
func storedNames(t testing.TB, r *Resolver) []string {
|
||||
t.Helper()
|
||||
var names []string
|
||||
for _, n := range r.byPath {
|
||||
if n.header != nil {
|
||||
names = append(names, n.header.Name)
|
||||
}
|
||||
for name := range readStore(t, r) {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// nodeCount is every path the tree holds, including directories the archive never listed.
|
||||
func nodeCount(r *Resolver) int {
|
||||
return len(r.tree.AllRealPaths())
|
||||
}
|
||||
|
||||
// contentAt is the blob held for the entry at a rooted path.
|
||||
func contentAt(t testing.TB, r *Resolver, p string) *blob {
|
||||
t.Helper()
|
||||
_, res, err := r.tree.File(stereoscopeFile.Path(p))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
return r.content[res.Reference.ID()]
|
||||
}
|
||||
|
||||
// memCharge is a charge against a memory bound with disk unbounded.
|
||||
func memCharge(maxMemory int64) *charge {
|
||||
return NewLimiter(Limits{MaxMemoryBytes: maxMemory, MaxDiskBytes: -1}).charge()
|
||||
|
||||
+160
-232
@@ -17,19 +17,18 @@ import (
|
||||
"github.com/mholt/archives"
|
||||
|
||||
stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
|
||||
"github.com/anchore/stereoscope/pkg/filetree"
|
||||
intFile "github.com/anchore/syft/internal/file"
|
||||
syftindex "github.com/anchore/syft/internal/index"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/internal/tmpdir"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
)
|
||||
|
||||
// Resolver is one archive opened as its own filesystem: a file.Resolver over the entries Extract fed
|
||||
// into it, holding their content within the scan's limits.
|
||||
// into it, holding their content within the scan's limits. Entries are indexed in a stereoscope file
|
||||
// tree, the same index the directory and image resolvers search.
|
||||
//
|
||||
// Paths are archive-relative with no leading slash. Files and directories are indexed by base name and
|
||||
// linked into a tree, so a glob is narrowed to the names its segments fix and verified up the parent
|
||||
// chain; see glob.go.
|
||||
// Paths are archive-relative with no leading slash.
|
||||
type Resolver struct {
|
||||
// Digests are of the archive file itself.
|
||||
Digests []file.Digest
|
||||
@@ -53,34 +52,11 @@ type Resolver struct {
|
||||
written int64
|
||||
chunk []byte
|
||||
|
||||
// byPath holds every file and directory by rooted path, including directories the archive never
|
||||
// listed but that entry paths imply
|
||||
byPath map[string]*node
|
||||
|
||||
// files holds every file the archive listed, in path order once finished
|
||||
files []*node
|
||||
|
||||
// names and dirs index files and directories by base name, for globs
|
||||
names syftindex.PrefixSuffix[[]*node]
|
||||
dirs syftindex.PrefixSuffix[[]*node]
|
||||
}
|
||||
|
||||
type node struct {
|
||||
path string // rooted, e.g. "/META-INF/MANIFEST.MF"
|
||||
name string // the last component of path
|
||||
isDir bool
|
||||
header *tar.Header // nil for a directory the archive never listed
|
||||
content blob
|
||||
metadata file.Metadata
|
||||
|
||||
// the tree the paths spell out, so a glob is matched up the chain and a directory's files are
|
||||
// found beneath it
|
||||
parent *node
|
||||
children []*node
|
||||
|
||||
// target is the node whose content this node reads: itself for a regular file, the resolved node
|
||||
// for a link, and nil for a link that leads nowhere inside this archive
|
||||
target *node
|
||||
tree *filetree.FileTree
|
||||
index filetree.Index
|
||||
search filetree.Searcher // built by finish, once every entry is in the tree
|
||||
content map[stereoscopeFile.ID]*blob
|
||||
entries int // non-directory entries added
|
||||
}
|
||||
|
||||
var _ file.Resolver = (*Resolver)(nil)
|
||||
@@ -94,7 +70,9 @@ func newResolver(ctx context.Context, fileSystemID, archivePath string, charge *
|
||||
charge: charge,
|
||||
budget: budgetFromContext(ctx),
|
||||
tempDir: tmpdir.FromContext(ctx),
|
||||
byPath: map[string]*node{"/": {path: "/", isDir: true}},
|
||||
tree: filetree.New(),
|
||||
index: filetree.NewIndex(),
|
||||
content: map[stereoscopeFile.ID]*blob{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,10 +86,10 @@ func (r *Resolver) Cleanup() {
|
||||
r.charge.release()
|
||||
}
|
||||
|
||||
// approxIndexBytesPerEntry estimates what holding one node costs beyond its content: the node, its
|
||||
// header, and its path map and name index slots. It is charged for every node an entry creates,
|
||||
// including the directories its path implies, so an archive of many tiny entries or of a few entries
|
||||
// with very deep paths is still bounded by the limits.
|
||||
// approxIndexBytesPerEntry estimates what holding one tree node costs beyond its content: the node, its
|
||||
// header and metadata, and its index slots. It is charged for every node an entry creates, including
|
||||
// the directories its path implies, so an archive of many tiny entries or of a few entries with very
|
||||
// deep paths is still bounded by the limits.
|
||||
const approxIndexBytesPerEntry = 2 * 1024
|
||||
|
||||
func approxIndexBytes(hdr tar.Header) int64 {
|
||||
@@ -126,42 +104,37 @@ func (r *Resolver) add(hdr tar.Header, content io.Reader) error {
|
||||
if entryPath == "/" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// directories the path implies that do not exist yet are nodes this entry pays for
|
||||
var newDirs int64
|
||||
for i := 1; i < len(entryPath); i++ {
|
||||
if entryPath[i] == '/' {
|
||||
if _, ok := r.byPath[entryPath[:i]]; !ok {
|
||||
newDirs++
|
||||
}
|
||||
}
|
||||
}
|
||||
if !r.chargeIndex(approxIndexBytes(hdr) + newDirs*approxIndexBytesPerEntry) {
|
||||
return ErrDiskLimitReached
|
||||
}
|
||||
|
||||
for i := 1; i < len(entryPath); i++ {
|
||||
if entryPath[i] == '/' {
|
||||
r.nodeAt(entryPath[:i], true)
|
||||
}
|
||||
}
|
||||
_, existed := r.byPath[entryPath]
|
||||
n := r.nodeAt(entryPath, hdr.FileInfo().IsDir())
|
||||
if n.header != nil {
|
||||
if exists, res, _ := r.tree.File(stereoscopeFile.Path(entryPath)); exists && res.HasReference() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// directories the path implies that do not exist yet are nodes this entry pays for
|
||||
var newDirs []string
|
||||
for i := 1; i < len(entryPath); i++ {
|
||||
if entryPath[i] == '/' && !r.tree.HasPath(stereoscopeFile.Path(entryPath[:i])) {
|
||||
newDirs = append(newDirs, entryPath[:i])
|
||||
}
|
||||
}
|
||||
if !r.chargeIndex(approxIndexBytes(hdr) + int64(len(newDirs))*approxIndexBytesPerEntry) {
|
||||
return ErrDiskLimitReached
|
||||
}
|
||||
// indexed as if listed, since a search for a directory's children looks the directory up by name
|
||||
for _, dir := range newDirs {
|
||||
if err := r.store(dir, tar.Header{Name: dir, Typeflag: tar.TypeDir, Mode: 0o755}, &blob{}); err != nil {
|
||||
log.WithFields("entry", hdr.Name, "error", err).Trace("skipping archive entry")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
b := &blob{}
|
||||
if hdr.Typeflag == tar.TypeReg && content != nil {
|
||||
// reading one byte past the cap is how an entry over it is told apart from one exactly at it
|
||||
err := r.put(&n.content, io.LimitReader(content, maxEntryBytes+1))
|
||||
if err == nil && n.content.size > maxEntryBytes {
|
||||
err := r.put(b, io.LimitReader(content, maxEntryBytes+1))
|
||||
if err == nil && b.size > maxEntryBytes {
|
||||
err = errEntryTooLarge
|
||||
}
|
||||
if err != nil {
|
||||
r.discard(&n.content)
|
||||
if !existed {
|
||||
delete(r.byPath, entryPath)
|
||||
}
|
||||
r.discard(b)
|
||||
if errors.Is(err, errEntryTooLarge) {
|
||||
r.truncate(fmt.Errorf("an entry was skipped: %w of %s", err, humanize.IBytes(uint64(maxEntryBytes))))
|
||||
return nil
|
||||
@@ -175,18 +148,45 @@ func (r *Resolver) add(hdr tar.Header, content io.Reader) error {
|
||||
}
|
||||
}
|
||||
|
||||
n.header = &hdr
|
||||
n.metadata = r.metadataOf(n)
|
||||
if n.isDir {
|
||||
if err := r.store(entryPath, hdr, b); err != nil {
|
||||
// a path the tree cannot hold, such as a file beneath another file's name
|
||||
r.discard(b)
|
||||
log.WithFields("entry", hdr.Name, "error", err).Trace("skipping archive entry")
|
||||
return nil
|
||||
}
|
||||
r.files = append(r.files, n)
|
||||
r.names.Update(path.Base(entryPath), func(current *syftindex.Node[[]*node]) []*node {
|
||||
return append(current.Value(), n)
|
||||
})
|
||||
if hdr.Typeflag != tar.TypeDir {
|
||||
r.entries++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Resolver) store(entryPath string, hdr tar.Header, b *blob) error {
|
||||
ref, err := r.addToTree(entryPath, hdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metadata := stereoscopeFile.NewMetadata(hdr, r.open(b))
|
||||
metadata.Path = reportedPath(entryPath)
|
||||
r.index.Add(*ref, metadata)
|
||||
r.content[ref.ID()] = b
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Resolver) addToTree(entryPath string, hdr tar.Header) (*stereoscopeFile.Reference, error) {
|
||||
p := stereoscopeFile.Path(entryPath)
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
return r.tree.AddDir(p)
|
||||
case tar.TypeSymlink:
|
||||
return r.tree.AddSymLink(p, stereoscopeFile.Path(hdr.Linkname))
|
||||
case tar.TypeLink:
|
||||
// a hard link names its target from the archive root
|
||||
return r.tree.AddHardLink(p, stereoscopeFile.Path(path.Join("/", hdr.Linkname)))
|
||||
default:
|
||||
return r.tree.AddFile(p)
|
||||
}
|
||||
}
|
||||
|
||||
// maxEntryBytes caps any one entry, as the legacy java extraction did (see intFile.SafeCopy): without it
|
||||
// a single entry of an overlapping-entry zip bomb can decompress up to the disk limit.
|
||||
var maxEntryBytes int64 = intFile.PerFileReadLimit
|
||||
@@ -212,76 +212,9 @@ func (r *Resolver) chargeIndex(n int64) bool {
|
||||
return r.charge.index(n)
|
||||
}
|
||||
|
||||
// nodeAt returns the node at a rooted path, creating it under its parent, which must exist already.
|
||||
func (r *Resolver) nodeAt(p string, isDir bool) *node {
|
||||
if n, ok := r.byPath[p]; ok {
|
||||
return n
|
||||
}
|
||||
slash := strings.LastIndexByte(p, '/')
|
||||
parentPath := p[:slash]
|
||||
if parentPath == "" {
|
||||
parentPath = "/"
|
||||
}
|
||||
parent := r.byPath[parentPath]
|
||||
n := &node{path: p, name: p[slash+1:], isDir: isDir, parent: parent}
|
||||
parent.children = append(parent.children, n)
|
||||
r.byPath[p] = n
|
||||
if isDir {
|
||||
r.dirs.Update(n.name, func(current *syftindex.Node[[]*node]) []*node {
|
||||
return append(current.Value(), n)
|
||||
})
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// finish orders the files and resolves links once every entry is added.
|
||||
// finish builds the search context once every entry is added.
|
||||
func (r *Resolver) finish() {
|
||||
sort.Slice(r.files, func(i, j int) bool { return r.files[i].path < r.files[j].path })
|
||||
for _, n := range r.byPath {
|
||||
n.target = r.linkTarget(n)
|
||||
}
|
||||
}
|
||||
|
||||
// maxLinkHops bounds a chain of links pointing at links, which is also what ends a cycle.
|
||||
const maxLinkHops = 8
|
||||
|
||||
func (r *Resolver) linkTarget(n *node) *node {
|
||||
for hops := 0; hops <= maxLinkHops; hops++ {
|
||||
if n.header == nil || n.isDir {
|
||||
return nil
|
||||
}
|
||||
if n.header.Linkname == "" {
|
||||
return n
|
||||
}
|
||||
next, ok := r.byPath[linkDestination(n.path, n.header.Linkname, n.header.Typeflag == tar.TypeLink)]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
n = next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// linkDestination is the rooted path a link points at inside the archive. A hard link names its
|
||||
// target from the archive root, as does an absolute symlink, so "/etc/passwd" is an entry of this
|
||||
// archive and never the host's. A relative symlink is joined onto the link's own directory, and
|
||||
// cleaning against the root keeps "../../x" inside the archive.
|
||||
func linkDestination(linkPath, linkname string, hard bool) string {
|
||||
if hard || path.IsAbs(linkname) {
|
||||
return path.Join("/", linkname)
|
||||
}
|
||||
return path.Join(path.Dir(linkPath), linkname)
|
||||
}
|
||||
|
||||
func (r *Resolver) metadataOf(n *node) file.Metadata {
|
||||
metadata := stereoscopeFile.NewMetadata(*n.header, r.open(&n.content))
|
||||
// NewMetadata copies the header and points FileInfo into the copy; share the one the node holds
|
||||
metadata.FileInfo = n.header.FileInfo()
|
||||
metadata.Path = reportedPath(n.path)
|
||||
if n.header.Linkname != "" {
|
||||
metadata.LinkDestination = reportedPath(linkDestination(n.path, n.header.Linkname, n.header.Typeflag == tar.TypeLink))
|
||||
}
|
||||
return metadata
|
||||
r.search = filetree.NewSearchContext(r.tree, r.index)
|
||||
}
|
||||
|
||||
// reportedPath is how paths leave the resolver: archive-relative with no leading slash.
|
||||
@@ -293,136 +226,129 @@ func rootedPath(userPath string) string {
|
||||
return path.Clean("/" + userPath)
|
||||
}
|
||||
|
||||
func (r *Resolver) coordinates(n *node) file.Coordinates {
|
||||
return file.Coordinates{
|
||||
RealPath: reportedPath(n.path),
|
||||
// location names a resolution: a link is reported at its target's path with the link's own path as
|
||||
// the access path.
|
||||
func (r *Resolver) location(res stereoscopeFile.Resolution) file.Location {
|
||||
coordinates := file.Coordinates{
|
||||
RealPath: reportedPath(string(res.RealPath)),
|
||||
FileSystemID: r.fileSystemID,
|
||||
ArchivePath: r.archivePath,
|
||||
}
|
||||
}
|
||||
|
||||
// locationOf names a node by its own path.
|
||||
func (r *Resolver) locationOf(n *node) file.Location {
|
||||
return file.NewLocationFromCoordinates(r.coordinates(n))
|
||||
}
|
||||
|
||||
// resolvedLocation names the file behind a node: a link is reported at its target's path with the
|
||||
// link's own path as the access path, and a link leading nowhere is reported as itself.
|
||||
func (r *Resolver) resolvedLocation(n *node) file.Location {
|
||||
if n.target == nil || n.target == n {
|
||||
return r.locationOf(n)
|
||||
accessPath := reportedPath(string(res.RequestPath))
|
||||
if accessPath == "" || accessPath == coordinates.RealPath {
|
||||
return file.NewLocationFromCoordinates(coordinates)
|
||||
}
|
||||
return file.NewVirtualLocationFromCoordinates(r.coordinates(n.target), reportedPath(n.path))
|
||||
return file.NewVirtualLocationFromCoordinates(coordinates, accessPath)
|
||||
}
|
||||
|
||||
// resolve finds the file a path names, following links, and reports false for directories and
|
||||
// paths the archive never listed.
|
||||
func (r *Resolver) resolve(userPath string) (*stereoscopeFile.Resolution, bool) {
|
||||
res, err := r.search.SearchByPath(rootedPath(userPath), filetree.FollowBasenameLinks)
|
||||
if err != nil || res == nil || !res.HasReference() {
|
||||
return nil, false
|
||||
}
|
||||
entry, err := r.index.Get(*res.Reference)
|
||||
if err != nil || entry.IsDir() {
|
||||
return nil, false
|
||||
}
|
||||
return res, true
|
||||
}
|
||||
|
||||
func (r *Resolver) FileContentsByLocation(location file.Location) (io.ReadCloser, error) {
|
||||
n, ok := r.byPath[rootedPath(location.RealPath)]
|
||||
if !ok || n.header == nil {
|
||||
res, ok := r.resolve(location.RealPath)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no archive entry for path=%q", location.RealPath)
|
||||
}
|
||||
return entryReader{r.open(&n.content)}, nil
|
||||
return entryReader{r.open(r.content[res.ID()])}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) FileMetadataByLocation(location file.Location) (file.Metadata, error) {
|
||||
n, ok := r.byPath[rootedPath(location.RealPath)]
|
||||
if !ok {
|
||||
exists, res, err := r.tree.File(stereoscopeFile.Path(rootedPath(location.RealPath)))
|
||||
if err != nil || !exists || !res.HasReference() {
|
||||
return file.Metadata{}, fmt.Errorf("no archive entry for path=%q", location.RealPath)
|
||||
}
|
||||
return n.metadata, nil
|
||||
entry, err := r.index.Get(*res.Reference)
|
||||
if err != nil {
|
||||
return file.Metadata{}, err
|
||||
}
|
||||
return entry.Metadata, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) HasPath(userPath string) bool {
|
||||
_, ok := r.byPath[rootedPath(userPath)]
|
||||
return ok
|
||||
return r.tree.HasPath(stereoscopeFile.Path(rootedPath(userPath)))
|
||||
}
|
||||
|
||||
func (r *Resolver) FilesByPath(paths ...string) ([]file.Location, error) {
|
||||
var out []file.Location
|
||||
seen := map[*node]struct{}{}
|
||||
seen := stereoscopeFile.NewFileReferenceSet()
|
||||
for _, userPath := range paths {
|
||||
n, ok := r.byPath[rootedPath(userPath)]
|
||||
if !ok || n.isDir || n.header == nil {
|
||||
res, ok := r.resolve(userPath)
|
||||
if !ok || seen.Contains(*res.Reference) {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[n]; dup {
|
||||
continue
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
out = append(out, r.resolvedLocation(n))
|
||||
seen.Add(*res.Reference)
|
||||
out = append(out, r.location(*res))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) FilesByGlob(patterns ...string) ([]file.Location, error) {
|
||||
matched := map[*node]struct{}{}
|
||||
for _, pattern := range patterns {
|
||||
for _, segments := range parseGlob(pattern) {
|
||||
candidates, exact := r.globCandidates(segments)
|
||||
for _, n := range candidates {
|
||||
if !exact {
|
||||
ok, err := matchNode(segments, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid glob %q: %w", pattern, err)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
matched[n] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var out []file.Location
|
||||
for _, n := range onePathPerFile(matched) {
|
||||
out = append(out, r.resolvedLocation(n))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].RealPath != out[j].RealPath {
|
||||
return out[i].RealPath < out[j].RealPath
|
||||
seen := stereoscopeFile.NewFileReferenceSet()
|
||||
for _, pattern := range patterns {
|
||||
results, err := r.searchByGlob(rootedPath(pattern))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid glob %q: %w", pattern, err)
|
||||
}
|
||||
return out[i].AccessPath < out[j].AccessPath
|
||||
})
|
||||
for _, res := range results {
|
||||
if !res.HasReference() || seen.Contains(*res.Reference) {
|
||||
continue
|
||||
}
|
||||
entry, err := r.index.Get(*res.Reference)
|
||||
if err != nil || entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
seen.Add(*res.Reference)
|
||||
out = append(out, r.location(res))
|
||||
}
|
||||
}
|
||||
sortLocations(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// onePathPerFile keeps one matched path per file. Several paths can name the same content, such as
|
||||
// a link and its target, and a resolver answers with one: the file's own path when it matched,
|
||||
// otherwise the lowest-sorting link. A link leading nowhere stands as its own file.
|
||||
func onePathPerFile(matched map[*node]struct{}) []*node {
|
||||
best := map[*node]*node{}
|
||||
for n := range matched {
|
||||
key := n.target
|
||||
if key == nil {
|
||||
key = n
|
||||
}
|
||||
current, seen := best[key]
|
||||
if !seen || n == key || (current != key && n.path < current.path) {
|
||||
best[key] = n
|
||||
}
|
||||
// searchByGlob is the index search, except that stereoscope answers "<dir>/*" by looking the directory
|
||||
// up by name and the root has none, so "/*" walks the tree instead.
|
||||
func (r *Resolver) searchByGlob(pattern string) ([]stereoscopeFile.Resolution, error) {
|
||||
if pattern == "/*" {
|
||||
return r.tree.FilesByGlob(pattern, filetree.FollowBasenameLinks)
|
||||
}
|
||||
out := make([]*node, 0, len(best))
|
||||
for _, n := range best {
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
return r.search.SearchByGlob(pattern, filetree.FollowBasenameLinks)
|
||||
}
|
||||
|
||||
// sortLocations orders answers by path, which decides how locations group into packages when results
|
||||
// merge into the SBOM.
|
||||
func sortLocations(locations []file.Location) {
|
||||
sort.Slice(locations, func(i, j int) bool {
|
||||
if locations[i].RealPath != locations[j].RealPath {
|
||||
return locations[i].RealPath < locations[j].RealPath
|
||||
}
|
||||
return locations[i].AccessPath < locations[j].AccessPath
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Resolver) FilesByMIMEType(types ...string) ([]file.Location, error) {
|
||||
wanted := map[string]struct{}{}
|
||||
for _, t := range types {
|
||||
wanted[t] = struct{}{}
|
||||
results, err := r.search.SearchByMIMEType(types...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []file.Location
|
||||
for _, n := range r.files {
|
||||
// only a file holding bytes has a type of its own; a link's target answers for itself
|
||||
if n.target != n {
|
||||
continue
|
||||
}
|
||||
if _, ok := wanted[n.metadata.MIMEType]; ok {
|
||||
out = append(out, r.locationOf(n))
|
||||
for _, res := range results {
|
||||
if res.HasReference() {
|
||||
out = append(out, r.location(res))
|
||||
}
|
||||
}
|
||||
sortLocations(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -439,9 +365,11 @@ func (r *Resolver) AllLocations(ctx context.Context) <-chan file.Location {
|
||||
out := make(chan file.Location)
|
||||
go func() {
|
||||
defer close(out)
|
||||
for _, n := range r.files {
|
||||
refs := r.tree.AllFiles(stereoscopeFile.TypeRegular, stereoscopeFile.TypeSymLink, stereoscopeFile.TypeHardLink)
|
||||
sort.Slice(refs, func(i, j int) bool { return refs[i].RealPath < refs[j].RealPath })
|
||||
for _, ref := range refs {
|
||||
select {
|
||||
case out <- r.locationOf(n):
|
||||
case out <- r.location(stereoscopeFile.Resolution{RequestPath: ref.RealPath, Reference: &ref}):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/syft/file"
|
||||
)
|
||||
|
||||
var jarEntriesFixture = map[string]string{
|
||||
@@ -130,7 +131,7 @@ func TestResolver_add_firstEntryAtAPathWins(t *testing.T) {
|
||||
r.finish()
|
||||
|
||||
assert.Equal(t, "first", readStore(t, r)["a.txt"].body)
|
||||
assert.Len(t, r.files, 1)
|
||||
assert.Equal(t, 1, r.entries)
|
||||
}
|
||||
|
||||
func TestResolver_pathsAndContents(t *testing.T) {
|
||||
@@ -252,6 +253,22 @@ func TestResolver_impliedDirectoriesArePathsButNotFiles(t *testing.T) {
|
||||
assert.Empty(t, locations)
|
||||
}
|
||||
|
||||
// stereoscope finds a directory's children by looking the directory up by name: directories the archive
|
||||
// never listed must be indexed, and the root, which has no name, answered another way
|
||||
func TestResolver_globsForADirectorysChildren(t *testing.T) {
|
||||
r := resolverOver(t, 1<<20, map[string]string{
|
||||
"LICENSE": "root",
|
||||
"META-INF/LICENSE.txt": "nested",
|
||||
"META-INF/maven/g/a/pom.xml": "deeper",
|
||||
})
|
||||
|
||||
assert.Equal(t, []string{"LICENSE"}, globPaths(t, r, "/*"))
|
||||
assert.Equal(t, []string{"LICENSE"}, globPaths(t, r, "*"))
|
||||
assert.Equal(t, []string{"META-INF/LICENSE.txt"}, globPaths(t, r, "/META-INF/*"))
|
||||
assert.Equal(t, []string{"META-INF/LICENSE.txt"}, globPaths(t, r, "META-INF/*"))
|
||||
assert.Equal(t, []string{"META-INF/maven/g/a/pom.xml"}, globPaths(t, r, "**/a/*"))
|
||||
}
|
||||
|
||||
func TestResolver_invalidGlobIsAnError(t *testing.T) {
|
||||
r := resolverOver(t, 1<<20, jarEntriesFixture)
|
||||
_, err := r.FilesByGlob("**/[")
|
||||
@@ -311,14 +328,14 @@ func TestResolver_linksCollapseToOnePath(t *testing.T) {
|
||||
reason: "collapsing is per file, not per pattern",
|
||||
},
|
||||
{
|
||||
name: "a dangling link keeps its own path",
|
||||
name: "a dangling link is not a file",
|
||||
entries: []testEntry{
|
||||
{name: "opt/real.jar", body: "PK\x03\x04"},
|
||||
{name: "opt/gone.jar", link: "missing.jar"},
|
||||
},
|
||||
pattern: "**/*.jar",
|
||||
want: []string{"opt/gone.jar", "opt/real.jar"},
|
||||
reason: "it names no content, so it belongs to no other file's group",
|
||||
want: []string{"opt/real.jar"},
|
||||
reason: "it names no content",
|
||||
},
|
||||
{
|
||||
name: "two dangling links are two paths, not one",
|
||||
@@ -327,8 +344,8 @@ func TestResolver_linksCollapseToOnePath(t *testing.T) {
|
||||
{name: "opt/b.jar", link: "missing.jar"},
|
||||
},
|
||||
pattern: "**/*.jar",
|
||||
want: []string{"opt/a.jar", "opt/b.jar"},
|
||||
reason: "nothing resolves them to a common file, so neither may absorb the other",
|
||||
want: nil,
|
||||
reason: "a link to nothing is not a file",
|
||||
},
|
||||
{
|
||||
name: "a chain of links collapses onto the file at its end",
|
||||
@@ -348,8 +365,8 @@ func TestResolver_linksCollapseToOnePath(t *testing.T) {
|
||||
{name: "opt/b.jar", link: "a.jar"},
|
||||
},
|
||||
pattern: "**/*.jar",
|
||||
want: []string{"opt/a.jar", "opt/b.jar"},
|
||||
reason: "neither reaches content, so each stands under its own path",
|
||||
want: nil,
|
||||
reason: "neither reaches a file",
|
||||
},
|
||||
{
|
||||
name: "a hard link resolves from the archive root, not from its own directory",
|
||||
@@ -371,60 +388,6 @@ func TestResolver_linksCollapseToOnePath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// the choice must not depend on the order the search found the candidates in
|
||||
func TestResolver_collapseIgnoresTheOrderItFinds(t *testing.T) {
|
||||
r := resolverFrom(t,
|
||||
testEntry{name: "opt/real.jar", body: "PK\x03\x04"},
|
||||
testEntry{name: "opt/a-sym.jar", link: "real.jar"},
|
||||
testEntry{name: "opt/m-sym.jar", link: "real.jar"},
|
||||
testEntry{name: "opt/z-sym.jar", link: "real.jar"},
|
||||
)
|
||||
|
||||
candidates := []*node{
|
||||
r.byPath["/opt/real.jar"], r.byPath["/opt/a-sym.jar"],
|
||||
r.byPath["/opt/m-sym.jar"], r.byPath["/opt/z-sym.jar"],
|
||||
}
|
||||
for _, n := range candidates {
|
||||
require.NotNil(t, n)
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
for range 64 {
|
||||
rng.Shuffle(len(candidates), func(i, j int) {
|
||||
candidates[i], candidates[j] = candidates[j], candidates[i]
|
||||
})
|
||||
|
||||
found := map[*node]struct{}{}
|
||||
for _, n := range candidates {
|
||||
found[n] = struct{}{}
|
||||
}
|
||||
|
||||
best := onePathPerFile(found)
|
||||
require.Len(t, best, 1, "four paths, one file")
|
||||
assert.Equal(t, "/opt/real.jar", best[0].path, "the file itself must win from any order")
|
||||
}
|
||||
|
||||
// and with the file itself not among the candidates, the lowest-sorting link wins from any order
|
||||
links := candidates[:0:0]
|
||||
for _, n := range candidates {
|
||||
if n.path != "/opt/real.jar" {
|
||||
links = append(links, n)
|
||||
}
|
||||
}
|
||||
for range 64 {
|
||||
rng.Shuffle(len(links), func(i, j int) { links[i], links[j] = links[j], links[i] })
|
||||
|
||||
found := map[*node]struct{}{}
|
||||
for _, n := range links {
|
||||
found[n] = struct{}{}
|
||||
}
|
||||
|
||||
best := onePathPerFile(found)
|
||||
require.Len(t, best, 1)
|
||||
assert.Equal(t, "/opt/a-sym.jar", best[0].path, "the lowest-sorting link must win from any order")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_collapsedLocationReadsTheTarget(t *testing.T) {
|
||||
r := resolverFrom(t,
|
||||
testEntry{name: "opt/real.bin", body: "PK\x03\x04 real contents"},
|
||||
@@ -452,24 +415,21 @@ func TestResolver_collapsedLocationReadsTheTarget(t *testing.T) {
|
||||
func TestResolver_danglingLinkHasNoContent(t *testing.T) {
|
||||
r := resolverFrom(t, testEntry{name: "opt/gone.jar", link: "missing.jar"})
|
||||
|
||||
locations, err := r.FilesByGlob("**/*.jar")
|
||||
require.NoError(t, err)
|
||||
var locations []file.Location
|
||||
for l := range r.AllLocations(context.Background()) {
|
||||
locations = append(locations, l)
|
||||
}
|
||||
require.Len(t, locations, 1)
|
||||
assert.Equal(t, "opt/gone.jar", locations[0].RealPath)
|
||||
|
||||
reader, err := r.FileContentsByLocation(locations[0])
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, string(body), "a link to nothing holds nothing")
|
||||
_, err := r.FileContentsByLocation(locations[0])
|
||||
assert.Error(t, err, "a link to nothing has no content to read")
|
||||
|
||||
metadata, err := r.FileMetadataByLocation(locations[0])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "opt/missing.jar", metadata.LinkDestination,
|
||||
"where it pointed is still reported, resolved against the link's own directory")
|
||||
assert.Equal(t, "missing.jar", metadata.LinkDestination, "where it pointed is still reported, as the archive named it")
|
||||
}
|
||||
|
||||
// a link has no content to sniff a type from; the file it points at answers under its own path
|
||||
func TestResolver_mimeTypeAnswersOnlyWithFiles(t *testing.T) {
|
||||
r := resolverFrom(t,
|
||||
testEntry{name: "opt/real.jar", body: "PK\x03\x04 zip contents here"},
|
||||
|
||||
@@ -230,8 +230,8 @@ func TestStorage_filesystemCostDoesNotTrackEntryCount(t *testing.T) {
|
||||
assert.Len(t, filesIn(t, smallRoot), 1)
|
||||
assert.Len(t, filesIn(t, largeRoot), 1, "5000 entries cost the same filesystem entries as 2")
|
||||
|
||||
assert.Len(t, small.files, 2)
|
||||
assert.Len(t, large.files, 5000)
|
||||
assert.Equal(t, 2, small.entries)
|
||||
assert.Equal(t, 5000, large.entries)
|
||||
|
||||
// entries cost their content and nothing else on disk: no headers, no padding
|
||||
assert.Equal(t, int64(2), small.written)
|
||||
@@ -264,10 +264,7 @@ func TestStorage_anEntryAtTheEndIsReadBySeeking(t *testing.T) {
|
||||
offset := int64(bytes.Index(body, []byte(wanted)))
|
||||
require.Greater(t, offset, int64(len(body))*9/10, "the last entry must really be at the end of the file")
|
||||
|
||||
n := r.byPath["/zz-last.txt"]
|
||||
require.NotNil(t, n)
|
||||
|
||||
reader := r.open(&n.content)
|
||||
reader := r.open(contentAt(t, r, "/zz-last.txt"))
|
||||
end, err := reader.Seek(0, io.SeekEnd)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(len(wanted)), end)
|
||||
|
||||
@@ -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