Add nested archive cataloging (#5260)

Signed-off-by: Kudryavcev Nikolay <kydry.nikolau@gmail.com>
Signed-off-by: Thomas Bechtold <thomasbechtold@jpberlin.de>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Signed-off-by: Keith Zantow <kzantow@gmail.com>
Co-authored-by: Kudryavcev Nikolay <kydry.nikolau@gmail.com>
Co-authored-by: Thomas Bechtold <thomasbechtold@jpberlin.de>
Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Keith Zantow
2026-10-29 17:33:07 -04:00
committed by GitHub
co-authored by Kudryavcev Nikolay Thomas Bechtold Alex Goodman
parent 14a0beb93c
commit 4b8a3db5f3
224 changed files with 15918 additions and 1281 deletions
+106
View File
@@ -0,0 +1,106 @@
# internal/archive
Opens one archive as its own filesystem so the ordinary catalogers can run against its contents.
The archive cataloger task (`internal/task/archive_tasks.go`) drives the recursion; this package
does everything for a single archive.
## Flow
```
Extract(ctx, reader, archiveCoordinates, limiter, exclusions) -> *Resolver
acquire read in place if the reader is already seekable, else hold in memory / spill to disk
identifyFormat internal/file.IdentifyArchive first, end-of-central-directory fallback for stub-prefixed zips
Resolver.extract every entry -> entryHeader -> Resolver.add (excluded entries are never stored)
digestsOf SHA-1 of the archive itself
```
`Extract` returns `nil` for content that is not an archive (a bare compression format such as a
gzipped file that is not a tar counts as not an archive), `ErrDiskLimitReached` when the archive's
own bytes cannot be placed, and otherwise a `Resolver` whose `Cleanup` releases everything.
## Resolver
One type does the work: `Resolver` is fed entries by `Extract`, holds their content, indexes them,
and implements `file.Resolver` over the result. Each entry is a `node` carrying its header and a
`blob`; the archive's own bytes, when they have to be held at all, are one more blob.
Every location it returns extends the archive's coordinates: `RealPath` and `FileSystemID` stay
those of the outermost archive on the scanned filesystem, and `FullPath` is the `!/`-delimited
chain down to the entry (`/app.war!/WEB-INF/lib/dep.jar!/META-INF/MANIFEST.MF`). `LocalPath` is the
entry's path within this archive.
## Where bytes live
Blobs are held in memory while the memory limit admits them and in one spill file per archive
(`archive-spill-*`) once it does not; when a chunk is refused, everything held in memory moves to
the file first. The file is created on first use, so most archives never touch the filesystem, and
blobs are located in it by offset with no framing of their own. It goes under the scan's temp root
when the context carries one (`internal/tmpdir`), otherwise the system temp directory.
Anything already random-access (an `*os.File`, or a nested archive read out of its parent's
resolver) is used in place and costs nothing, so only a top-level stream is ever copied. Its memory
is refunded once it is extracted and digested; bytes it spilled wait for `Cleanup` with the entries.
## Limits
`Limits` bound what one scan holds *at once*; each falls as archives are released. For both limits,
positive is the bound, zero forbids the resource, negative is unbounded.
- `Limiter` is scan-wide; a `charge` is one archive's draw on it. Bytes are charged as they land,
never from declared sizes.
- Memory refused → content moves to disk. Disk refused → the archive's own bytes cannot be placed
(`ErrDiskLimitReached`, archive skipped) or an entry cannot be stored (`Resolver.Truncated`, the
entries stored so far are still cataloged).
- Index bookkeeping is approximated as `approxIndexBytesPerEntry` (2 KiB) per node, listed or
implied by an entry's path, plus name length. It lives in memory, so it is charged to the memory
limit, spilling held content to make room; only a zero memory limit lets it fall back to the disk
budget so that configuration still indexes. An entry whose index cost is refused is dropped like
one whose content is, and marks the resolver `Truncated`.
- `Budget` bounds the bytes decompressed under one top-level archive, summed over everything nested
in it. Unlike `Limits` it never refills, so a recursive bomb whose every level fits the limits
still ends (`errBudgetSpent`, the resolver is `Truncated`).
- `Limiter.Peak` reports the most held at once over the scan, for reporting.
## Hostile archives
Nothing an archive declares is trusted. Content is charged chunk by chunk as it decompresses, so a
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. A recursive bomb stops
at the `Budget`. Names longer than PATH_MAX are refused, and nesting stops at the task's `MaxDepth`.
See the bomb tests in `extract_test.go`.
## Lookup
Entries are indexed in a stereoscope `filetree.FileTree` + `filetree.Index` and searched with
`filetree.SearchContext`, the same index the directory and image resolvers use. A query may name an
entry by its full path or by its `LocalPath` (leading slash optional); a full path into another
archive in the scan names nothing. Directories implied by entry paths exist for `HasPath` but are
never files. The last entry at a path wins over earlier
duplicates, as it does on extraction; a file and a directory at one path keep the first.
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` the full path matched and `FullPath` that of 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
- `Exclusions`: the scan's exclusion patterns, of which only the `**/` shape means the same thing
re-rooted at an archive. Matched against each entry path and its parent directories at extraction
time. `syft.CreateSBOM` passes the source's patterns to the task, which builds one `Exclusions`
for the scan.
- `Traversal`: put on the context by the task so a cataloger (java) knows which archive it is inside,
what its digests are, and which packages describe its parent. `VirtualPath(location)` converts a
location's `FullPath` to the java cataloger's colon-delimited `app.war:WEB-INF/lib/inner.jar`
form, escaping `%` and `:` in entry names. `Container` and `EntryCoordinates` split and build the
coordinates of files within archives (the SPDX and CycloneDX formats use them too); `OwnerContainers` and
`OwnedCoordinates` map package-owned file paths to coordinates in the right archive.
- `WithNestedCataloging` / `NestedCatalogingEnabled`: `syft.CreateSBOM` marks the context when the
archive task is running, so the java cataloger leaves nested archives to the task instead of
opening them itself.
- `entryHeader`: keeps regular files, directories and links only (no devices or fifos), cleans entry names to
archive-relative paths, refuses names longer than PATH_MAX or containing control characters, line
separators or the `!/` archive path separator (marking the resolver truncated), and drops header
fields nothing reads (PAX records, user and group names, sub-second and access times) since the
header is held for the life of the archive.
+38
View File
@@ -0,0 +1,38 @@
package archive
import (
"strings"
"github.com/bmatcuk/doublestar/v4"
)
// Exclusions are the scan's exclusion patterns that apply inside archives.
//
// A scan accepts three pattern shapes: `./x` anchored at the scan root, `*/x` exactly one level below
// it, and `**/x` at any depth. Only the last means the same thing when re-rooted at an archive, so
// only it reaches inside one.
type Exclusions []string
func NewExclusions(scanPatterns []string) Exclusions {
var out Exclusions
for _, pattern := range scanPatterns {
if strings.HasPrefix(pattern, "**/") {
// a trailing slash reads as "a directory" but makes doublestar match nothing (issue #4839)
out = append(out, strings.TrimSuffix(pattern, "/"))
}
}
return out
}
// Excludes reports whether an archive-relative path, or any directory above it, matches a pattern.
// Directories are checked because an archive need not list a directory before the files in it.
func (e Exclusions) Excludes(entryPath string) bool {
for _, pattern := range e {
for end := len(entryPath); end > 0; end = strings.LastIndexByte(entryPath[:end], '/') {
if matched, _ := doublestar.Match(pattern, entryPath[:end]); matched {
return true
}
}
}
return false
}
+56
View File
@@ -0,0 +1,56 @@
package archive
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewExclusions(t *testing.T) {
tests := []struct {
name string
given []string
want Exclusions
}{
{name: "nothing configured"},
{name: "root-anchored does not reach inside", given: []string{"./nested/marker.txt"}},
{name: "one level down is anchored too", given: []string{"*/vendor"}},
{name: "any depth reaches inside", given: []string{"**/*.rpm"}, want: Exclusions{"**/*.rpm"}},
{name: "mixed keeps only the any-depth patterns", given: []string{"./a", "**/b", "*/c", "**/d"}, want: Exclusions{"**/b", "**/d"}},
// a trailing slash makes doublestar match nothing (issue #4839)
{name: "a trailing slash is trimmed", given: []string{"**/vendor/"}, want: Exclusions{"**/vendor"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, NewExclusions(tt.given))
})
}
}
func TestExclusions_Excludes(t *testing.T) {
exclusions := NewExclusions([]string{"**/vendor", "**/*.rpm"})
tests := []struct {
path string
want bool
}{
{"vendor", true},
{"a/vendor", true},
{"vendor/x/y.go", true},
{"a/vendor/y.go", true},
{"pkg.rpm", true},
{"deep/down/pkg.rpm", true},
{"keep/vendored.txt", false},
{"vendor-ish", false},
{"keep.txt", false},
{"PKG.RPM", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
assert.Equal(t, tt.want, exclusions.Excludes(tt.path))
})
}
assert.False(t, NewExclusions(nil).Excludes("anything"))
assert.False(t, Exclusions{"["}.Excludes("keep.txt"), "a malformed pattern excludes nothing")
}
+263
View File
@@ -0,0 +1,263 @@
package archive
import (
"bytes"
"context"
"crypto"
"errors"
"fmt"
"io"
"path"
"github.com/mholt/archives"
"github.com/anchore/syft/internal"
intFile "github.com/anchore/syft/internal/file"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/file"
)
// ErrUnsupportedFormat is returned for an archive in a format that is not extracted (see supportedExtractor).
var ErrUnsupportedFormat = errors.New("archive format is not supported")
// Extract opens the archive read from r as its own filesystem, holding its bytes and entries within
// the limiter's bounds. It returns nil when the content is not an archive, ErrUnsupportedFormat when it
// is one that is not extracted, and ErrDiskLimitReached when the archive's own bytes cannot be placed
// within the disk limit. The caller must Cleanup the result.
//
// archiveCoordinates are those of the archive file itself; every location the resolver returns
// extends them with its entry path (see file.Coordinates).
func Extract(ctx context.Context, r io.Reader, archiveCoordinates file.Coordinates, limiter *Limiter, exclusions Exclusions) (*Resolver, error) {
charge := limiter.charge()
resolver := newResolver(ctx, archiveCoordinates, charge)
archivePath := archiveCoordinates.FullNestedPath()
// deferred so that a panic in a decoder still gives back the charge and removes any spill file
succeeded := false
defer func() {
if !succeeded {
resolver.Cleanup()
}
}()
content, release, err := resolver.acquire(r)
if err != nil {
return nil, err
}
// the archive's own bytes are needed only until they are extracted and digested
defer release()
format, err := identifyFormat(ctx, path.Base(archiveCoordinates.LocalPath()), content)
if format == nil || err != nil {
return nil, err
}
if err := resolver.extract(ctx, format, content, exclusions); err != nil {
return nil, fmt.Errorf("unable to extract archive %q: %w", archivePath, err)
}
resolver.Digests = digestsOf(ctx, content, archivePath)
succeeded = true
return resolver, nil
}
// acquire makes the archive's own bytes random-access, which identifying, extracting and digesting
// them needs. Bytes that already are, such as a file on disk or an entry of a parent archive, are
// read in place and cost nothing. Anything else is held within the limits like an entry, and the
// returned release gives back what memory it takes; bytes spilled to disk stay until Cleanup.
// Reaching the disk limit yields ErrDiskLimitReached.
func (r *Resolver) acquire(content io.Reader) (ReaderAtSeeker, func(), error) {
if ras, ok := content.(ReaderAtSeeker); ok {
return ras, func() {}, nil
}
var b blob
if err := r.put(&b, content); err != nil {
if errors.Is(err, ErrDiskLimitReached) {
return nil, nil, err
}
return nil, nil, fmt.Errorf("unable to read archive content: %w", err)
}
return r.open(&b), func() { r.discard(&b) }, nil
}
// identifyFormat returns the format able to extract this content, or nil when it is not an archive.
//
// Identification from the head of the stream wins: a tar holding one jar ends in that jar's central
// directory, and reading the tar as a zip would lose the rest. The end of the stream is consulted
// only when the head identifies nothing supported, which is how a zip appended to a launcher script (a
// Spring Boot executable jar) is found, and how a java resource adapter (a zip named .rar) is read.
func identifyFormat(ctx context.Context, name string, content ReaderAtSeeker) (archives.Extractor, error) {
if _, err := content.Seek(0, io.SeekStart); err != nil {
return nil, nil
}
format, _, err := intFile.IdentifyArchive(ctx, name, content)
if err == nil {
if extractor := supportedExtractor(format); extractor != nil {
return extractor, nil
}
}
if HasZipEndOfCentralDirectory(content) {
return archives.Zip{}, nil
}
if err == nil && holdsFiles(format) {
return nil, fmt.Errorf("%w: %s", ErrUnsupportedFormat, format.MediaType())
}
return nil, nil
}
// holdsFiles reports whether the format is an archive of files rather than a bare compression format,
// such as a gzipped man page, which holds one stream and is not cataloged as an archive.
func holdsFiles(format archives.Format) bool {
switch f := format.(type) {
case archives.CompressedArchive:
return f.Extraction != nil
case archives.Extractor:
return true
}
return false
}
// supportedExtractor admits only the zip and tar families, the tar family optionally compressed. Other
// formats mholt identifies, such as 7z and rar, pull in decoders that have not been exercised against
// untrusted input here, so they are not opened until they are deliberately supported. A bare
// compression format, such as a gzipped file that is not a tar, is not extractable either.
func supportedExtractor(format archives.Format) archives.Extractor {
switch f := format.(type) {
case archives.Zip:
return f
case archives.Tar:
return f
case archives.CompressedArchive:
if _, ok := f.Extraction.(archives.Tar); ok {
return f
}
}
return nil
}
// extract adds every entry of the archive. Reaching the disk limit stops the walk and marks the
// resolver truncated rather than failing.
func (r *Resolver) extract(ctx context.Context, format archives.Extractor, content ReaderAtSeeker, exclusions Exclusions) error {
if _, err := content.Seek(0, io.SeekStart); err != nil {
return err
}
var source io.Reader = content
if ca, ok := format.(archives.CompressedArchive); ok && ca.Compression != nil {
// a compressed tar decompresses everything to reach each header, including entries skipped whole
// and whatever an entry leaves unread, so the budget is charged for every byte the decoder
// produces rather than for what is kept. A zip or a plain tar seeks past what it skips
rc, err := ca.OpenReader(content)
if err != nil {
return err
}
defer internal.CloseAndLogError(rc, "decompressor")
source, format, r.metered = &meteredReader{ctx: ctx, reader: rc, budget: r.budget}, ca.Extraction, true
}
err := format.Extract(ctx, source, func(_ context.Context, f archives.FileInfo) error {
hdr, ok := r.entryHeader(f)
if !ok || exclusions.Excludes(hdr.Name) {
return nil
}
if !f.Mode().IsRegular() {
return r.add(hdr, nil)
}
entry, err := f.Open()
if err != nil {
log.WithFields("entry", hdr.Name, "error", err).Trace("unable to open archive entry, skipping it")
return nil
}
defer func() {
if err := entry.Close(); err != nil {
log.WithFields("entry", hdr.Name, "error", err).Trace("unable to close archive entry")
}
}()
return r.add(hdr, entry)
})
r.finish()
if errors.Is(err, ErrDiskLimitReached) {
r.truncate(fmt.Errorf("extraction stopped: %w", err))
return nil
}
if errors.Is(err, errBudgetSpent) {
r.truncate(fmt.Errorf("extraction stopped, possible decompression bomb: %w", err))
return nil
}
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
}
return err
}
// archiveDigestHashes are the digests taken of an archive as a whole: SHA-1, matching what the java
// cataloger reports for a jar.
var archiveDigestHashes = []crypto.Hash{crypto.SHA1}
// digestsOf digests the archive's own bytes while they are in hand. A failure is logged and returns
// nothing rather than discarding an archive that extracted fine.
func digestsOf(ctx context.Context, content ReaderAtSeeker, archivePath string) []file.Digest {
if _, err := content.Seek(0, io.SeekStart); err != nil {
log.WithFields("archive", archivePath, "error", err).Trace("unable to rewind archive content for digests")
return nil
}
digests, err := intFile.NewDigestsFromFile(ctx, io.NopCloser(content), archiveDigestHashes)
if err != nil {
log.WithFields("archive", archivePath, "error", err).Trace("unable to digest archive")
return nil
}
return digests
}
var (
// zipLocalFileHeaderSignature starts every entry in a zip.
zipLocalFileHeaderSignature = []byte{'P', 'K', 0x03, 0x04}
// zipEOCDSignature starts the end-of-central-directory record that closes every zip. Readers
// locate the central directory from it, which is why a zip with data prepended still opens.
zipEOCDSignature = []byte{'P', 'K', 0x05, 0x06}
)
const (
// maxAppendedArchiveStubBytes is how far into a file an appended zip's first entry is looked for. A
// Spring Boot launcher stub is about 9 KB.
maxAppendedArchiveStubBytes = 64 * 1024
// maxZipEOCDSearch is how far from the end the end-of-central-directory record can start: the 22
// byte record plus a comment of up to 65535 bytes.
maxZipEOCDSearch = 22 + 65535
)
// MayHideAnAppendedArchive reports whether a zip entry signature appears in the head of the stream,
// which a zip appended to a launcher stub does and a file that merely has an archive's extension
// almost never does. The returned reader replays what was consumed.
func MayHideAnAppendedArchive(r io.Reader) (io.Reader, bool) {
head := make([]byte, maxAppendedArchiveStubBytes)
n, err := io.ReadFull(r, head)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
return r, false
}
head = head[:n]
found := bytes.Contains(head, zipLocalFileHeaderSignature)
if seeker, ok := r.(io.Seeker); ok {
if _, err := seeker.Seek(0, io.SeekStart); err == nil {
return r, found
}
}
return io.MultiReader(bytes.NewReader(head), r), found
}
// HasZipEndOfCentralDirectory reports whether the content ends in a zip end-of-central-directory
// record, the one part of a zip whose position is known without parsing it.
func HasZipEndOfCentralDirectory(r ReaderAtSeeker) bool {
size, err := r.Seek(0, io.SeekEnd)
if err != nil || size < int64(len(zipEOCDSignature)) {
return false
}
window := min(size, int64(maxZipEOCDSearch))
buf := make([]byte, window)
if _, err := r.ReadAt(buf, size-window); err != nil && !errors.Is(err, io.EOF) {
return false
}
return bytes.Contains(buf, zipEOCDSignature)
}
+706
View File
@@ -0,0 +1,706 @@
package archive
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"io/fs"
"math/rand"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mholt/archives"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/syft/syft/file"
)
var sampleFiles = map[string]string{
"file1.txt": "content1",
"dir/file2.txt": "content2",
}
func Test_identifyFormat(t *testing.T) {
ctx := context.Background()
launcher := append([]byte("#!/bin/bash\nexec java -jar \"$0\" \"$@\"\nexit 0\n"), zipBytes(t, sampleFiles)...)
tests := []struct {
name string
data []byte
want archives.Extractor
unsupported bool
}{
{"test.zip", zipBytes(t, sampleFiles), archives.Zip{}, false},
{"test.tar.gz", tarGzBytes(t, sampleFiles), archives.CompressedArchive{}, false},
{"plain.txt", []byte("just text"), nil, false},
// identified by mholt, but not opened until their decoders are deliberately supported
{"test.7z", []byte("7z\xbc\xaf\x27\x1c\x00\x04"), nil, true},
{"test.rar", []byte("Rar!\x1a\x07\x01\x00"), nil, true},
// a bare compression format holds one stream, not files
{"page.1.gz", gzBytes(t, "a man page"), nil, false},
// a java resource adapter archive is a zip that shares rar's extension
{"adapter.rar", zipBytes(t, sampleFiles), archives.Zip{}, false},
// the name can be claimed by identification, so the probe alone must not admit prose
{"app.jar", []byte("plain text, no central directory anywhere in it"), nil, false},
{"bundle", []byte("plain text, no central directory anywhere in it"), nil, false},
// content sniffing types this as a shell script; only its tail says zip
{"app.jar", launcher, archives.Zip{}, false},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%s/%d bytes", tt.name, len(tt.data)), func(t *testing.T) {
got, err := identifyFormat(ctx, tt.name, bytes.NewReader(tt.data))
if tt.unsupported {
assert.ErrorIs(t, err, ErrUnsupportedFormat)
} else {
assert.NoError(t, err)
}
if tt.want == nil {
assert.Nil(t, got)
return
}
assert.IsType(t, tt.want, got)
})
}
}
func TestResolver_extract_storesEveryEntryInMemoryWhenUnbounded(t *testing.T) {
for name, data := range map[string][]byte{
"test.zip": zipBytes(t, sampleFiles),
"test.tar.gz": tarGzBytes(t, sampleFiles),
} {
t.Run(name, func(t *testing.T) {
r, root := extractBytes(t, data, name, nil)
assert.NoError(t, r.Truncated)
assert.Zero(t, r.written)
assert.Empty(t, filesIn(t, root))
entries := readStore(t, r)
assert.Equal(t, "content1", entries["file1.txt"].body)
assert.Equal(t, "content2", entries["dir/file2.txt"].body)
})
}
}
func TestResolver_extract_diskLimitTruncatesAtTheEntryThatDoesNotFit(t *testing.T) {
// each entry costs its index estimate plus its content on disk
record := approxIndexBytes(tar.Header{Name: "file1.txt"})
data := zipBytes(t, map[string]string{"file1.txt": "a", "file2.txt": "b", "file3.txt": "c"})
r, _ := extractBytes(t, data, "test.zip", diskCharge(2*(record+1)+record))
assert.Error(t, r.Truncated)
assert.Equal(t, []string{"file1.txt", "file2.txt"}, storedNames(t, r), "the refused entry is not stored")
entries := readStore(t, r)
assert.Equal(t, "a", entries["file1.txt"].body)
assert.Equal(t, "b", entries["file2.txt"].body)
}
func TestResolver_extract_diskLimitBitesMidEntry(t *testing.T) {
// a tar is walked entry by entry, so the limit can bite part way through an entry
data := tarGzBytes(t, map[string]string{
"a/first.txt": strings.Repeat("a", 40*1024),
"b/second.txt": strings.Repeat("b", 40*1024),
})
const budget = 80 * 1024
charge := diskCharge(budget)
r, root := extractBytes(t, data, "test.tar.gz", charge)
assert.Error(t, r.Truncated)
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))
assert.Greater(t, disk, r.written, "the index estimate is charged to disk too")
info, err := os.Stat(filepath.Join(root, spillFile(t, root)))
require.NoError(t, err)
assert.Equal(t, r.written, info.Size())
}
func TestResolver_extract_directoryOnlyArchivesAreBoundedByTheIndexEstimate(t *testing.T) {
var entries []testEntry
for i := range 50 {
entries = append(entries, testEntry{name: fmt.Sprintf("d%04d/", i), dir: true})
}
room := approxIndexBytes(tar.Header{Name: "d0000"}) * 10
for name, data := range map[string][]byte{
"dirs.zip": zipFromEntries(t, entries),
"dirs.tar.gz": tarGzFromEntries(t, entries),
} {
t.Run(name, func(t *testing.T) {
r, _ := extractBytes(t, data, name, diskCharge(room))
assert.Error(t, r.Truncated)
assert.Len(t, storedNames(t, r), 10)
})
}
}
func TestResolver_extract_entryNamesAreCleanedAndNothingIsWrittenUnderThem(t *testing.T) {
entries := []testEntry{
{name: "../../etc/passwd", body: "evil"},
{name: "/etc/hosts", body: "pwned"},
{name: "META-INF/", dir: true, mode: 0o555},
{name: "META-INF/MANIFEST.MF", body: "Manifest-Version: 1.0"},
{name: "passwd", link: "../../../../etc/passwd"},
{name: "shadow", link: "/etc/shadow"},
{name: "bin/link.txt", link: "../lib/real.txt"},
}
for name, data := range map[string][]byte{
"evil.zip": zipFromEntries(t, entries),
"evil.tar.gz": tarGzFromEntries(t, entries),
} {
t.Run(name, func(t *testing.T) {
r, root := extractBytes(t, data, name, nil)
require.NoError(t, r.Truncated)
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"))
got := readStore(t, r)
assert.Equal(t, "evil", got["etc/passwd"].body)
assert.Equal(t, "pwned", got["etc/hosts"].body)
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, stereoscopeFile.TypeSymLink, got[link].kind)
assert.Empty(t, got[link].body)
}
assert.Equal(t, "../../../../etc/passwd", got["passwd"].linkTarget)
assert.Equal(t, "/etc/shadow", got["shadow"].linkTarget)
assert.Equal(t, "../lib/real.txt", got["bin/link.txt"].linkTarget)
})
}
}
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.Zero(t, r.entries)
assert.NoError(t, r.Truncated)
})
t.Run("a zero-byte entry costs no disk", func(t *testing.T) {
r, _ := extractBytes(t, zipBytes(t, map[string]string{"empty.txt": ""}), "test.zip", memCharge(0))
assert.Equal(t, []string{"empty.txt"}, storedNames(t, r))
assert.Zero(t, r.written)
assert.Empty(t, readStore(t, r)["empty.txt"].body)
})
t.Run("unicode entry names survive", func(t *testing.T) {
names := map[string]string{
"café/naïve.txt": "accents",
"日本語/ファイル.txt": "japanese",
"emoji-🎉.txt": "emoji",
}
r, _ := extractBytes(t, zipBytes(t, names), "test.zip", nil)
entries := readStore(t, r)
for name, want := range names {
assert.Equal(t, want, entries[name].body, name)
}
})
t.Run("an entry name with too many path components is skipped and reported", func(t *testing.T) {
atCap := strings.Repeat("a/", maxEntryNameDepth-1) + "f"
data := zipBytes(t, map[string]string{
atCap: "at the cap",
strings.Repeat("b/", maxEntryNameDepth) + "f": "over the cap",
})
r, _ := extractBytes(t, data, "test.zip", nil)
names := storedNames(t, r)
assert.Contains(t, names, atCap)
assert.Len(t, names, maxEntryNameDepth, "only the entry at the cap and its implied directories")
assert.ErrorContains(t, r.Truncated, "path components")
})
t.Run("an entry whose chain is longer than a path is skipped and reported", func(t *testing.T) {
// the chain is the archive's own path, the separator, then the name
fits := strings.Repeat("a", MaxPathBytes-len("test-archive!/"))
data := zipBytes(t, map[string]string{
fits: "at the cap",
fits + "b": "over the cap",
})
r, _ := extractBytes(t, data, "test.zip", nil)
assert.Equal(t, []string{fits}, storedNames(t, r))
assert.ErrorContains(t, r.Truncated, "archive chain")
})
t.Run("an over-long entry name is skipped", func(t *testing.T) {
data := zipBytes(t, map[string]string{
strings.Repeat("a/", maxEntryNameBytes) + "f": "deep",
"ok.txt": "fine",
})
r, _ := extractBytes(t, data, "test.zip", nil)
assert.Equal(t, []string{"ok.txt"}, storedNames(t, r))
})
}
func TestResolver_extract_unsafeEntryNamesAreRefusedAndReported(t *testing.T) {
jar := string(zipBytes(t, map[string]string{"META-INF/MANIFEST.MF": "real"}))
tests := []struct {
name string
forged string
}{
{name: "archive path separator", forged: "a.jar!/META-INF/MANIFEST.MF"},
{name: "line break", forged: "x\nlayerID: sha256:forged"},
{name: "unicode line separator", forged: "x\u2028y"},
{name: "C1 control", forged: "x\u0085y"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := zipBytes(t, map[string]string{"a.jar": jar, tt.forged: "forged"})
r, _ := extractBytes(t, data, "outer.zip", nil)
assert.Equal(t, []string{"a.jar"}, storedNames(t, r))
require.Error(t, r.Truncated)
assert.Contains(t, r.Truncated.Error(), fmt.Sprintf("%q", tt.forged))
})
}
}
func TestResolver_extract_excludedEntriesAreNeverStored(t *testing.T) {
data := zipBytes(t, map[string]string{
"keep.txt": "kept",
"vendor/lib.go": "excluded by directory",
"deep/down/pkg.rpm": "excluded by name",
})
limiter := NewLimiter(Limits{MaxMemoryBytes: 1 << 20, MaxDiskBytes: -1})
r, _ := resolverIn(t, limiter.charge())
content := bytes.NewReader(data)
format, err := identifyFormat(context.Background(), "test.zip", content)
require.NoError(t, err)
err = r.extract(context.Background(), format, content,
NewExclusions([]string{"**/vendor", "**/*.rpm"}))
require.NoError(t, err)
require.NoError(t, r.Truncated)
assert.Equal(t, []string{"keep.txt"}, storedNames(t, r))
mem, _ := limiter.InUse()
assert.Equal(t, approxIndexBytes(tar.Header{Name: "keep.txt"})+4, mem, "an excluded entry costs nothing")
}
// a compressed tar reader decompresses a skipped entry to reach the next header, so skipping must still
// spend the budget or an excluded or oversized entry would decompress without bound
func TestResolver_extract_skippedCompressedTarBytesSpendTheBudget(t *testing.T) {
big := strings.Repeat("\x00", 4<<20)
tests := []struct {
name string
exclusions []string
entryCap int64
}{
{name: "excluded entry", exclusions: []string{"**/*.bin"}, entryCap: maxEntryBytes},
{name: "entry over the size cap", entryCap: 1 << 10},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer func(prev int64) { maxEntryBytes = prev }(maxEntryBytes)
maxEntryBytes = tt.entryCap
data := tarGzFromEntries(t, []testEntry{{name: "bomb.bin", body: big}, {name: "after.txt", body: "after"}})
r, _ := resolverIn(t, memCharge(1<<30))
r.budget = NewBudget(1 << 20)
content := bytes.NewReader(data)
format, err := identifyFormat(context.Background(), "test.tar.gz", content)
require.NoError(t, err)
require.NoError(t, r.extract(context.Background(), format, content, NewExclusions(tt.exclusions)))
require.Error(t, r.Truncated)
assert.Empty(t, storedNames(t, r), "the walk stopped at the entry that spent the budget")
assert.Zero(t, r.budget.remaining)
})
}
}
// a body behind a header that doesn't read as a regular file (here a socket per its mode bits) is never
// opened, but the tar reader still decompresses it to reach the next header
func TestResolver_extract_bodiesOfNonRegularTarEntriesSpendTheBudget(t *testing.T) {
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
body := strings.Repeat("\x00", 4<<20)
require.NoError(t, tw.WriteHeader(&tar.Header{Name: "sock", Typeflag: tar.TypeReg, Mode: 0o140644, Size: int64(len(body))}))
_, err := tw.Write([]byte(body))
require.NoError(t, err)
require.NoError(t, tw.WriteHeader(&tar.Header{Name: "after.txt", Typeflag: tar.TypeReg, Mode: 0o644, Size: 5}))
_, err = tw.Write([]byte("after"))
require.NoError(t, err)
require.NoError(t, tw.Close())
require.NoError(t, gw.Close())
r, _ := resolverIn(t, memCharge(1<<30))
r.budget = NewBudget(1 << 20)
content := bytes.NewReader(buf.Bytes())
format, err := identifyFormat(context.Background(), "test.tar.gz", content)
require.NoError(t, err)
require.NoError(t, r.extract(context.Background(), format, content, nil))
assert.ErrorIs(t, r.Truncated, errBudgetSpent)
assert.Empty(t, storedNames(t, r))
assert.Zero(t, r.budget.remaining)
}
// a zip or a plain tar seeks past what it skips, so an excluded entry there costs nothing
func TestResolver_extract_skippedSeekableBytesAreFree(t *testing.T) {
entries := []testEntry{{name: "bomb.bin", body: strings.Repeat("\x00", 4<<20)}, {name: "after.txt", body: "after"}}
var plainTar bytes.Buffer
tw := tar.NewWriter(&plainTar)
for _, e := range entries {
hdr := e.header()
require.NoError(t, tw.WriteHeader(&hdr))
_, err := tw.Write([]byte(e.body))
require.NoError(t, err)
}
require.NoError(t, tw.Close())
for name, data := range map[string][]byte{"test.zip": zipFromEntries(t, entries), "test.tar": plainTar.Bytes()} {
t.Run(name, func(t *testing.T) {
r, _ := resolverIn(t, memCharge(1<<30))
r.budget = NewBudget(1 << 20)
content := bytes.NewReader(data)
format, err := identifyFormat(context.Background(), name, content)
require.NoError(t, err)
require.NoError(t, r.extract(context.Background(), format, content, NewExclusions([]string{"**/*.bin"})))
require.NoError(t, r.Truncated)
assert.Equal(t, []string{"after.txt"}, storedNames(t, r))
})
}
}
func TestExtract_readsEntriesAndDigestsTheArchive(t *testing.T) {
data := zipBytes(t, map[string]string{"dir/hello.txt": "hello world"})
extracted, err := Extract(context.Background(), bytes.NewReader(data), file.Coordinates{RealPath: "app.war", FileSystemID: "parentFS", FullPath: "app.war!/some/path/app.zip"}, nil, nil)
require.NoError(t, err)
require.NotNil(t, extracted)
t.Cleanup(extracted.Cleanup)
assert.NoError(t, extracted.Truncated)
require.Len(t, extracted.Digests, 1)
assert.Equal(t, "sha1", extracted.Digests[0].Algorithm)
locations, err := extracted.FilesByGlob("**/*.txt")
require.NoError(t, err)
require.Len(t, locations, 1)
assert.Equal(t, file.Coordinates{RealPath: "app.war", FileSystemID: "parentFS", FullPath: "app.war!/some/path/app.zip!/dir/hello.txt"},
locations[0].Coordinates)
reader, err := extracted.FileContentsByLocation(locations[0])
require.NoError(t, err)
body, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "hello world", string(body))
}
func TestExtract_heldContentIsReleasedOnceExtracted(t *testing.T) {
// the archive's own bytes are only needed to extract and digest it; afterwards the limiter holds
// the entries and the index estimate alone
data := zipBytes(t, map[string]string{"hello.txt": "hi"})
limiter := NewLimiter(Limits{MaxMemoryBytes: -1, MaxDiskBytes: -1})
extracted, err := Extract(context.Background(), unseekable{bytes.NewReader(data)}, file.NewCoordinates("app.zip", ""), limiter, nil)
require.NoError(t, err)
t.Cleanup(extracted.Cleanup)
mem, disk := limiter.InUse()
assert.Equal(t, approxIndexBytes(tar.Header{Name: "hello.txt"})+2, mem)
assert.Zero(t, disk)
}
func TestExtract_heldContentWrittenToDiskStaysUntilCleanup(t *testing.T) {
// only a top-level stream is ever held; a nested archive is read out of its parent, which is held
// for the whole descent anyway, so spilled bytes wait for Cleanup with the entries
ctx, root := scanContext(t)
data := zipBytes(t, map[string]string{"hello.txt": "hi"})
limiter := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: -1})
extracted, err := Extract(ctx, unseekable{bytes.NewReader(data)}, file.NewCoordinates("app.zip", ""), limiter, nil)
require.NoError(t, err)
t.Cleanup(extracted.Cleanup)
require.Len(t, filesIn(t, root), 1, "the archive's bytes and its entries share one file")
_, disk := limiter.InUse()
assert.Equal(t, approxIndexBytes(tar.Header{Name: "hello.txt"})+2+int64(len(data)), disk)
extracted.Cleanup()
assert.Empty(t, filesIn(t, root))
_, disk = limiter.InUse()
assert.Zero(t, disk)
}
func TestExtract_aTruncatedStreamKeepsWhatWasRead(t *testing.T) {
// a .tar.gz cut off partway breaks the stream for every entry after the cut; the ones before it are
// still good and must not be thrown away
noise := make([]byte, 64*1024)
rand.New(rand.NewSource(1)).Read(noise)
data := tarGzBytes(t, map[string]string{"a.txt": "first", "b.bin": string(noise)})
cut := data[:len(data)/2]
extracted, err := Extract(context.Background(), bytes.NewReader(cut), file.NewCoordinates("app.tar.gz", ""), NewLimiter(unboundedLimits), nil)
require.NoError(t, err)
require.NotNil(t, extracted)
t.Cleanup(extracted.Cleanup)
assert.Error(t, extracted.Truncated)
locations, err := extracted.FilesByGlob("**")
require.NoError(t, err)
assert.Equal(t, []string{"/a.txt"}, localPaths(locations))
}
func TestExtract_aPanicStillReleasesEverything(t *testing.T) {
// a decoder panicking on hostile bytes must not leak the charge or the spill file
ctx, root := scanContext(t)
limiter := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: -1})
require.Panics(t, func() {
_, _ = Extract(ctx, &panicsAfter{n: 4096}, file.NewCoordinates("app.zip", ""), limiter, nil)
})
mem, disk := limiter.InUse()
assert.Zero(t, mem)
assert.Zero(t, disk)
assert.Empty(t, filesIn(t, root))
}
// panicsAfter hands out n zero bytes, then panics.
type panicsAfter struct{ n int }
func (p *panicsAfter) Read(b []byte) (int, error) {
if p.n <= 0 {
panic("decoder blew up")
}
n := min(len(b), p.n)
clear(b[:n])
p.n -= n
return n, nil
}
func TestExtract_archiveBytesThatCannotBePlacedAreSkipped(t *testing.T) {
data := zipBytes(t, sampleFiles)
limiter := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: 0})
_, err := Extract(context.Background(), unseekable{bytes.NewReader(data)}, file.NewCoordinates("app.zip", ""), limiter, nil)
assert.ErrorIs(t, err, ErrDiskLimitReached)
// read in place, the same bytes cost nothing; only the entries are refused
extracted, err := Extract(context.Background(), bytes.NewReader(data), file.NewCoordinates("app.zip", ""), limiter, nil)
require.NoError(t, err)
require.NotNil(t, extracted)
t.Cleanup(extracted.Cleanup)
assert.Error(t, extracted.Truncated)
locations, err := extracted.FilesByGlob("**/*")
require.NoError(t, err)
assert.Empty(t, locations)
}
func Test_HasZipEndOfCentralDirectory(t *testing.T) {
tests := []struct {
name string
data []byte
want bool
}{
{"a plain zip", zipBytes(t, sampleFiles), true},
{"a zip behind a launcher script", append([]byte("#!/bin/bash\nexit 0\n"), zipBytes(t, sampleFiles)...), true},
{"prose", []byte("this is not an archive, it is a sentence"), false},
{"a tar.gz", tarGzBytes(t, sampleFiles), false},
{"empty", nil, false},
{"shorter than the signature", []byte("PK"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, HasZipEndOfCentralDirectory(bytes.NewReader(tt.data)))
})
}
}
func Test_MayHideAnAppendedArchive(t *testing.T) {
launcher := append([]byte("#!/bin/bash\nexit 0\n"), zipBytes(t, sampleFiles)...)
prose := []byte("plain text, whatever the extension claims")
t.Run("a seekable reader is rewound and returned as is", func(t *testing.T) {
reader := bytes.NewReader(launcher)
got, found := MayHideAnAppendedArchive(reader)
assert.True(t, found)
assert.Same(t, reader, got)
rest, err := io.ReadAll(got)
require.NoError(t, err)
assert.Equal(t, launcher, rest)
})
t.Run("an unseekable reader replays what was consumed", func(t *testing.T) {
got, found := MayHideAnAppendedArchive(unseekable{bytes.NewReader(launcher)})
assert.True(t, found)
rest, err := io.ReadAll(got)
require.NoError(t, err)
assert.Equal(t, launcher, rest)
})
t.Run("prose hides nothing", func(t *testing.T) {
got, found := MayHideAnAppendedArchive(unseekable{bytes.NewReader(prose)})
assert.False(t, found)
rest, err := io.ReadAll(got)
require.NoError(t, err)
assert.Equal(t, prose, rest)
})
}
// zeros is a reader of n zero bytes: the most compressible content there is.
func zeros(n int64) io.Reader {
return io.LimitReader(zeroSource{}, n)
}
type zeroSource struct{}
func (zeroSource) Read(p []byte) (int, error) {
clear(p)
return len(p), nil
}
func TestExtract_decompressionBombIsBoundedByTheLimits(t *testing.T) {
// 64 MiB of zeros compresses to a few hundred KiB. The declared size is never trusted: bytes are
// charged as they land, so the limits hold whatever the archive claims
const inflated = 64 << 20
limits := Limits{MaxMemoryBytes: 1 << 20, MaxDiskBytes: 4 << 20}
var zipBomb bytes.Buffer
zw := zip.NewWriter(&zipBomb)
w, err := zw.Create("bomb.bin")
require.NoError(t, err)
_, err = io.Copy(w, zeros(inflated))
require.NoError(t, err)
require.NoError(t, zw.Close())
var tarBomb bytes.Buffer
gw := gzip.NewWriter(&tarBomb)
tw := tar.NewWriter(gw)
require.NoError(t, tw.WriteHeader(&tar.Header{Name: "bomb.bin", Mode: 0o644, Size: inflated, Typeflag: tar.TypeReg}))
_, err = io.Copy(tw, zeros(inflated))
require.NoError(t, err)
require.NoError(t, tw.Close())
require.NoError(t, gw.Close())
for name, data := range map[string][]byte{"bomb.zip": zipBomb.Bytes(), "bomb.tar.gz": tarBomb.Bytes()} {
t.Run(name, func(t *testing.T) {
require.Less(t, len(data), 1<<20, "the fixture must be a bomb: small on disk, huge inflated")
ctx, root := scanContext(t)
limiter := NewLimiter(limits)
r, err := Extract(ctx, bytes.NewReader(data), file.NewCoordinates(name, ""), limiter, nil)
require.NoError(t, err)
require.NotNil(t, r)
t.Cleanup(r.Cleanup)
assert.Error(t, r.Truncated)
locations, err := r.FilesByGlob("**/*")
require.NoError(t, err)
assert.Empty(t, locations, "the entry that did not fit is not cataloged in part")
peakMemory, peakDisk := limiter.Peak()
assert.LessOrEqual(t, peakMemory, limits.MaxMemoryBytes)
assert.LessOrEqual(t, peakDisk, limits.MaxDiskBytes)
r.Cleanup()
assert.Empty(t, filesIn(t, root))
})
}
}
func TestExtract_entryCountBombIsBoundedByTheMemoryLimit(t *testing.T) {
// every entry is a node in memory whatever the disk limit says, so a tiny archive of many entries
// is bounded by the memory limit alone
limits := Limits{MaxMemoryBytes: 1 << 20, MaxDiskBytes: -1}
limiter := NewLimiter(limits)
r, err := Extract(context.Background(), bytes.NewReader(manyEntryZip(t, 20_000)), file.NewCoordinates("many.zip", ""), limiter, nil)
require.NoError(t, err)
t.Cleanup(r.Cleanup)
assert.Error(t, r.Truncated)
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)
}
func TestExtract_deepPathBombIsBoundedByTheMemoryLimit(t *testing.T) {
// 100 entries whose names each imply 250 distinct directories would be 25,000 nodes; the implied
// directories are charged as nodes, so the memory limit bounds them too
files := map[string]string{}
for i := range 100 {
var b strings.Builder
for j := range maxEntryNameDepth - 6 {
fmt.Fprintf(&b, "%03d-%03d/", i, j)
}
b.WriteString("f")
require.LessOrEqual(t, b.Len(), maxEntryNameBytes)
files[b.String()] = "x"
}
limits := Limits{MaxMemoryBytes: 8 << 20, MaxDiskBytes: -1}
limiter := NewLimiter(limits)
r, err := Extract(context.Background(), bytes.NewReader(zipBytes(t, files)), file.NewCoordinates("deep.zip", ""), limiter, nil)
require.NoError(t, err)
t.Cleanup(r.Cleanup)
assert.Error(t, r.Truncated)
assert.LessOrEqual(t, int64(nodeCount(r))*approxIndexBytesPerEntry, limits.MaxMemoryBytes+approxIndexBytesPerEntry)
peakMemory, _ := limiter.Peak()
assert.LessOrEqual(t, peakMemory, limits.MaxMemoryBytes)
}
func TestExtract_locationsKeepTheTopLevelRealPath(t *testing.T) {
data := zipBytes(t, map[string]string{"dir/hello.txt": "hello world"})
// a top-level archive's coordinates, with FullPath unset or set to RealPath, name entries alike
for _, archive := range []file.Coordinates{
{RealPath: "/app.zip"},
{RealPath: "/app.zip", FullPath: "/app.zip"},
} {
extracted, err := Extract(context.Background(), bytes.NewReader(data), archive, nil, nil)
require.NoError(t, err)
require.NotNil(t, extracted)
t.Cleanup(extracted.Cleanup)
locations, err := extracted.FilesByPath("dir/hello.txt")
require.NoError(t, err)
require.Len(t, locations, 1)
assert.Equal(t, file.Coordinates{RealPath: "/app.zip", FullPath: "/app.zip!/dir/hello.txt"}, locations[0].Coordinates)
assert.Equal(t, "/dir/hello.txt", locations[0].LocalPath())
for _, loc := range []file.Location{
locations[0],
file.NewLocation("dir/hello.txt"),
file.NewLocation("/dir/hello.txt"),
file.NewLocationFromCoordinates(file.Coordinates{RealPath: "/app.zip", FullPath: "/app.zip!/dir/hello.txt"}),
} {
reader, err := extracted.FileContentsByLocation(loc)
require.NoError(t, err, "location %s", loc)
body, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "hello world", string(body))
metadata, err := extracted.FileMetadataByLocation(loc)
require.NoError(t, err, "location %s", loc)
assert.Equal(t, "/app.zip!/dir/hello.txt", metadata.Path)
}
_, err = extracted.FileContentsByLocation(file.NewLocationFromCoordinates(file.Coordinates{RealPath: "/other.zip", FullPath: "/other.zip!/dir/hello.txt"}))
assert.Error(t, err, "an entry of another archive is not read from this one")
}
}
+344
View File
@@ -0,0 +1,344 @@
package archive
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"io/fs"
"os"
"sort"
"testing"
"github.com/stretchr/testify/require"
stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/syft/internal/tmpdir"
"github.com/anchore/syft/syft/file"
)
// held reports what this charge is currently holding, in memory and on disk.
func (c *charge) held() (memory, disk int64) {
if c == nil {
return 0, 0
}
c.limiter.mu.Lock()
defer c.limiter.mu.Unlock()
return c.inMemory, c.onDisk
}
// heldInMemory reports the content the resolver is still holding in memory.
func (r *Resolver) heldInMemory() int64 {
var total int64
for _, b := range r.held {
total += int64(len(b.mem))
}
return total
}
// unseekable hides a reader's Seek and ReadAt, forcing content through the held path.
type unseekable struct{ io.Reader }
// testEntry is one entry of a fixture archive: a directory when dir is set, a link when link is set
// (hard when hard is set, otherwise symbolic), otherwise a regular file.
type testEntry struct {
name string
body string
link string
hard bool
dir bool
mode fs.FileMode
}
// header is the tar header this entry would carry.
func (e testEntry) header() tar.Header {
switch {
case e.dir:
return tar.Header{Name: e.name, Mode: int64(e.modeOr(0o755)), Typeflag: tar.TypeDir}
case e.link != "" && e.hard:
return tar.Header{Name: e.name, Mode: 0o644, Typeflag: tar.TypeLink, Linkname: e.link}
case e.link != "":
return tar.Header{Name: e.name, Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: e.link}
}
return tar.Header{Name: e.name, Mode: int64(e.modeOr(0o644)), Size: int64(len(e.body)), Typeflag: tar.TypeReg}
}
func fileEntries(files map[string]string) []testEntry {
names := make([]string, 0, len(files))
for name := range files {
names = append(names, name)
}
// limits bite as the walk proceeds, so entry order decides what lands before a truncation
sort.Strings(names)
entries := make([]testEntry, 0, len(names))
for _, name := range names {
entries = append(entries, testEntry{name: name, body: files[name]})
}
return entries
}
func zipBytes(t testing.TB, files map[string]string) []byte {
return zipFromEntries(t, fileEntries(files))
}
func zipFromEntries(t testing.TB, entries []testEntry) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
for _, e := range entries {
hdr := &zip.FileHeader{Name: e.name}
body := e.body
switch {
case e.dir:
hdr.SetMode(fs.ModeDir | e.modeOr(0o755))
case e.link != "":
hdr.SetMode(fs.ModeSymlink | 0o777)
body = e.link
default:
hdr.SetMode(e.modeOr(0o644))
}
fw, err := w.CreateHeader(hdr)
require.NoError(t, err)
_, err = fw.Write([]byte(body))
require.NoError(t, err)
}
require.NoError(t, w.Close())
return buf.Bytes()
}
func gzBytes(t testing.TB, body string) []byte {
t.Helper()
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
_, err := gw.Write([]byte(body))
require.NoError(t, err)
require.NoError(t, gw.Close())
return buf.Bytes()
}
func tarGzBytes(t testing.TB, files map[string]string) []byte {
return tarGzFromEntries(t, fileEntries(files))
}
func tarGzFromEntries(t testing.TB, entries []testEntry) []byte {
t.Helper()
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
for _, e := range entries {
hdr := e.header()
require.NoError(t, tw.WriteHeader(&hdr))
_, err := tw.Write([]byte(e.body))
require.NoError(t, err)
}
require.NoError(t, tw.Close())
require.NoError(t, gw.Close())
return buf.Bytes()
}
func (e testEntry) modeOr(fallback fs.FileMode) fs.FileMode {
if e.mode != 0 {
return e.mode
}
return fallback
}
func regularHeader(name string, size int64) tar.Header {
return tar.Header{Name: name, Size: size, Mode: 0o600, Typeflag: tar.TypeReg}
}
// scanContext returns a context whose temp root is a fresh directory, and that directory.
func scanContext(t testing.TB) (context.Context, string) {
t.Helper()
root := t.TempDir()
return tmpdir.WithValue(context.Background(), tmpdir.FromPath(root)), root
}
// filesIn lists the regular files directly in dir.
func filesIn(t testing.TB, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
require.NoError(t, err)
var names []string
for _, e := range entries {
if !e.IsDir() {
names = append(names, e.Name())
}
}
return names
}
// spillFile is the one file a resolver has written, or "" when it has not.
func spillFile(t testing.TB, dir string) string {
t.Helper()
files := filesIn(t, dir)
require.LessOrEqual(t, len(files), 1)
if len(files) == 0 {
return ""
}
return files[0]
}
// resolverIn makes an empty resolver over the given charge that spills into dir, along with that
// directory. Its entries are added by the test.
func resolverIn(t testing.TB, charge *charge) (*Resolver, string) {
t.Helper()
root := t.TempDir()
ctx := tmpdir.WithValue(context.Background(), tmpdir.FromPath(root))
r := newResolver(ctx, file.NewCoordinates("test-archive", ""), charge)
t.Cleanup(r.Cleanup)
return r, root
}
// addAll adds entries in the order given and finishes the resolver.
func addAll(t testing.TB, r *Resolver, entries []testEntry) {
t.Helper()
for _, e := range entries {
require.NoError(t, r.add(e.header(), bytes.NewReader([]byte(e.body))))
}
r.finish()
}
// resolverOver builds a resolver holding the given files within a memory bound.
func resolverOver(t testing.TB, maxInMemory int64, files map[string]string) *Resolver {
t.Helper()
r, _ := resolverIn(t, memCharge(maxInMemory))
r.archiveCoordinates = file.NewCoordinates("outer.jar", "")
addAll(t, r, fileEntries(files))
return r
}
// resolverFrom builds a resolver holding the given entries, links and directories included, with
// ample memory.
func resolverFrom(t testing.TB, entries ...testEntry) *Resolver {
t.Helper()
r, _ := resolverIn(t, memCharge(1<<20))
r.archiveCoordinates = file.NewCoordinates("outer.jar", "")
addAll(t, r, entries)
return r
}
// extractBytes extracts an archive into a resolver built over the given charge.
func extractBytes(t testing.TB, data []byte, name string, charge *charge) (*Resolver, string) {
t.Helper()
r, root := resolverIn(t, charge)
content := bytes.NewReader(data)
format, err := identifyFormat(context.Background(), name, content)
require.NoError(t, err)
require.NotNil(t, format, "fixture must identify as an archive")
require.NoError(t, r.extract(context.Background(), format, content, nil))
return r, root
}
func readEntry(t testing.TB, r ReaderAtSeeker) string {
t.Helper()
_, err := r.Seek(0, io.SeekStart)
require.NoError(t, err)
b, err := io.ReadAll(r)
require.NoError(t, err)
return string(b)
}
// storedEntry is one entry read back out of a resolver.
type storedEntry struct {
body string
linkTarget string
mode fs.FileMode
kind stereoscopeFile.Type
}
// 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 _, 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
}
// storedNames lists every listed entry's name, files and directories alike.
func storedNames(t testing.TB, r *Resolver) []string {
t.Helper()
var names []string
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()
}
// diskCharge is a charge against a disk bound with memory refusing everything.
func diskCharge(maxDisk int64) *charge {
return NewLimiter(Limits{MaxDiskBytes: maxDisk}).charge()
}
// localPaths lists the local entry path of every location (rather than full nested archive path), sorted.
func localPaths(locations []file.Location) []string {
var out []string
for _, loc := range locations {
out = append(out, loc.LocalPath())
}
sort.Strings(out)
return out
}
// manyEntryZip is a zip of count one-byte files.
func manyEntryZip(t testing.TB, count int) []byte {
t.Helper()
files := make(map[string]string, count)
for i := range count {
files[fmt.Sprintf("e%06d.txt", i)] = "x"
}
return zipBytes(t, files)
}
// extractedResolver extracts one archive through Extract and returns the resolver, along with the scan
// temp root its spill file would appear under.
func extractedResolver(t testing.TB, data []byte, limits Limits) (*Resolver, string) {
t.Helper()
ctx, root := scanContext(t)
r, err := Extract(ctx, bytes.NewReader(data), file.NewCoordinates("app.zip", ""), NewLimiter(limits), nil)
require.NoError(t, err)
require.NotNil(t, r)
t.Cleanup(r.Cleanup)
return r, root
}
var (
unboundedLimits = Limits{MaxMemoryBytes: -1, MaxDiskBytes: -1}
// spillingLimits hold nothing in memory, so every entry lands in the spill file
spillingLimits = Limits{MaxMemoryBytes: 0, MaxDiskBytes: -1}
)
+202
View File
@@ -0,0 +1,202 @@
package archive
import (
"errors"
"sync"
)
// Budget bounds the bytes decompressed under one top-level archive, summed over every archive nested in
// it. Unlike Limits it never refills as archives are released: it bounds work, not what is held at once,
// which is what stops a recursive bomb (42.zip) whose every level fits the limits on its own. A nil
// *Budget admits everything.
//
// ponytail: no lock, since the archive walk is sequential; guard it if the walk ever runs concurrently.
type Budget struct {
remaining int64
}
func NewBudget(bytes int64) *Budget {
return &Budget{remaining: bytes}
}
// take spends n bytes, or reports false (and spends the rest) when fewer than n remain.
func (b *Budget) take(n int64) bool {
if b == nil {
return true
}
if n > b.remaining {
b.remaining = 0
return false
}
b.remaining -= n
return true
}
var errBudgetSpent = errors.New("archive tree decompression budget spent")
// ErrDiskLimitReached reports that placing an archive's content would exceed the disk limit.
var ErrDiskLimitReached = errors.New("archive content would exceed the disk limit")
// Limits bound how much archive content one scan holds at once. Each limit falls as archives are
// released, so it bounds peak usage rather than the total ever written.
//
// For both limits: positive is the bound, zero forbids the resource, negative is unbounded.
type Limits struct {
// MaxMemoryBytes bounds archive content held in memory. Content that does not fit is written to
// disk instead.
MaxMemoryBytes int64
// MaxDiskBytes bounds archive content written to disk. Content that does not fit is dropped and
// the archive is skipped or truncated.
MaxDiskBytes int64
}
// Limiter tracks how much archive content a scan holds right now against its Limits. A nil *Limiter
// enforces nothing.
type Limiter struct {
limits Limits
mu sync.Mutex
memory int64
disk int64
peakMemory int64
peakDisk int64
}
func NewLimiter(limits Limits) *Limiter {
return &Limiter{limits: limits}
}
// Peak reports the most memory and disk held at any one moment.
func (l *Limiter) Peak() (memory, disk int64) {
if l == nil {
return 0, 0
}
l.mu.Lock()
defer l.mu.Unlock()
return l.peakMemory, l.peakDisk
}
// InUse reports the memory and disk held right now.
func (l *Limiter) InUse() (memory, disk int64) {
if l == nil {
return 0, 0
}
l.mu.Lock()
defer l.mu.Unlock()
return l.memory, l.disk
}
// charge starts accounting for one archive. Releasing the charge gives back everything it took.
func (l *Limiter) charge() *charge {
if l == nil {
return nil
}
return &charge{limiter: l}
}
// charge is one archive's draw on a Limiter. A nil *charge admits everything.
//
// Bytes are charged as they land, never from the sizes an archive declares, since those can lie.
type charge struct {
limiter *Limiter
inMemory int64
onDisk int64
}
func admits(limit, held, n int64) bool {
switch {
case limit < 0:
return true
case limit == 0:
return false
default:
return held+n <= limit
}
}
// memory charges n bytes held in memory, or reports false (charging nothing) when the memory limit
// does not admit them.
func (c *charge) memory(n int64) bool {
if c == nil || n <= 0 {
return true
}
l := c.limiter
l.mu.Lock()
defer l.mu.Unlock()
if !admits(l.limits.MaxMemoryBytes, l.memory, n) {
return false
}
l.memory += n
c.inMemory += n
l.peakMemory = max(l.peakMemory, l.memory)
return true
}
// disk charges n bytes written to disk, or reports false (charging nothing) when the disk limit does
// not admit them.
func (c *charge) disk(n int64) bool {
if c == nil || n <= 0 {
return true
}
l := c.limiter
l.mu.Lock()
defer l.mu.Unlock()
if !admits(l.limits.MaxDiskBytes, l.disk, n) {
return false
}
l.disk += n
c.onDisk += n
l.peakDisk = max(l.peakDisk, l.disk)
return true
}
// index charges n bytes of index bookkeeping, which lives in memory whatever the limits say. A zero
// memory limit is a valid configuration that must still index archives, so only then does the cost
// fall back to the disk budget; a bounded memory limit bounds the index too.
func (c *charge) index(n int64) bool {
if c.memory(n) {
return true
}
return c != nil && c.limiter.limits.MaxMemoryBytes == 0 && c.disk(n)
}
// refundMemory gives back n bytes no longer held in memory.
func (c *charge) refundMemory(n int64) {
if c == nil || n <= 0 {
return
}
l := c.limiter
l.mu.Lock()
defer l.mu.Unlock()
n = min(n, c.inMemory)
l.memory -= n
c.inMemory -= n
}
// refundDisk gives back n bytes no longer on disk.
func (c *charge) refundDisk(n int64) {
if c == nil || n <= 0 {
return
}
l := c.limiter
l.mu.Lock()
defer l.mu.Unlock()
n = min(n, c.onDisk)
l.disk -= n
c.onDisk -= n
}
// release gives back everything this charge took. Safe to call more than once.
func (c *charge) release() {
if c == nil {
return
}
l := c.limiter
l.mu.Lock()
defer l.mu.Unlock()
l.memory -= c.inMemory
l.disk -= c.onDisk
c.inMemory, c.onDisk = 0, 0
}
+178
View File
@@ -0,0 +1,178 @@
package archive
import (
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLimiter_chargeAndRelease(t *testing.T) {
limiter := NewLimiter(Limits{MaxMemoryBytes: 100, MaxDiskBytes: 200})
first := limiter.charge()
require.True(t, first.memory(60))
require.True(t, first.disk(150))
mem, disk := limiter.InUse()
assert.Equal(t, int64(60), mem)
assert.Equal(t, int64(150), disk)
second := limiter.charge()
assert.False(t, second.memory(50), "60 + 50 is over the memory limit")
assert.False(t, second.disk(60), "150 + 60 is over the disk limit")
assert.True(t, second.memory(40), "and exactly at the limit is admitted")
mem, _ = limiter.InUse()
assert.Equal(t, int64(100), mem, "a refused charge takes nothing")
first.release()
mem, disk = limiter.InUse()
assert.Equal(t, int64(40), mem, "release gives back exactly what that charge took")
assert.Zero(t, disk)
first.release()
mem, _ = limiter.InUse()
assert.Equal(t, int64(40), mem, "releasing twice must not give back what was never taken")
}
func TestLimiter_refund(t *testing.T) {
limiter := NewLimiter(Limits{MaxDiskBytes: 100})
charge := limiter.charge()
require.True(t, charge.disk(80))
charge.refundDisk(80)
_, disk := limiter.InUse()
assert.Zero(t, disk)
assert.True(t, charge.disk(100), "the refunded room is available again")
charge.refundDisk(500)
_, disk = limiter.InUse()
assert.Zero(t, disk, "a refund larger than what was charged cannot drive the limiter negative")
}
func TestLimiter_threeStateReadingIsUniform(t *testing.T) {
t.Run("positive is the limit", func(t *testing.T) {
c := NewLimiter(Limits{MaxMemoryBytes: 100, MaxDiskBytes: 100}).charge()
assert.True(t, c.memory(100))
assert.False(t, c.memory(1))
assert.True(t, c.disk(100))
assert.False(t, c.disk(1))
})
t.Run("zero refuses unconditionally on both", func(t *testing.T) {
c := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: 0}).charge()
assert.False(t, c.memory(1))
assert.False(t, c.disk(1))
})
t.Run("negative admits unconditionally on both", func(t *testing.T) {
c := NewLimiter(Limits{MaxMemoryBytes: -1, MaxDiskBytes: -1}).charge()
assert.True(t, c.memory(1_000_000_000))
assert.True(t, c.disk(1_000_000_000))
})
}
func TestLimiter_nilIsUnbounded(t *testing.T) {
var limiter *Limiter
charge := limiter.charge()
assert.Nil(t, charge)
assert.True(t, charge.memory(1_000_000))
assert.True(t, charge.disk(1_000_000))
charge.refundDisk(10)
charge.release()
mem, disk := charge.held()
assert.Zero(t, mem)
assert.Zero(t, disk)
mem, disk = limiter.InUse()
assert.Zero(t, mem)
assert.Zero(t, disk)
mem, disk = limiter.Peak()
assert.Zero(t, mem)
assert.Zero(t, disk)
}
func TestLimiter_concurrentChargesAgree(t *testing.T) {
limiter := NewLimiter(Limits{MaxMemoryBytes: 1_000_000, MaxDiskBytes: -1})
var wg sync.WaitGroup
charges := make([]*charge, 50)
for i := range charges {
charges[i] = limiter.charge()
wg.Add(1)
go func(c *charge) {
defer wg.Done()
for range 100 {
c.memory(1)
c.disk(2)
}
}(charges[i])
}
wg.Wait()
mem, disk := limiter.InUse()
assert.Equal(t, int64(50*100), mem)
assert.Equal(t, int64(50*100*2), disk)
for _, c := range charges {
c.release()
}
mem, disk = limiter.InUse()
assert.Zero(t, mem)
assert.Zero(t, disk)
}
func TestLimiter_peak(t *testing.T) {
t.Run("is a high-water mark", func(t *testing.T) {
l := NewLimiter(Limits{MaxMemoryBytes: -1, MaxDiskBytes: -1})
first := l.charge()
require.True(t, first.memory(100))
require.True(t, first.disk(300))
first.release()
second := l.charge()
require.True(t, second.memory(40))
require.True(t, second.disk(50))
memory, disk := l.InUse()
assert.Equal(t, int64(40), memory)
assert.Equal(t, int64(50), disk)
peakMemory, peakDisk := l.Peak()
assert.Equal(t, int64(100), peakMemory, "the peak is the most held at once, not the most recently held")
assert.Equal(t, int64(300), peakDisk)
})
t.Run("counts concurrent holders together", func(t *testing.T) {
l := NewLimiter(Limits{MaxMemoryBytes: -1, MaxDiskBytes: -1})
outer := l.charge()
require.True(t, outer.memory(100))
inner := l.charge()
require.True(t, inner.memory(60))
inner.release()
outer.release()
peakMemory, _ := l.Peak()
assert.Equal(t, int64(160), peakMemory)
})
t.Run("a refused charge does not move it", func(t *testing.T) {
l := NewLimiter(Limits{MaxMemoryBytes: 50, MaxDiskBytes: 50})
c := l.charge()
assert.False(t, c.memory(500))
assert.False(t, c.disk(500))
peakMemory, peakDisk := l.Peak()
assert.Zero(t, peakMemory)
assert.Zero(t, peakDisk)
})
}
+575
View File
@@ -0,0 +1,575 @@
package archive
import (
"archive/tar"
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"slices"
"sort"
"strings"
"time"
"unicode"
"github.com/dustin/go-humanize"
"github.com/mholt/archives"
stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/stereoscope/pkg/filetree"
intFile "github.com/anchore/syft/internal/file"
"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. Entries are indexed in a stereoscope file
// tree, the same index the directory and image resolvers search.
//
// Locations it returns keep the outermost archive's RealPath and carry the entry's FullPath. Queries
// may name an entry either by its full path or by its path within the archive (see rootedPath).
type Resolver struct {
// Digests are of the archive file itself.
Digests []file.Digest
// Truncated is non-nil when the resolver covers only part of the archive, and says why: the disk limit
// or the decompression budget stopped extraction early, or an entry was too large or unreadable.
Truncated error
archiveCoordinates file.Coordinates
charge *charge
budget *Budget
metered bool // the budget is charged as the archive is decoded (see meteredReader), not in put
// content is held in memory while the memory limit admits it and in one spill file once it does
// not. The file is created on first use, so an archive that stays in memory never touches the
// filesystem, and blobs are located in it by offset with no framing of their own.
tempDir *tmpdir.TempDir
held []*blob // blobs in memory, in the order they were added
file *os.File
remove func()
written int64
chunk []byte
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)
// newResolver returns an empty resolver whose locations extend the archive's coordinates, spilling into
// the scan's temp root when ctx carries one (see internal/tmpdir).
func newResolver(ctx context.Context, archiveCoordinates file.Coordinates, charge *charge) *Resolver {
return &Resolver{
archiveCoordinates: archiveCoordinates,
charge: charge,
budget: budgetFromContext(ctx),
tempDir: tmpdir.FromContext(ctx),
tree: filetree.New(),
index: filetree.NewIndex(),
content: map[stereoscopeFile.ID]*blob{},
}
}
// Cleanup removes everything the archive holds on disk and releases its charge against the limiter.
// Readers handed out before are no longer valid. Safe to call more than once.
func (r *Resolver) Cleanup() {
if r == nil {
return
}
r.releaseStorage()
r.charge.release()
}
// 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 {
return approxIndexBytesPerEntry + int64(len(hdr.Name)+len(hdr.Linkname))
}
// add stores and indexes one entry, reading its content in full now since archive readers are
// sequential. Reaching a limit stores nothing for this entry and returns ErrDiskLimitReached; entries
// added before it remain usable. The last entry at a path wins, as it does when the archive is
// extracted (tar appends updates, an OCI layer applies in order, and java's legacy zip reading
// overwrites too), so a stale earlier copy, such as an older pom.properties, can't stand in for what is
// really there.
func (r *Resolver) add(hdr tar.Header, content io.Reader) error {
entryPath := path.Clean("/" + hdr.Name)
if entryPath == "/" {
return nil
}
replacing, ok := r.replace(entryPath, hdr)
if !ok {
return nil
}
// directories the path implies that do not exist yet are nodes this entry pays for. Walking from the
// deepest parent up stops at the first that exists, since its ancestors exist too
var newDirs []string
for i := strings.LastIndexByte(entryPath, '/'); i > 0; i = strings.LastIndexByte(entryPath[:i], '/') {
if r.tree.HasPath(stereoscopeFile.Path(entryPath[:i])) {
break
}
newDirs = append(newDirs, entryPath[:i])
}
slices.Reverse(newDirs)
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(b, io.LimitReader(content, maxEntryBytes+1))
if err == nil && b.size > maxEntryBytes {
err = errEntryTooLarge
}
if err != nil {
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
}
if errors.Is(err, ErrDiskLimitReached) || errors.Is(err, errBudgetSpent) {
return err
}
// a damaged entry (a CRC mismatch, a corrupt deflate stream) costs only that entry
r.truncate(fmt.Errorf("entry %q could not be read: %w", hdr.Name, err))
return nil
}
}
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
}
if hdr.Typeflag != tar.TypeDir && !replacing {
r.entries++
}
return nil
}
// replace clears the way for an entry at a path that is already held, reporting whether it did and
// false when the entry should be dropped. A later non-directory entry replaces an earlier one, refunding
// what it held. A file and a directory at one path keep the first, since swapping kinds would mean
// re-parenting what is already beneath it.
func (r *Resolver) replace(entryPath string, hdr tar.Header) (replacing, ok bool) {
exists, res, _ := r.tree.File(stereoscopeFile.Path(entryPath))
if !exists || !res.HasReference() {
return false, true
}
earlier, err := r.index.Get(*res.Reference)
if err != nil || earlier.IsDir() || hdr.Typeflag == tar.TypeDir {
return false, false
}
// a removed node's index entry is left behind, but searches only answer with references in the tree
if err := r.tree.RemovePath(stereoscopeFile.Path(entryPath)); err != nil {
log.WithFields("entry", hdr.Name, "error", err).Trace("unable to replace archive entry")
return false, false
}
if b := r.content[res.ID()]; b != nil {
r.discard(b)
}
delete(r.content, res.ID())
return true, true
}
func (r *Resolver) store(rooted string, hdr tar.Header, b *blob) error {
ref, err := r.addToTree(rooted, hdr)
if err != nil {
return err
}
metadata := stereoscopeFile.NewMetadata(hdr, r.open(b))
metadata.Path = entryPath(rooted)
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
var errEntryTooLarge = errors.New("archive entry exceeds the per-entry size cap")
// truncate marks the resolver as covering part of the archive, keeping the first reason given.
func (r *Resolver) truncate(err error) {
if r.Truncated == nil {
r.Truncated = err
}
}
// chargeIndex charges index bookkeeping, which lives in memory. Content held in memory can move to
// disk to make room for it; the index cannot, so when it is refused the entries are.
func (r *Resolver) chargeIndex(n int64) bool {
if r.charge.index(n) {
return true
}
if len(r.held) == 0 || r.spill() != nil {
return false
}
return r.charge.index(n)
}
// finish builds the search context once every entry is added.
func (r *Resolver) finish() {
r.search = filetree.NewSearchContext(r.tree, r.index)
}
// 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)
}
// entryPath is a node's path within the archive: archive-relative with no leading slash.
func entryPath(rooted string) string {
return strings.TrimPrefix(rooted, "/")
}
// fullPath is how paths leave the resolver: the archive's own full path, then the entry path.
func (r *Resolver) fullPath(rooted string) string {
return r.archiveCoordinates.FullNestedPath() + file.ArchivePathSeparator + entryPath(rooted)
}
// rootedPath is the tree path a query names, which is either form a path takes: a full path is trimmed to
// the entry path within this archive, and anything else (a LocalPath, with or without its leading slash)
// is taken as archive-relative already, since the tree looks a relative path up from its root. The archive's full path with a trailing "!" (what path.Dir gives for an entry at the root)
// names the root. A full path into another archive in the scan names nothing here and reports false.
func (r *Resolver) rootedPath(userPath string) (string, bool) {
if rest, ok := strings.CutPrefix(userPath, r.archiveCoordinates.FullNestedPath()); ok {
if rest == file.ArchivePathSeparator[:1] {
return "/", true
}
if strings.HasPrefix(rest, file.ArchivePathSeparator) {
// keep the separator's slash as the rooted path's
return path.Clean(rest[len(file.ArchivePathSeparator)-1:]), true
}
}
if r.outside(userPath) {
return "", false
}
return path.Clean(userPath), true
}
// outside reports whether a path not under this archive's full path is a full path from the scan
// nonetheless: every full path in this archive tree starts with the outermost archive's real path and a
// separator. The real path alone is taken as an entry path, so an entry named like the outermost archive
// is found by its LocalPath.
func (r *Resolver) outside(userPath string) bool {
rest, ok := strings.CutPrefix(userPath, r.archiveCoordinates.RealPath)
return ok && strings.HasPrefix(rest, file.ArchivePathSeparator)
}
// coordinates name an entry by the outermost archive's real path, which never includes a path inside an
// archive, and its full path through every archive.
func (r *Resolver) coordinates(rooted string) file.Coordinates {
return file.Coordinates{
RealPath: r.archiveCoordinates.RealPath,
FileSystemID: r.archiveCoordinates.FileSystemID,
FullPath: r.fullPath(rooted),
}
}
// location names a resolution: a link is reported at its target's path with the link's own full path
// as the access path.
func (r *Resolver) location(res stereoscopeFile.Resolution) file.Location {
coordinates := r.coordinates(string(res.RealPath))
if res.RequestPath == "" || res.RequestPath == res.RealPath {
return file.NewLocationFromCoordinates(coordinates)
}
return file.NewVirtualLocationFromCoordinates(coordinates, r.fullPath(string(res.RequestPath)))
}
// 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) {
rooted, ok := r.rootedPath(userPath)
if !ok {
return nil, false
}
res, err := r.search.SearchByPath(rooted, filetree.FollowBasenameLinks)
if err != nil || res == nil || !res.HasReference() {
return nil, false
}
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) {
res, ok := r.resolve(location.FullNestedPath())
if !ok {
return nil, fmt.Errorf("no archive entry for path=%q", location.FullNestedPath())
}
return entryReader{r.open(r.content[res.ID()])}, nil
}
func (r *Resolver) FileMetadataByLocation(location file.Location) (file.Metadata, error) {
rooted, ok := r.rootedPath(location.FullNestedPath())
if !ok {
return file.Metadata{}, fmt.Errorf("no archive entry for path=%q", location.FullNestedPath())
}
exists, res, err := r.tree.File(stereoscopeFile.Path(rooted))
if err != nil || !exists || !res.HasReference() {
return file.Metadata{}, fmt.Errorf("no archive entry for path=%q", location.FullNestedPath())
}
entry, err := r.index.Get(*res.Reference)
if err != nil {
return file.Metadata{}, err
}
// the index holds entry paths; callers get full paths, as with locations
metadata := entry.Metadata
metadata.Path = r.fullPath(rooted)
if metadata.LinkDestination != "" {
metadata.LinkDestination = r.fullPath(linkDestination(rooted, metadata.LinkDestination, metadata.Type == stereoscopeFile.TypeHardLink))
}
return metadata, nil
}
func (r *Resolver) HasPath(userPath string) bool {
rooted, ok := r.rootedPath(userPath)
return ok && r.tree.HasPath(stereoscopeFile.Path(rooted))
}
func (r *Resolver) FilesByPath(paths ...string) ([]file.Location, error) {
var out []file.Location
var seen []stereoscopeFile.ID // few paths are asked for at once
for _, userPath := range paths {
res, ok := r.resolve(userPath)
if !ok || slices.Contains(seen, res.ID()) {
continue
}
seen = append(seen, res.ID())
out = append(out, r.location(*res))
}
return out, nil
}
func (r *Resolver) FilesByGlob(patterns ...string) ([]file.Location, error) {
var out []file.Location
seen := stereoscopeFile.NewFileReferenceSet()
for _, pattern := range patterns {
rooted, ok := r.rootedPath(pattern)
if !ok {
continue
}
results, err := r.searchByGlob(rooted)
if err != nil {
return nil, fmt.Errorf("invalid glob %q: %w", pattern, err)
}
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
}
// searchByGlob is the index search, except that stereoscope answers "<dir>/*" by looking the directory
// up by name and the root has none, so "/*" (or "*") walks the tree instead.
func (r *Resolver) searchByGlob(pattern string) ([]stereoscopeFile.Resolution, error) {
if strings.TrimPrefix(pattern, "/") == "*" {
return r.tree.FilesByGlob("/*", filetree.FollowBasenameLinks)
}
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 a, b := locations[i].FullNestedPath(), locations[j].FullNestedPath(); a != b {
return a < b
}
return locations[i].AccessPath < locations[j].AccessPath
})
}
func (r *Resolver) FilesByMIMEType(types ...string) ([]file.Location, error) {
results, err := r.search.SearchByMIMEType(types...)
if err != nil {
return nil, err
}
var out []file.Location
for _, res := range results {
if res.HasReference() {
out = append(out, r.location(res))
}
}
sortLocations(out)
return out, nil
}
// RelativeFileByPath resolves against the archive root: an archive has no layers to be relative to.
func (r *Resolver) RelativeFileByPath(_ file.Location, userPath string) *file.Location {
locations, _ := r.FilesByPath(userPath)
if len(locations) == 0 {
return nil
}
return &locations[0]
}
func (r *Resolver) AllLocations(ctx context.Context) <-chan file.Location {
out := make(chan file.Location)
go func() {
defer close(out)
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.location(stereoscopeFile.Resolution{RequestPath: ref.RealPath, Reference: &ref}):
case <-ctx.Done():
return
}
}
}()
return out
}
// entryReader gives an entry's reader the Close the resolver interface requires. The resolver owns
// the bytes, so there is nothing to close.
type entryReader struct {
ReaderAtSeeker
}
func (entryReader) Close() error { return nil }
// maxEntryNameBytes is the longest entry name stored: PATH_MAX. Every path component becomes a node,
// so a longer name is a cheap way to inflate the index and no filesystem could hold it anyway.
const maxEntryNameBytes = MaxPathBytes
// MaxPathBytes is PATH_MAX, the longest whole archive chain a scan stores or a decoded document may build.
// Each nested file copies its container's chain, so without it a long name shared by many small entries
// costs memory quadratic in the document.
const MaxPathBytes = 4096
// MaxDepth is the deepest archives nest: a scan stops there whatever depth is configured, and a decoded
// document is held to it. No real nesting comes close, and a hostile archive (a zip quine, a recursive bomb)
// must still end.
const MaxDepth = 16
// maxEntryNameDepth is the most path components an entry name may have. Adding an entry costs work per
// component, and no real archive nests anywhere near this deep.
const maxEntryNameDepth = 256
// entryHeader builds the header stored for one archive entry, or reports false for an entry not worth
// carrying: device nodes, fifos and sockets, which no cataloger reads, and names that cannot be a path
// inside the archive. A name refused as unsafe marks the resolver truncated.
//
// The name is cleaned to an archive-relative path with no leading slash, so "../x" names x at the
// archive root rather than anything outside it. Symlinks are stored as headers, never followed on the
// host; the resolver resolves their targets inside the archive.
func (r *Resolver) entryHeader(f archives.FileInfo) (tar.Header, bool) {
mode := f.Mode()
if !mode.IsRegular() && !mode.IsDir() && mode.Type()&fs.ModeSymlink == 0 {
log.WithFields("entry", f.NameInArchive, "mode", mode).Debug("skipping non-regular archive entry")
return tar.Header{}, false
}
name, err := EntryName(f.NameInArchive)
if err != nil {
r.truncate(fmt.Errorf("an entry was skipped: %w", err))
return tar.Header{}, false
}
if name == "" {
return tar.Header{}, false
}
// held to the chain length a decoder accepts back, so the SBOM round-trips
if chain := len(r.archiveCoordinates.FullNestedPath()) + len(file.ArchivePathSeparator) + len(name); chain > MaxPathBytes {
r.truncate(fmt.Errorf("an entry was skipped: its path of %d bytes within the archive chain exceeds the %d byte cap", chain, MaxPathBytes))
return tar.Header{}, false
}
hdr, err := tar.FileInfoHeader(f.FileInfo, f.LinkTarget)
if err != nil {
log.WithFields("entry", f.NameInArchive, "error", err).Debug("skipping archive entry with an unusable header")
return tar.Header{}, false
}
hdr.Name = name
hdr.Linkname = f.LinkTarget
// nothing reads these, and the header is held for the life of the archive
hdr.PAXRecords = nil
hdr.Uname, hdr.Gname = "", ""
hdr.AccessTime, hdr.ChangeTime = time.Time{}, time.Time{}
hdr.ModTime = hdr.ModTime.Truncate(time.Second)
return *hdr, true
}
// EntryName cleans an archive entry's name to a path relative to the archive root, returning "" when it
// names nothing inside the archive. This is the trust boundary for names: every one ends up in FullPath
// and the encoders, so a name that could forge an archive boundary ("!/") or a line in a line-oriented
// format (control characters, unicode line separators) is refused. Decoders building nested coordinates
// from a document hold its names to the same rules.
func EntryName(name string) (string, error) {
if len(name) > maxEntryNameBytes {
return "", fmt.Errorf("entry name of %d bytes exceeds the %d byte cap", len(name), maxEntryNameBytes)
}
if strings.IndexFunc(name, isUnsafeNameRune) >= 0 {
return "", fmt.Errorf("entry name %q contains a control character", name)
}
cleaned := strings.TrimPrefix(path.Clean("/"+name), "/")
if depth := strings.Count(cleaned, "/") + 1; depth > maxEntryNameDepth {
return "", fmt.Errorf("entry name of %d path components exceeds the %d component cap", depth, maxEntryNameDepth)
}
if strings.Contains(cleaned, file.ArchivePathSeparator) {
return "", fmt.Errorf("entry name %q contains the archive path separator %q", name, file.ArchivePathSeparator)
}
return cleaned, nil
}
func isUnsafeNameRune(c rune) bool {
return unicode.IsControl(c) || c == '\u2028' || c == '\u2029'
}
+506
View File
@@ -0,0 +1,506 @@
package archive
import (
"archive/tar"
"bytes"
"context"
"io"
"path"
"sort"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/syft/syft/file"
)
var jarEntriesFixture = map[string]string{
"META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n",
"META-INF/maven/com.example/lib/pom.properties": "groupId=com.example\n",
"META-INF/maven/com.example/lib/pom.xml": "<project/>",
"LICENSE": "Apache License 2.0",
"BOOT-INF/lib/inner.jar": "PK\x03\x04 not really",
"com/example/Thing.class": "bytecode",
}
func TestResolver_add_refusedEntryIsNotStored(t *testing.T) {
// a half-stored pom a cataloger parses is worse than an absent one
r, _ := resolverIn(t, NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: 0}).charge())
err := r.add(regularHeader("a.txt", 3), bytes.NewReader([]byte("abc")))
assert.ErrorIs(t, err, ErrDiskLimitReached)
assert.Empty(t, storedNames(t, r))
assert.False(t, r.HasPath("a.txt"))
}
func TestResolver_add_refusedContentLeavesNoNodeBehind(t *testing.T) {
// the index estimate is admitted, then the content is refused
record := approxIndexBytes(regularHeader("dir/a.txt", 0)) + approxIndexBytesPerEntry // the entry and the directory it implies
r, _ := resolverIn(t, diskCharge(record+2))
err := r.add(regularHeader("dir/a.txt", 3), bytes.NewReader([]byte("abc")))
assert.ErrorIs(t, err, ErrDiskLimitReached)
assert.False(t, r.HasPath("dir/a.txt"))
assert.True(t, r.HasPath("dir"), "the directories it implied stay")
}
func TestResolver_add_directoryEntriesHoldNoContent(t *testing.T) {
r, _ := resolverIn(t, memCharge(1<<20))
require.NoError(t, r.add(tar.Header{Name: "lib", Typeflag: tar.TypeDir, Mode: 0o755}, nil))
assert.Zero(t, r.heldInMemory())
assert.Empty(t, readStore(t, r)["lib"].body)
}
func TestResolver_add_indexDoesNotFallBackToDiskWhenMemoryIsBounded(t *testing.T) {
// the index lives in memory, so a bounded memory limit bounds it; only a zero limit defers to disk
limiter := NewLimiter(Limits{MaxMemoryBytes: 1024, MaxDiskBytes: -1})
r, _ := resolverIn(t, limiter.charge())
err := r.add(regularHeader("a.txt", 0), bytes.NewReader(nil))
assert.ErrorIs(t, err, ErrDiskLimitReached)
_, disk := limiter.InUse()
assert.Zero(t, disk)
}
func TestResolver_add_indexMakesRoomBySpillingHeldContent(t *testing.T) {
record := approxIndexBytes(regularHeader("a.txt", 0))
r, dir := resolverIn(t, memCharge(2*record+50))
require.NoError(t, r.add(regularHeader("a.txt", 100), bytes.NewReader(bytes.Repeat([]byte("a"), 100))))
require.Equal(t, int64(100), r.heldInMemory())
// b's index does not fit beside a's content, so a's content moves to disk and b is indexed
require.NoError(t, r.add(regularHeader("b.txt", 3), bytes.NewReader([]byte("bbb"))))
r.finish()
assert.Equal(t, int64(3), r.heldInMemory())
assert.NotEmpty(t, spillFile(t, dir))
assert.Equal(t, []string{"a.txt", "b.txt"}, storedNames(t, r))
assert.Equal(t, strings.Repeat("a", 100), readStore(t, r)["a.txt"].body)
}
func TestResolver_add_lastEntryAtAPathWins(t *testing.T) {
r, _ := resolverIn(t, memCharge(1<<20))
require.NoError(t, r.add(regularHeader("a.txt", 5), bytes.NewReader([]byte("first"))))
require.NoError(t, r.add(regularHeader("a.txt", 6), bytes.NewReader([]byte("second"))))
require.NoError(t, r.add(regularHeader("b.txt", 1), bytes.NewReader([]byte("b"))))
require.NoError(t, r.add(tar.Header{Name: "b.txt", Typeflag: tar.TypeSymlink, Linkname: "a.txt"}, nil))
r.finish()
assert.Equal(t, "second", readStore(t, r)["a.txt"].body)
assert.Equal(t, "a.txt", readStore(t, r)["b.txt"].linkTarget, "a later entry may change kind")
assert.Equal(t, 2, r.entries)
assert.Equal(t, int64(len("second")), r.heldInMemory(), "the earlier copy is refunded")
locations, err := r.FilesByGlob("**/a.txt")
require.NoError(t, err)
require.Len(t, locations, 1)
reader, err := r.FileContentsByLocation(locations[0])
require.NoError(t, err)
assert.Equal(t, "second", readEntry(t, reader.(entryReader).ReaderAtSeeker))
}
func TestResolver_add_fileAndDirectoryAtOnePathKeepsTheFirst(t *testing.T) {
r, _ := resolverIn(t, memCharge(1<<20))
require.NoError(t, r.add(regularHeader("a/b", 1), bytes.NewReader([]byte("x"))))
require.NoError(t, r.add(regularHeader("a", 4), bytes.NewReader([]byte("file"))))
require.NoError(t, r.add(regularHeader("c", 4), bytes.NewReader([]byte("file"))))
require.NoError(t, r.add(tar.Header{Name: "c", Typeflag: tar.TypeDir, Mode: 0o755}, nil))
r.finish()
got := readStore(t, r)
assert.Equal(t, stereoscopeFile.TypeDirectory, got["a"].kind)
assert.Equal(t, "file", got["c"].body)
}
func TestResolver_pathsAndContents(t *testing.T) {
r := resolverOver(t, 1<<20, jarEntriesFixture)
locations, err := r.FilesByPath("/META-INF/MANIFEST.MF")
require.NoError(t, err)
require.Len(t, locations, 1)
assert.True(t, r.HasPath("/META-INF/MANIFEST.MF"))
assert.True(t, r.HasPath("META-INF/MANIFEST.MF"), "a path may arrive with or without the leading slash")
assert.False(t, r.HasPath("/nope"))
reader, err := r.FileContentsByLocation(locations[0])
require.NoError(t, err)
body, err := io.ReadAll(reader)
require.NoError(t, err)
require.NoError(t, reader.Close())
assert.Equal(t, "Manifest-Version: 1.0\n", string(body))
}
func TestResolver_fullPathsOutsideTheArchiveNameNothing(t *testing.T) {
r, _ := resolverIn(t, memCharge(1<<20))
r.archiveCoordinates = file.Coordinates{RealPath: "/app.war", FullPath: "/app.war!/WEB-INF/lib/dep.jar"}
addAll(t, r, fileEntries(map[string]string{"/app.war": "same name", "/META-INF/MANIFEST.MF": "m"}))
locations, err := r.FilesByPath("/app.war!/WEB-INF/lib/dep.jar!/META-INF/MANIFEST.MF")
require.NoError(t, err)
assert.Len(t, locations, 1, "a full path in this archive")
for _, p := range []string{"/app.war!/WEB-INF/lib/dep.jar", "/app.war!/WEB-INF/lib/other.jar!/META-INF/MANIFEST.MF"} {
locations, err = r.FilesByPath(p)
require.NoError(t, err)
assert.Empty(t, locations, "%s is outside the archive", p)
assert.False(t, r.HasPath(p), p)
}
for _, p := range []string{"app.war", "/app.war"} {
locations, err = r.FilesByPath(p)
require.NoError(t, err)
require.Len(t, locations, 1, "%s is an entry path named like the outermost archive", p)
assert.Equal(t, "/app.war!/WEB-INF/lib/dep.jar!/app.war", locations[0].FullPath, p)
}
again, err := r.FilesByPath(locations[0].LocalPath())
require.NoError(t, err)
assert.Equal(t, locations, again, "a LocalPath finds its own entry")
}
func TestResolver_mimeTypesAreSniffedFromContent(t *testing.T) {
r := resolverOver(t, 1<<20, map[string]string{
"notes.txt": "just some text",
"run.sh": "#!/bin/bash\necho hello\n",
})
locations, err := r.FilesByMIMEType("text/x-shellscript")
require.NoError(t, err)
assert.Equal(t, []string{"/run.sh"}, localPaths(locations))
}
func TestResolver_answersAreOrdered(t *testing.T) {
// order decides which locations group into which package when results merge into the SBOM
entries := map[string]string{}
for _, name := range []string{
"b/z.jar", "a/y.jar", "c/x.jar", "a/b/c/w.jar", "z.jar", "a.jar",
} {
entries[name] = "PK\x03\x04 not really"
}
r := resolverOver(t, 1<<20, entries)
first, err := r.FilesByGlob("**/*.jar")
require.NoError(t, err)
require.Len(t, first, len(entries))
byMIME, err := r.FilesByMIMEType("application/octet-stream")
require.NoError(t, err)
for range 8 {
again, err := r.FilesByGlob("**/*.jar")
require.NoError(t, err)
assert.Equal(t, first, again)
mimeAgain, err := r.FilesByMIMEType("application/octet-stream")
require.NoError(t, err)
assert.Equal(t, byMIME, mimeAgain)
var all []string
for loc := range r.AllLocations(context.Background()) {
all = append(all, loc.LocalPath())
}
assert.True(t, sort.StringsAreSorted(all))
}
var paths []string
for _, loc := range first {
paths = append(paths, loc.LocalPath())
}
assert.True(t, sort.StringsAreSorted(paths), "ordered by path, not merely stable")
}
func TestResolver_impliedDirectoriesArePathsButNotFiles(t *testing.T) {
r := resolverOver(t, 1<<20, jarEntriesFixture)
assert.True(t, r.HasPath("META-INF"))
assert.True(t, r.HasPath("/META-INF/maven"))
locations, err := r.FilesByPath("META-INF")
require.NoError(t, err)
assert.Empty(t, locations)
locations, err = r.FilesByGlob("**/maven")
require.NoError(t, err)
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("**/[")
assert.Error(t, err)
}
func globPaths(t *testing.T, r *Resolver, pattern string) []string {
t.Helper()
locations, err := r.FilesByGlob(pattern)
require.NoError(t, err)
return localPaths(locations)
}
// several paths to one file answer as one location: the file's own path when it matched, otherwise
// the lowest-sorting link
func TestResolver_linksCollapseToOnePath(t *testing.T) {
tests := []struct {
name string
entries []testEntry
pattern string
want []string
reason string
}{
{
name: "the file itself beats every link to it",
entries: []testEntry{
{name: "opt/real.jar", body: "PK\x03\x04"},
{name: "opt/a-sym.jar", link: "real.jar"},
{name: "opt/z-sym.jar", link: "real.jar"},
{name: "opt/hard.jar", link: "opt/real.jar", hard: true},
},
pattern: "**/*.jar",
want: []string{"/opt/real.jar"},
reason: "one file, so one location - and it is named by the path that holds it",
},
{
name: "with no candidate naming the file, the lowest-sorting link wins",
entries: []testEntry{
{name: "opt/real.bin", body: "PK\x03\x04"},
{name: "opt/z-sym.jar", link: "real.bin"},
{name: "opt/a-sym.jar", link: "real.bin"},
{name: "opt/m-sym.jar", link: "real.bin"},
},
pattern: "**/*.jar",
want: []string{"/opt/real.bin"},
reason: "the content is the target's, so that is the real path the answer carries",
},
{
name: "distinct files stay distinct",
entries: []testEntry{
{name: "opt/one.jar", body: "one"},
{name: "opt/two.jar", body: "two"},
{name: "opt/one-sym.jar", link: "one.jar"},
},
pattern: "**/*.jar",
want: []string{"/opt/one.jar", "/opt/two.jar"},
reason: "collapsing is per file, not per pattern",
},
{
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/real.jar"},
reason: "it names no content",
},
{
name: "two dangling links are two paths, not one",
entries: []testEntry{
{name: "opt/a.jar", link: "missing.jar"},
{name: "opt/b.jar", link: "missing.jar"},
},
pattern: "**/*.jar",
want: nil,
reason: "a link to nothing is not a file",
},
{
name: "a chain of links collapses onto the file at its end",
entries: []testEntry{
{name: "opt/real.jar", body: "PK\x03\x04"},
{name: "opt/mid.jar", link: "real.jar"},
{name: "opt/outer.jar", link: "mid.jar"},
},
pattern: "**/*.jar",
want: []string{"/opt/real.jar"},
reason: "following one hop at a time reaches the same file from every path",
},
{
name: "a cycle resolves to nothing rather than looping",
entries: []testEntry{
{name: "opt/a.jar", link: "b.jar"},
{name: "opt/b.jar", link: "a.jar"},
},
pattern: "**/*.jar",
want: nil,
reason: "neither reaches a file",
},
{
name: "a hard link resolves from the archive root, not from its own directory",
entries: []testEntry{
{name: "opt/real.jar", body: "PK\x03\x04"},
{name: "opt/nested/hard.jar", link: "opt/real.jar", hard: true},
},
pattern: "**/*.jar",
want: []string{"/opt/real.jar"},
reason: "read as relative it would resolve to opt/nested/opt/real.jar and dangle",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := resolverFrom(t, tt.entries...)
assert.Equal(t, tt.want, globPaths(t, r, tt.pattern), tt.reason)
})
}
}
func TestResolver_collapsedLocationReadsTheTarget(t *testing.T) {
r := resolverFrom(t,
testEntry{name: "opt/real.bin", body: "PK\x03\x04 real contents"},
testEntry{name: "opt/a-sym.jar", link: "real.bin"},
testEntry{name: "opt/z-sym.jar", link: "real.bin"},
)
locations, err := r.FilesByGlob("**/*.jar")
require.NoError(t, err)
require.Len(t, locations, 1)
assert.Equal(t, "/opt/real.bin", locations[0].LocalPath(), "content comes from the file")
assert.Equal(t, "outer.jar!/opt/a-sym.jar", locations[0].AccessPath, "the path searched by is the one matched")
reader, err := r.FileContentsByLocation(locations[0])
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, reader.Close()) })
body, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "PK\x03\x04 real contents", string(body),
"a location standing for a link must still read the file's bytes, not the link's none")
}
func TestResolver_fullPathsAreTrimmedToEntryPaths(t *testing.T) {
// catalogers build queries from the full paths the resolver hands out, e.g. path.Dir plus a sibling
r := resolverOver(t, 1<<20, map[string]string{"pom.xml": "a", "lib/dep.jar": "b"})
locations, err := r.FilesByPath("outer.jar!/lib/dep.jar")
require.NoError(t, err)
require.Len(t, locations, 1)
assert.Equal(t, "outer.jar!/lib/dep.jar", locations[0].AccessPath)
locations, err = r.FilesByPath(path.Join(path.Dir(locations[0].AccessPath), "../pom.xml"))
require.NoError(t, err)
require.Len(t, locations, 1, "a sibling at the archive root is reached through the full path")
assert.Equal(t, "outer.jar!/pom.xml", locations[0].FullNestedPath())
assert.True(t, r.HasPath(path.Dir("outer.jar!/pom.xml")), "the directory of a root entry is the root")
assert.True(t, r.HasPath("lib/dep.jar"), "an entry path is still accepted")
locations, err = r.FilesByGlob("outer.jar!/**/*.jar")
require.NoError(t, err)
require.Len(t, locations, 1)
}
func TestResolver_danglingLinkHasNoContent(t *testing.T) {
r := resolverFrom(t, testEntry{name: "opt/gone.jar", link: "missing.jar"})
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].LocalPath())
_, 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, "outer.jar!/opt/missing.jar", metadata.LinkDestination,
"where it pointed is still reported, resolved against the link's own directory")
}
func TestResolver_mimeTypeAnswersOnlyWithFiles(t *testing.T) {
r := resolverFrom(t,
testEntry{name: "opt/real.jar", body: "PK\x03\x04 zip contents here"},
testEntry{name: "opt/sym.jar", link: "real.jar"},
testEntry{name: "opt/hard.jar", link: "opt/real.jar", hard: true},
testEntry{name: "opt/gone.jar", link: "missing.jar"},
testEntry{name: "opt/notes.txt", body: "hello world"},
)
byMIME := func(types ...string) []string {
t.Helper()
locations, err := r.FilesByMIMEType(types...)
require.NoError(t, err)
return localPaths(locations)
}
assert.Equal(t, []string{"/opt/real.jar"}, byMIME("application/zip"),
"the file answers, not the two links to it")
assert.Equal(t, []string{"/opt/notes.txt"}, byMIME("text/plain"))
assert.Empty(t, byMIME(""),
"a link sniffs to no type, and must not be reachable by asking for that")
assert.Equal(t, []string{"/opt/notes.txt", "/opt/real.jar"}, byMIME("application/zip", "text/plain"))
}
// AllLocations enumerates what the archive holds rather than searching for files: the file metadata
// cataloger records a row per path, links included
func TestResolver_allLocationsKeepsEveryPath(t *testing.T) {
r := resolverFrom(t,
testEntry{name: "opt/real.jar", body: "PK\x03\x04"},
testEntry{name: "opt/sym.jar", link: "real.jar"},
testEntry{name: "opt/gone.jar", link: "missing.jar"},
)
var got []string
for l := range r.AllLocations(context.Background()) {
got = append(got, l.LocalPath())
}
assert.Equal(t, []string{"/opt/gone.jar", "/opt/real.jar", "/opt/sym.jar"}, got,
"every path the archive names, links included")
}
func TestResolver_add_anEntryOverTheCapIsSkippedAndReadNoFurther(t *testing.T) {
// one entry of an overlapping-entry bomb can inflate without bound; it must stop at the cap, cost
// the archive only that entry, and say so
orig := maxEntryBytes
maxEntryBytes = 1024
t.Cleanup(func() { maxEntryBytes = orig })
r, _ := resolverIn(t, memCharge(-1))
endless := &countingReader{r: zeroSource{}}
require.NoError(t, r.add(tar.Header{Name: "bomb.bin", Typeflag: tar.TypeReg, Mode: 0o644}, endless))
require.NoError(t, r.add(tar.Header{Name: "ok.txt", Typeflag: tar.TypeReg, Mode: 0o644}, strings.NewReader("ok")))
r.finish()
assert.LessOrEqual(t, endless.n, 1024+copyChunkSize, "reading stops just past the cap")
assert.ErrorIs(t, r.Truncated, errEntryTooLarge)
assert.ErrorContains(t, r.Truncated, "per-entry size cap of 1.0 KiB")
locations, err := r.FilesByGlob("**")
require.NoError(t, err)
assert.Equal(t, []string{"/ok.txt"}, localPaths(locations))
}
type countingReader struct {
r io.Reader
n int
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += n
return n, err
}
+217
View File
@@ -0,0 +1,217 @@
package archive
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"github.com/anchore/syft/internal/log"
)
// copyChunkSize is how many bytes are read and charged at a time, bounding how far a charge runs
// ahead of what is actually held.
const copyChunkSize = 32 * 1024
// spillFilePattern names the one file an archive's content spills into, as in os.CreateTemp.
const spillFilePattern = "archive-spill-*"
// ReaderAtSeeker is the random access an archive format needs: a zip is read from its central
// directory at the end of the stream.
type ReaderAtSeeker interface {
io.Reader
io.ReaderAt
io.Seeker
}
// blob is one piece of content the resolver holds: in memory, or at an offset in its spill file once
// memory refused it. Moving changes nothing that points at the blob.
type blob struct {
mem []byte // content while held in memory
off int64 // where content starts in the spill file once written there
size int64
}
// put reads content in full into b, since archive readers are sequential. Each chunk is charged before it
// is held, so a stream larger than the memory limit is never held in full. Reaching the disk limit
// returns ErrDiskLimitReached; what landed in the file before that stays there and stays charged.
func (r *Resolver) put(b *blob, content io.Reader) error {
if r.chunk == nil {
r.chunk = make([]byte, copyChunkSize)
}
for {
n, readErr := content.Read(r.chunk)
if n > 0 {
// content read through a meteredReader was charged as it was decoded
if !r.metered && !r.budget.take(int64(n)) {
return errBudgetSpent
}
if err := r.write(b, r.chunk[:n]); err != nil {
return err
}
}
if readErr == nil {
continue
}
if errors.Is(readErr, io.EOF) {
break
}
return readErr
}
return nil
}
// meteredReader charges the decompression budget for every byte read through it and stops once ctx is
// done, so nothing a decoder produces gets around the budget.
type meteredReader struct {
ctx context.Context
reader io.Reader
budget *Budget
}
func (m *meteredReader) Read(p []byte) (int, error) {
if err := m.ctx.Err(); err != nil {
return 0, err
}
n, err := m.reader.Read(p)
if n > 0 && !m.budget.take(int64(n)) {
return 0, errBudgetSpent
}
return n, err
}
// write appends p to b: into memory while the memory limit admits it, otherwise into the spill file
// after everything held in memory is moved there first.
func (r *Resolver) write(b *blob, p []byte) error {
n := int64(len(p))
onDisk := b.mem == nil && b.size > 0
if !onDisk {
if r.charge.memory(n) {
if b.mem == nil {
r.held = append(r.held, b)
}
b.mem = append(b.mem, p...)
b.size += n
return nil
}
if err := r.spill(); err != nil {
return err
}
}
if err := r.openFile(); err != nil {
return err
}
if !r.charge.disk(n) {
return ErrDiskLimitReached
}
if b.size == 0 {
b.off = r.written
}
// b is always the blob most recently written, so it ends where the file does
w, err := r.file.WriteAt(p, r.written)
r.written += int64(w)
b.size += int64(w)
r.charge.refundDisk(n - int64(w))
if err != nil {
return fmt.Errorf("unable to write archive content to disk: %w", err)
}
return nil
}
// spill moves every blob held in memory into the spill file, charging the disk limit as bytes land
// and refunding the memory they leave.
func (r *Resolver) spill() error {
if len(r.held) == 0 {
return nil
}
if err := r.openFile(); err != nil {
return err
}
log.WithFields("archive", r.archiveCoordinates.FullNestedPath(), "held", len(r.held), "bytes", r.written).
Debug("archive content does not fit in memory; writing it to disk")
for len(r.held) > 0 {
b := r.held[0]
n := int64(len(b.mem))
if !r.charge.disk(n) {
return ErrDiskLimitReached
}
if _, err := r.file.WriteAt(b.mem, r.written); err != nil {
return fmt.Errorf("unable to write archive content to disk: %w", err)
}
b.off, b.mem = r.written, nil
r.written += n
r.charge.refundMemory(n)
r.held = r.held[1:]
}
return nil
}
func (r *Resolver) openFile() error {
if r.file != nil {
return nil
}
var err error
if r.tempDir != nil {
r.file, r.remove, err = r.tempDir.NewFile(spillFilePattern)
} else {
r.file, err = os.CreateTemp("", spillFilePattern)
r.remove = func() {
if err := os.Remove(r.file.Name()); err != nil {
log.WithFields("path", r.file.Name(), "error", err).Trace("unable to remove archive temp file")
}
}
}
if err != nil {
return fmt.Errorf("unable to create archive temp file: %w", err)
}
return nil
}
// discard drops a blob's bytes held in memory and refunds them. Bytes it holds on disk stay until
// Cleanup.
func (r *Resolver) discard(b *blob) {
for i, held := range r.held {
if held == b {
r.held = append(r.held[:i], r.held[i+1:]...)
break
}
}
r.charge.refundMemory(int64(len(b.mem)))
b.mem, b.size = nil, 0
}
// open returns a reader over a blob's content, wherever it is. The reader supports Read, Seek and
// ReadAt, so a nested archive can be read where it lies.
func (r *Resolver) open(b *blob) ReaderAtSeeker {
switch {
case b.mem != nil:
return bytes.NewReader(b.mem)
case b.size == 0:
return bytes.NewReader(nil)
default:
return io.NewSectionReader(r.file, b.off, b.size)
}
}
// releaseStorage refunds every blob held in memory and closes, removes and refunds the spill file.
// Readers from open must not be used after this. Safe to call more than once.
func (r *Resolver) releaseStorage() {
for _, b := range r.held {
r.charge.refundMemory(int64(len(b.mem)))
b.mem = nil
}
r.held = nil
if r.file == nil {
return
}
if err := r.file.Close(); err != nil {
log.WithFields("archive", r.archiveCoordinates.FullNestedPath(), "error", err).Trace("unable to close archive temp file")
}
r.remove()
r.charge.refundDisk(r.written)
r.file, r.remove, r.written = nil, nil, 0
}
+276
View File
@@ -0,0 +1,276 @@
package archive
import (
"archive/zip"
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResolver_put_holdsSmallContentInMemory(t *testing.T) {
s, dir := resolverIn(t, memCharge(1024))
var b blob
require.NoError(t, s.put(&b, bytes.NewReader([]byte("hello"))))
assert.Equal(t, int64(5), s.heldInMemory())
assert.Empty(t, filesIn(t, dir))
assert.Equal(t, "hello", readEntry(t, s.open(&b)))
}
func TestResolver_put_spillsToDiskAndKeepsTheSameBlobs(t *testing.T) {
// nodes pointing at a blob are not rebuilt when its content moves
s, dir := resolverIn(t, memCharge(8))
var first, second blob
require.NoError(t, s.put(&first, bytes.NewReader([]byte("aaaa"))))
require.Equal(t, int64(4), s.heldInMemory())
require.NoError(t, s.put(&second, bytes.NewReader([]byte("bbbbbb"))))
assert.Zero(t, s.heldInMemory(), "everything moves out, not just the blob that did not fit")
assert.NotEmpty(t, spillFile(t, dir))
assert.Equal(t, int64(10), s.written)
assert.Equal(t, "aaaa", readEntry(t, s.open(&first)))
assert.Equal(t, "bbbbbb", readEntry(t, s.open(&second)))
}
func TestResolver_put_threeStateLimits(t *testing.T) {
body := []byte(strings.Repeat("e", 500))
t.Run("a zero memory limit sends everything to disk", func(t *testing.T) {
charge := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: 10_000}).charge()
s, dir := resolverIn(t, charge)
var b blob
require.NoError(t, s.put(&b, bytes.NewReader(body)))
assert.Zero(t, s.heldInMemory())
assert.NotEmpty(t, spillFile(t, dir))
assert.Equal(t, body, []byte(readEntry(t, s.open(&b))))
mem, disk := charge.held()
assert.Zero(t, mem)
assert.Equal(t, int64(500), disk)
})
t.Run("a negative memory limit holds content regardless of how much is already held", func(t *testing.T) {
charge := NewLimiter(Limits{MaxMemoryBytes: -1, MaxDiskBytes: 10_000}).charge()
require.True(t, charge.memory(1_000_000))
s, dir := resolverIn(t, charge)
var b blob
require.NoError(t, s.put(&b, bytes.NewReader(body)))
assert.Empty(t, filesIn(t, dir))
mem, disk := charge.held()
assert.Equal(t, int64(1_000_000+len(body)), mem)
assert.Zero(t, disk)
})
t.Run("a zero disk limit still holds what memory admits", func(t *testing.T) {
charge := NewLimiter(Limits{MaxMemoryBytes: 10_000, MaxDiskBytes: 0}).charge()
s, dir := resolverIn(t, charge)
var b blob
require.NoError(t, s.put(&b, bytes.NewReader(body)))
assert.Empty(t, filesIn(t, dir))
mem, disk := charge.held()
assert.Equal(t, int64(len(body)), mem)
assert.Zero(t, disk)
})
t.Run("a negative disk limit writes with no ceiling", func(t *testing.T) {
s, dir := resolverIn(t, NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: -1}).charge())
var b blob
require.NoError(t, s.put(&b, bytes.NewReader(body)))
assert.NotEmpty(t, spillFile(t, dir))
assert.Equal(t, body, []byte(readEntry(t, s.open(&b))))
})
t.Run("a nil charge holds everything in memory", func(t *testing.T) {
s, dir := resolverIn(t, nil)
var b blob
require.NoError(t, s.put(&b, bytes.NewReader(body)))
assert.Empty(t, filesIn(t, dir))
assert.Equal(t, body, []byte(readEntry(t, s.open(&b))))
})
}
func TestResolver_put_bytesReadBeforeTheLimitRefusedAreNotLost(t *testing.T) {
body := []byte(strings.Repeat("b", 250))
charge := NewLimiter(Limits{MaxMemoryBytes: 100, MaxDiskBytes: 10_000}).charge()
s, _ := resolverIn(t, charge)
var b blob
require.NoError(t, s.put(&b, bytes.NewReader(body)))
assert.Equal(t, body, []byte(readEntry(t, s.open(&b))))
mem, disk := charge.held()
assert.Zero(t, mem, "nothing stays charged to a limit that refused")
assert.Equal(t, int64(250), disk)
}
// peakTrackingReader records the highest memory charge observed while being read.
type peakTrackingReader struct {
r io.Reader
charge *charge
peak int64
}
func (p *peakTrackingReader) Read(b []byte) (int, error) {
n, err := p.r.Read(b)
if mem, _ := p.charge.held(); mem > p.peak {
p.peak = mem
}
return n, err
}
func TestResolver_put_neverBuffersBeyondTheMemoryLimit(t *testing.T) {
const limit = int64(3 * copyChunkSize)
body := []byte(strings.Repeat("c", 10*copyChunkSize))
charge := NewLimiter(Limits{MaxMemoryBytes: limit, MaxDiskBytes: int64(len(body)) + 1}).charge()
tracked := &peakTrackingReader{r: bytes.NewReader(body), charge: charge}
s, _ := resolverIn(t, charge)
var b blob
require.NoError(t, s.put(&b, tracked))
assert.Equal(t, body, []byte(readEntry(t, s.open(&b))))
assert.LessOrEqual(t, tracked.peak, limit, "memory charged must never exceed the limit, even transiently")
mem, disk := charge.held()
assert.Zero(t, mem)
assert.Equal(t, int64(len(body)), disk)
}
func TestResolver_put_diskLimitIsTerminal(t *testing.T) {
tests := []struct {
name string
limits Limits
}{
{"a disk limit that is exceeded", Limits{MaxDiskBytes: 100}},
{"a zero disk limit", Limits{MaxMemoryBytes: 10, MaxDiskBytes: 0}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
charge := NewLimiter(tt.limits).charge()
s, dir := resolverIn(t, charge)
var b blob
err := s.put(&b, bytes.NewReader([]byte(strings.Repeat("d", 500))))
require.ErrorIs(t, err, ErrDiskLimitReached)
s.Cleanup()
assert.Empty(t, filesIn(t, dir), "cleanup removes the partial file")
mem, disk := charge.held()
assert.Zero(t, mem)
assert.Zero(t, disk, "and refunds it")
})
}
}
func TestResolver_cleanupRefundsEverythingAndIsIdempotent(t *testing.T) {
charge := NewLimiter(Limits{MaxMemoryBytes: 8, MaxDiskBytes: -1}).charge()
s, dir := resolverIn(t, charge)
var spilled, held blob
require.NoError(t, s.put(&spilled, bytes.NewReader([]byte("spilled to disk"))))
require.NoError(t, s.put(&held, bytes.NewReader([]byte("held"))))
mem, disk := charge.held()
require.Equal(t, int64(4), mem)
require.Equal(t, int64(15), disk)
require.Equal(t, "held", readEntry(t, s.open(&held)))
s.Cleanup()
assert.Empty(t, filesIn(t, dir))
mem, disk = charge.held()
assert.Zero(t, mem)
assert.Zero(t, disk)
_, err := io.ReadAll(s.open(&spilled))
assert.Error(t, err, "readers over the spill file are no longer valid")
s.Cleanup()
mem, disk = charge.held()
assert.Zero(t, mem, "a second cleanup refunds nothing twice")
assert.Zero(t, disk)
}
func TestResolver_put_discardRefundsWhatIsHeldInMemory(t *testing.T) {
charge := memCharge(1024)
s, _ := resolverIn(t, charge)
var keep, drop blob
require.NoError(t, s.put(&keep, bytes.NewReader([]byte("keep"))))
require.NoError(t, s.put(&drop, bytes.NewReader([]byte("dropped"))))
s.discard(&drop)
mem, _ := charge.held()
assert.Equal(t, int64(4), mem)
assert.Equal(t, int64(4), s.heldInMemory())
assert.Empty(t, readEntry(t, s.open(&drop)))
assert.Equal(t, "keep", readEntry(t, s.open(&keep)))
}
func TestResolver_filesystemCostDoesNotTrackEntryCount(t *testing.T) {
small, smallRoot := extractedResolver(t, manyEntryZip(t, 2), spillingLimits)
large, largeRoot := extractedResolver(t, manyEntryZip(t, 5000), spillingLimits)
assert.Len(t, filesIn(t, smallRoot), 1)
assert.Len(t, filesIn(t, largeRoot), 1, "5000 entries cost the same filesystem entries as 2")
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)
assert.Equal(t, int64(5000), large.written)
}
func TestResolver_anEntryAtTheEndIsReadBySeeking(t *testing.T) {
// the reader over an entry is a bounded window at its offset, not a re-read of what precedes it
const filler = 64 * 1024
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for i := range 8 {
w, err := zw.Create(fmt.Sprintf("filler%d.bin", i))
require.NoError(t, err)
_, err = w.Write(bytes.Repeat([]byte{byte(i)}, filler))
require.NoError(t, err)
}
last, err := zw.Create("zz-last.txt")
require.NoError(t, err)
wanted := strings.Repeat("last", 64)
_, err = last.Write([]byte(wanted))
require.NoError(t, err)
require.NoError(t, zw.Close())
r, root := extractedResolver(t, buf.Bytes(), spillingLimits)
body, err := os.ReadFile(filepath.Join(root, spillFile(t, root)))
require.NoError(t, err)
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")
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)
head := make([]byte, 8)
_, err = reader.ReadAt(head, 0)
require.NoError(t, err)
assert.Equal(t, wanted[:8], string(head))
}
+194
View File
@@ -0,0 +1,194 @@
package archive
import (
"context"
"path"
"slices"
"strings"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
)
// Traversal describes the archive whose contents a cataloger is running against. The archive
// cataloger task puts one on the context before running catalogers inside an archive, so a cataloger
// such as java can describe the archive it is inside without owning the recursion.
type Traversal struct {
// Location is the archive file's location within its parent filesystem, including the access path
// it was reached by, so VirtualPath(Location) is the chain from the scan root as seen through that
// access path. The files inside it extend its real coordinates instead (see ContainerPath).
Location file.Location
// Digests are of the archive file itself, taken during extraction; a cataloger running inside the
// archive cannot compute them since the archive is not inside itself.
Digests []file.Digest
// Parent is the traversal of the archive this one is nested in, nil for an archive at the scan root.
Parent *Traversal
// Packages are those the catalogers found describing this archive (located at it), filled in once
// they have run. Catalogers inside a nested archive read them through Parent, as java does to link a
// nested jar to the jar that holds it.
Packages []pkg.Package
}
// FullPathIn is the full path of the entry at entryPath inside the archive whose full path is
// containerPath, or entryPath itself for a blank containerPath (the scan root).
func FullPathIn(containerPath, entryPath string) string {
if containerPath == "" {
return entryPath
}
// cleaning an already clean path allocates nothing
return containerPath + file.ArchivePathSeparator + strings.TrimPrefix(path.Clean(entryPath), "/")
}
// OwnerContainers are the containers a package owns files within: the container of each of its
// locations, each once, as coordinates whose FullPath is the container's full path (blank at the scan
// root). A package without locations owns files at the scan root. See OwnedCoordinates.
func OwnerContainers(p pkg.Package) []file.Coordinates {
var out []file.Coordinates
for _, l := range p.Locations.ToUnorderedSlice() {
c := file.Coordinates{FileSystemID: l.FileSystemID}
if container := ContainerPath(l.Coordinates); container != "" {
c.RealPath, c.FullPath = l.RealPath, container
}
if !slices.Contains(out, c) {
out = append(out, c)
}
}
if len(out) == 0 {
out = append(out, file.Coordinates{})
}
return out
}
// OwnedCoordinates are the coordinates of the file at ownedPath within container, one of
// OwnerContainers. At the scan root the path may still need resolving (it may pass through links).
func OwnedCoordinates(container file.Coordinates, ownedPath string) file.Coordinates {
if container.FullPath == "" {
return file.Coordinates{RealPath: ownedPath, FileSystemID: container.FileSystemID}
}
return file.Coordinates{
RealPath: container.RealPath,
FileSystemID: container.FileSystemID,
FullPath: FullPathIn(container.FullPath, ownedPath),
}
}
// FullTraversalArchivePath is the archive's own full path, which is the ContainerPath of every file inside it. Outside
// any archive (a nil Traversal) it is blank, as for files on the scanned filesystem.
func (t *Traversal) FullTraversalArchivePath() string {
if t == nil {
return ""
}
return t.Location.FullNestedPath()
}
// ContainerPath returns the full path of the archive directly holding the file at c, or blank for a
// file on the scanned filesystem, whether or not its FullPath is set.
func ContainerPath(c file.Coordinates) string {
entries, ok := archiveEntries(c)
if !ok {
return ""
}
if i := strings.LastIndex(entries, file.ArchivePathSeparator); i >= 0 {
return c.RealPath + file.ArchivePathSeparator + entries[:i]
}
return c.RealPath
}
// ContainingArchive returns the coordinates of the archive directly holding the file at c, reporting false
// for a file on the scanned filesystem.
func ContainingArchive(c file.Coordinates) (file.Coordinates, bool) {
containerPath := ContainerPath(c)
if containerPath == "" {
return file.Coordinates{}, false
}
container := file.Coordinates{RealPath: c.RealPath, FileSystemID: c.FileSystemID}
if containerPath != c.RealPath {
container.FullPath = containerPath
}
return container, true
}
// archiveEntries is the "!/"-delimited chain of entry paths after RealPath, reporting false for a file
// not inside an archive. RealPath is split off first, so a "!/" in it is not taken for a boundary.
func archiveEntries(c file.Coordinates) (string, bool) {
remain, ok := strings.CutPrefix(c.FullNestedPath(), c.RealPath)
if !ok {
return "", false
}
return strings.CutPrefix(remain, file.ArchivePathSeparator)
}
// VirtualPath returns the colon-delimited chain of archives from the scan root to loc, ending in loc
// itself as accessed: "app.war:WEB-INF/lib/dep.jar". Outside any archive it is the location's path
// unchanged. In an entry name '%' is escaped as "%25" and then ':' as "%3A", so splitting on ':'
// recovers the archive boundaries and no two entry names share an escaped form. Leading slashes are
// dropped to match the java cataloger, which joins slash-less zip entry names onto the containing
// archive's path.
func VirtualPath(loc file.Location) string {
accessPath := loc.Path()
entries, ok := archiveEntries(loc.Coordinates)
if !ok {
return accessPath
}
segments := strings.Split(entries, file.ArchivePathSeparator)
if rest, ok := strings.CutPrefix(accessPath, ContainerPath(loc.Coordinates)); ok && strings.HasPrefix(rest, file.ArchivePathSeparator) {
accessPath = rest[len(file.ArchivePathSeparator):]
}
segments[len(segments)-1] = accessPath
for i, s := range segments {
segments[i] = strings.TrimPrefix(escapeEntryPath(s), "/")
}
return loc.RealPath + ":" + strings.Join(segments, ":")
}
var entryPathEscaper = strings.NewReplacer("%", "%25", ":", "%3A")
func escapeEntryPath(p string) string {
if !strings.ContainsAny(p, "%:") {
return p
}
return entryPathEscaper.Replace(p)
}
type traversalCtxKey struct{}
func WithTraversal(ctx context.Context, t *Traversal) context.Context {
return context.WithValue(ctx, traversalCtxKey{}, t)
}
// TraversalFromContext returns the archive traversal on the context, or nil outside an archive.
func TraversalFromContext(ctx context.Context) *Traversal {
t, _ := ctx.Value(traversalCtxKey{}).(*Traversal)
return t
}
type budgetCtxKey struct{}
// WithBudget sets the decompression budget that Extract spends from, for this archive and every archive
// extracted beneath it.
func WithBudget(ctx context.Context, b *Budget) context.Context {
return context.WithValue(ctx, budgetCtxKey{}, b)
}
func budgetFromContext(ctx context.Context) *Budget {
b, _ := ctx.Value(budgetCtxKey{}).(*Budget)
return b
}
type nestedCatalogingCtxKey struct{}
// WithNestedCataloging marks the context of a scan whose archive cataloger task will extract every
// archive and run the catalogers inside it. syft.CreateSBOM sets it before any task runs, so a
// cataloger that would otherwise open archives itself can leave them to the task instead.
func WithNestedCataloging(ctx context.Context) context.Context {
return context.WithValue(ctx, nestedCatalogingCtxKey{}, true)
}
// NestedCatalogingEnabled reports whether the archive cataloger task is extracting archives in this scan.
func NestedCatalogingEnabled(ctx context.Context) bool {
enabled, _ := ctx.Value(nestedCatalogingCtxKey{}).(bool)
return enabled
}
+113
View File
@@ -0,0 +1,113 @@
package archive
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/file"
)
func TestTraversalContextRoundTrip(t *testing.T) {
assert.Nil(t, TraversalFromContext(context.Background()))
trav := &Traversal{Location: file.NewLocation("app.war")}
ctx := WithTraversal(context.Background(), trav)
require.Same(t, trav, TraversalFromContext(ctx))
child := &Traversal{Location: locationIn("app.war", "WEB-INF/lib/dep.jar")}
ctx = WithTraversal(ctx, child)
require.Same(t, child, TraversalFromContext(ctx))
}
// locationIn is a location as the Resolver returns it: the outermost archive's real path, and the chain
// of entry paths within it.
func locationIn(realPath, archivePath string) file.Location {
return file.NewLocationFromCoordinates(file.Coordinates{RealPath: realPath, FullPath: realPath + file.ArchivePathSeparator + archivePath})
}
func TestContainerPath(t *testing.T) {
assert.Equal(t, "", ContainerPath(file.NewCoordinates("/app.war", "")))
assert.Equal(t, "", ContainerPath(file.Coordinates{RealPath: "/app.war", FullPath: "/app.war"}), "a top-level file whose FullPath is set")
assert.Equal(t, "", ContainerPath(file.Coordinates{RealPath: "/odd!/name"}), "a separator in a top-level real path is not an archive boundary")
assert.Equal(t, "", ContainerPath(file.Coordinates{RealPath: "/odd!/name", FullPath: "/odd!/name"}), "nor when FullPath is set")
assert.Equal(t, "/odd!/name.zip", ContainerPath(locationIn("/odd!/name.zip", "a.txt").Coordinates))
assert.Equal(t, "/odd!/name.zip!/b.jar", ContainerPath(locationIn("/odd!/name.zip", "b.jar!/a.txt").Coordinates))
assert.Equal(t, "/app.war", ContainerPath(locationIn("/app.war", "WEB-INF/lib/dep.jar").Coordinates))
assert.Equal(t, "/app.war!/WEB-INF/lib/dep.jar",
ContainerPath(locationIn("/app.war", "WEB-INF/lib/dep.jar!/META-INF/MANIFEST.MF").Coordinates))
}
func TestVirtualPath(t *testing.T) {
tests := []struct {
name string
location file.Location
expected string
}{
{
name: "outside an archive returns the path unchanged",
location: file.NewLocation("/WEB-INF/lib/dep.jar"),
expected: "/WEB-INF/lib/dep.jar",
},
{
name: "outside an archive preserves a relative path",
location: file.NewLocation("some/dir/example.jar"),
expected: "some/dir/example.jar",
},
{
name: "outside an archive with FullPath set returns the path unchanged",
location: file.NewLocationFromCoordinates(file.Coordinates{RealPath: "/odd!/name.jar", FullPath: "/odd!/name.jar"}),
expected: "/odd!/name.jar",
},
{
name: "single level trims leading slash",
location: locationIn("app.war", "/WEB-INF/lib/dep.jar"),
expected: "app.war:WEB-INF/lib/dep.jar",
},
{
name: "single level with slash-less entry",
location: locationIn("app.war", "WEB-INF/lib/dep.jar"),
expected: "app.war:WEB-INF/lib/dep.jar",
},
{
name: "multi-level chain",
location: locationIn("app.war", "WEB-INF/lib/dep.jar!/inner/most.jar"),
expected: "app.war:WEB-INF/lib/dep.jar:inner/most.jar",
},
{
// a colon in the entry name must not be mistaken for a chain delimiter
name: "colon in entry path is escaped",
location: locationIn("app.war", "WEB-INF/lib/dep:special.jar"),
expected: "app.war:WEB-INF/lib/dep%3Aspecial.jar",
},
{
name: "every colon in the entry path is escaped",
location: locationIn("app.war", "lib/a:b:c.jar"),
expected: "app.war:lib/a%3Ab%3Ac.jar",
},
{
// or the literal text %3A would collide with an escaped colon
name: "the escape character is itself escaped",
location: locationIn("outer.zip", "a%3Ab.jar"),
expected: "outer.zip:a%253Ab.jar",
},
{
name: "access path is preferred over real path",
location: file.NewVirtualLocation("/real/dep.jar", "/link/dep.jar"),
expected: "/link/dep.jar",
},
{
name: "access path within an archive replaces the last entry",
location: file.NewVirtualLocationFromCoordinates(
file.Coordinates{RealPath: "app.war", FullPath: "app.war!/WEB-INF/lib/real.jar"}, "app.war!/WEB-INF/lib/link.jar"),
expected: "app.war:WEB-INF/lib/link.jar",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, VirtualPath(tt.location))
})
}
}