mirror of
https://github.com/anchore/syft.git
synced 2026-11-01 15:06:36 +01:00
chore: revert custom index in favor of key_Split
Signed-off-by: Keith Zantow <keith.zantow@anchore.com>
This commit is contained in:
+30
-53
@@ -6,19 +6,17 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
intFile "github.com/anchore/syft/internal/file"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
)
|
||||
|
||||
// copyChunkSize is how much is read, charged and written at a time. It bounds how far a charge can
|
||||
// be ahead of what has actually been written, and matches io.Copy's own buffer size.
|
||||
// copyChunkSize is the read/charge/write chunk size, bounding how far a charge runs ahead of what is
|
||||
// written. Matches io.Copy's buffer size.
|
||||
const copyChunkSize = 32 * 1024
|
||||
|
||||
// ReaderAtSeeker is the random access an archive format needs. A zip reads its central directory
|
||||
// from the end of the stream, so a plain io.Reader will not do; both a *bytes.Reader over content
|
||||
// held in memory and an *os.File over content overflowed to disk satisfy it.
|
||||
// ReaderAtSeeker is the random access an archive format needs: a zip reads its central directory from
|
||||
// the end of the stream, so a plain io.Reader will not do.
|
||||
type ReaderAtSeeker interface {
|
||||
io.Reader
|
||||
io.ReaderAt
|
||||
@@ -26,9 +24,6 @@ type ReaderAtSeeker interface {
|
||||
}
|
||||
|
||||
// Content is one archive's bytes, wherever the limiter routed them.
|
||||
//
|
||||
// Name is the archive's file name and is used for format identification only - never to open
|
||||
// anything - so content held in memory needs no file on disk at all.
|
||||
type Content struct {
|
||||
Name string
|
||||
Reader ReaderAtSeeker
|
||||
@@ -45,43 +40,22 @@ func (c Content) Close() error {
|
||||
return c.closer.Close()
|
||||
}
|
||||
|
||||
// OpenFileContent opens an archive already on disk as Content. The caller closes it.
|
||||
func OpenFileContent(path string) (Content, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return Content{}, err
|
||||
}
|
||||
return Content{Name: filepath.Base(path), Reader: f, closer: f}, nil
|
||||
}
|
||||
|
||||
// holdContent routes one archive's bytes: held in memory and charged to the memory limit while that
|
||||
// limit admits them, overflowed into workDir and charged to the disk limit once it does not. Where the
|
||||
// boundary falls is decided entirely by the memory limit - there is no separate configured size at
|
||||
// which overflowing begins.
|
||||
// holdContent holds one archive's bytes in memory while the memory limit admits them, and spills into
|
||||
// workDir against the disk limit once it does not. Reads and charges copyChunkSize at a time so an
|
||||
// oversized archive is never held past the limit. A zero memory limit spills in full; a negative one
|
||||
// never refuses.
|
||||
//
|
||||
// Content is read and charged incrementally, copyChunkSize at a time, rather than read speculatively
|
||||
// into a buffer ahead of any charge: nothing is ever held beyond what the memory limit would admit at
|
||||
// that moment, so a single archive far larger than the limit is never buffered past it. A zero
|
||||
// memory limit refuses every chunk, so its content overflows to disk in full; a negative memory limit
|
||||
// never refuses, so nothing overflows for want of memory.
|
||||
//
|
||||
// When the limit refuses a chunk part way through, what has already been read leads the overflow rather
|
||||
// than being re-read from the start or lost, and the memory it was charged against is refunded since
|
||||
// it is about to be charged to disk instead.
|
||||
//
|
||||
// Memory pressure degrades to disk - content that would take memory in use past the memory limit
|
||||
// overflows instead of being refused, because there is somewhere for it to go. The disk limit is
|
||||
// terminal: there is nowhere further, so content that would take it past the disk limit yields
|
||||
// ErrDiskLimitReached and the archive is skipped. Nothing waits for capacity; see ExtractToResolver.
|
||||
func holdContent(r io.Reader, workDir, name string, charge *Charge, notify Notify) (Content, error) {
|
||||
// On a refused chunk, held bytes and the rest of the stream spill without re-reading, and the memory
|
||||
// charge is refunded as it is charged to disk. The disk limit is terminal: exceeding it yields
|
||||
// ErrDiskLimitReached and the archive is skipped (see ExtractToResolver).
|
||||
func holdContent(r io.Reader, workDir *WorkDir, name string, charge *Charge, notify Notify) (Content, error) {
|
||||
var held bytes.Buffer
|
||||
chunk := make([]byte, copyChunkSize)
|
||||
for {
|
||||
n, readErr := r.Read(chunk)
|
||||
if n > 0 {
|
||||
if !charge.Memory(int64(n)) {
|
||||
// the memory limit refused this chunk: overflow what is already held plus this chunk
|
||||
// plus whatever remains, without re-reading from the start
|
||||
// spill held bytes, this chunk, and the rest without re-reading
|
||||
if notify != nil {
|
||||
notify(ContentOverflowed{Archive: name, Bytes: int64(held.Len() + n), Reason: overflowReason(charge)})
|
||||
}
|
||||
@@ -99,20 +73,24 @@ func holdContent(r io.Reader, workDir, name string, charge *Charge, notify Notif
|
||||
}
|
||||
}
|
||||
|
||||
// overflowContent writes an archive's bytes into workDir, charging the disk limit as they land, and
|
||||
// returns a reader over the file positioned at its start.
|
||||
// overflowContent writes an archive's bytes into its work directory, charging the disk limit as they
|
||||
// land, and returns a reader over the file positioned at its start. The work directory is created
|
||||
// here on first write, so an in-memory archive never creates one.
|
||||
//
|
||||
// This is the only filesystem write left in the extraction path - an archive's entries are headers in
|
||||
// one tar and traverse no path - and the name it writes under is derived from where the archive was
|
||||
// found, which inside another archive is text the archive supplied. So the destination is checked the
|
||||
// same two ways an entry's path used to be: lexically, and then against where it really lands on the
|
||||
// filesystem.
|
||||
func overflowContent(r io.Reader, workDir, name string, charge *Charge) (Content, error) {
|
||||
dest, err := intFile.SafeJoin(workDir, name)
|
||||
// This is the only filesystem write in the extraction path, and the name derives from where the
|
||||
// archive was found - attacker-controlled inside another archive - so the destination is checked
|
||||
// both lexically and against where it really lands.
|
||||
func overflowContent(r io.Reader, workDir *WorkDir, name string, charge *Charge) (Content, error) {
|
||||
dir, err := workDir.Path()
|
||||
if err != nil {
|
||||
return Content{}, err
|
||||
}
|
||||
|
||||
dest, err := intFile.SafeJoin(dir, name)
|
||||
if err != nil {
|
||||
return Content{}, fmt.Errorf("refusing to overflow archive content to %q: %w", name, err)
|
||||
}
|
||||
if inside, err := resolvesInsideRoot(workDir, dest); err != nil || !inside {
|
||||
if inside, err := resolvesInsideRoot(dir, dest); err != nil || !inside {
|
||||
return Content{}, fmt.Errorf("refusing to overflow archive content outside its work directory: %q", name)
|
||||
}
|
||||
f, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
@@ -144,10 +122,9 @@ func overflowContent(r io.Reader, workDir, name string, charge *Charge) (Content
|
||||
return Content{Name: name, Reader: f, closer: f}, nil
|
||||
}
|
||||
|
||||
// copyCharged copies src into dst, charging every chunk to the disk limit before it is written, so
|
||||
// what bounds the copy is what actually lands rather than any size the archive declared. It stops at
|
||||
// the first refused charge and says so, leaving the caller to decide whether that truncates one
|
||||
// entry or skips a whole archive.
|
||||
// copyCharged copies src into dst, charging each chunk to the disk limit before writing, so the copy
|
||||
// is bounded by what lands rather than any declared size. It stops at the first refused charge and
|
||||
// reports it; the caller decides whether that truncates one entry or skips the archive.
|
||||
func copyCharged(dst io.Writer, src io.Reader, charge *Charge) (written int64, limitReached bool, err error) {
|
||||
buf := make([]byte, copyChunkSize)
|
||||
for {
|
||||
|
||||
+206
-212
@@ -12,8 +12,212 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHoldContent_heldWhileTheLimitAdmitsIt(t *testing.T) {
|
||||
// an archive small enough to hold needs no file on disk at all
|
||||
workDir := t.TempDir()
|
||||
body := []byte(strings.Repeat("a", 100))
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: 1000, MaxDiskBytes: 1000})
|
||||
charge := limiter.Charge()
|
||||
|
||||
held, err := holdContent(bytes.NewReader(body), WorkDirAt(workDir), "app.zip", charge, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, held.Close()) })
|
||||
|
||||
assert.IsType(t, &bytes.Reader{}, held.Reader)
|
||||
assert.Equal(t, body, readAll(t, held))
|
||||
assert.Empty(t, filesIn(t, workDir), "content held in memory must write nothing to disk")
|
||||
|
||||
mem, disk := charge.held()
|
||||
assert.Equal(t, int64(100), mem)
|
||||
assert.Zero(t, disk)
|
||||
}
|
||||
|
||||
func TestHoldContent_overflowsWhenTheLimitDoesNotAdmitIt(t *testing.T) {
|
||||
// the memory limit alone decides where the boundary falls: an archive that does not fit spills in
|
||||
// full, and the bytes already read to discover that lead the spill rather than being lost
|
||||
workDir := t.TempDir()
|
||||
body := []byte(strings.Repeat("b", 250))
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: 100, MaxDiskBytes: 10_000})
|
||||
charge := limiter.Charge()
|
||||
|
||||
held, err := holdContent(bytes.NewReader(body), WorkDirAt(workDir), "app.zip", charge, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, held.Close()) })
|
||||
|
||||
assert.Equal(t, []string{"app.zip"}, filesIn(t, workDir))
|
||||
assert.Equal(t, body, readAll(t, held),
|
||||
"the bytes read to decide the routing must lead the overflow rather than being lost")
|
||||
|
||||
mem, disk := charge.held()
|
||||
assert.Zero(t, mem, "nothing is charged to a limit that refused")
|
||||
assert.Equal(t, int64(250), disk)
|
||||
}
|
||||
|
||||
// peakTrackingReader records the highest memory charge observed while reading, which is how a test
|
||||
// sees that a large archive was never buffered beyond what the limit admits; the routing decision is
|
||||
// not otherwise observable from outside holdContent.
|
||||
type peakTrackingReader struct {
|
||||
r io.Reader
|
||||
charge *Charge
|
||||
peak int64
|
||||
}
|
||||
|
||||
func TestHoldContent_largeArchiveNeverBuffersBeyondTheLimit(t *testing.T) {
|
||||
// an archive many times the memory limit must spill, and at no point may more than the limit admits
|
||||
// be held while the routing is decided: it is never read speculatively
|
||||
workDir := t.TempDir()
|
||||
const limit = int64(3 * copyChunkSize)
|
||||
body := []byte(strings.Repeat("c", 10*copyChunkSize))
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: limit, MaxDiskBytes: int64(len(body)) + 1})
|
||||
charge := limiter.Charge()
|
||||
tracked := &peakTrackingReader{r: bytes.NewReader(body), charge: charge}
|
||||
|
||||
held, err := holdContent(tracked, WorkDirAt(workDir), "app.zip", charge, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, held.Close()) })
|
||||
|
||||
assert.Equal(t, []string{"app.zip"}, filesIn(t, workDir), "an archive this much larger than the limit must overflow")
|
||||
assert.Equal(t, body, readAll(t, held))
|
||||
assert.LessOrEqual(t, tracked.peak, limit,
|
||||
"memory charged must never exceed the limit, even transiently, while routing is decided")
|
||||
|
||||
mem, disk := charge.held()
|
||||
assert.Zero(t, mem)
|
||||
assert.Equal(t, int64(len(body)), disk)
|
||||
}
|
||||
|
||||
func TestHoldContent_diskLimitIsTerminal(t *testing.T) {
|
||||
// there is nowhere further to spill, so content that will not fit is refused and the caller skips the
|
||||
// archive rather than waiting for capacity nothing will release
|
||||
workDir := t.TempDir()
|
||||
body := []byte(strings.Repeat("d", 500))
|
||||
limiter := NewLimiter(Limits{MaxDiskBytes: 100})
|
||||
charge := limiter.Charge()
|
||||
|
||||
_, err := holdContent(bytes.NewReader(body), WorkDirAt(workDir), "app.zip", charge, nil)
|
||||
require.ErrorIs(t, err, ErrDiskLimitReached)
|
||||
|
||||
assert.Empty(t, filesIn(t, workDir), "the partial overflow must not be left behind")
|
||||
mem, disk := charge.held()
|
||||
assert.Zero(t, mem)
|
||||
assert.Zero(t, disk, "bytes no longer on disk must be refunded, or the limit only ever rises")
|
||||
}
|
||||
|
||||
func TestHoldContent_zeroDiskLimitSkipsRatherThanFails(t *testing.T) {
|
||||
// a zero disk limit behaves like a positive one exceeded: ErrDiskLimitReached, so the caller skips
|
||||
// the archive rather than the scan failing
|
||||
workDir := t.TempDir()
|
||||
body := []byte(strings.Repeat("f", 500))
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: 10, MaxDiskBytes: 0})
|
||||
charge := limiter.Charge()
|
||||
|
||||
_, err := holdContent(bytes.NewReader(body), WorkDirAt(workDir), "app.zip", charge, nil)
|
||||
require.ErrorIs(t, err, ErrDiskLimitReached)
|
||||
|
||||
assert.Empty(t, filesIn(t, workDir), "no partial overflow is left behind")
|
||||
mem, disk := charge.held()
|
||||
assert.Zero(t, mem)
|
||||
assert.Zero(t, disk)
|
||||
}
|
||||
|
||||
func TestHoldContent_threeStateLimits(t *testing.T) {
|
||||
// both limits read positive/zero/negative the same way - the limit, none of that resource, unbounded
|
||||
// - and a caller can bound one without the other
|
||||
body := []byte(strings.Repeat("e", 500))
|
||||
|
||||
t.Run("a zero memory limit sends everything to disk", func(t *testing.T) {
|
||||
// zero does not mean unlimited memory: the limit decides a routing question, so zero means the
|
||||
// memory side of that routing is never taken
|
||||
workDir := t.TempDir()
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: 10_000})
|
||||
charge := limiter.Charge()
|
||||
|
||||
held, err := holdContent(bytes.NewReader(body), WorkDirAt(workDir), "app.zip", charge, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, held.Close()) })
|
||||
|
||||
assert.Equal(t, []string{"app.zip"}, filesIn(t, workDir), "a zero memory limit holds nothing in memory")
|
||||
mem, disk := charge.held()
|
||||
assert.Zero(t, mem)
|
||||
assert.Equal(t, int64(500), disk, "and the disk limit still applies to what it overflowed")
|
||||
})
|
||||
|
||||
t.Run("a negative memory limit holds content regardless of how much is already held", func(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: -1, MaxDiskBytes: 10_000})
|
||||
charge := limiter.Charge()
|
||||
require.True(t, charge.Memory(1_000_000), "a negative memory limit admits any amount already")
|
||||
|
||||
held, err := holdContent(bytes.NewReader(body), WorkDirAt(workDir), "app.zip", charge, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, held.Close()) })
|
||||
|
||||
assert.Empty(t, filesIn(t, workDir), "nothing overflows for want of memory when the limit is negative")
|
||||
mem, disk := charge.held()
|
||||
assert.Equal(t, int64(1_000_000+len(body)), mem)
|
||||
assert.Zero(t, disk)
|
||||
})
|
||||
|
||||
t.Run("a zero disk limit writes nothing to disk while content the memory limit admits is still cataloged", func(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: 10_000, MaxDiskBytes: 0})
|
||||
charge := limiter.Charge()
|
||||
|
||||
held, err := holdContent(bytes.NewReader(body), WorkDirAt(workDir), "app.zip", charge, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, held.Close()) })
|
||||
|
||||
assert.Empty(t, filesIn(t, workDir), "content the memory limit admits never touches disk")
|
||||
mem, disk := charge.held()
|
||||
assert.Equal(t, int64(len(body)), mem)
|
||||
assert.Zero(t, disk)
|
||||
})
|
||||
|
||||
t.Run("a negative disk limit overflows with no ceiling", func(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: -1})
|
||||
charge := limiter.Charge()
|
||||
|
||||
held, err := holdContent(bytes.NewReader(body), WorkDirAt(workDir), "app.zip", charge, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, held.Close()) })
|
||||
|
||||
assert.Equal(t, []string{"app.zip"}, filesIn(t, workDir))
|
||||
assert.Equal(t, body, readAll(t, held))
|
||||
})
|
||||
}
|
||||
|
||||
func TestHoldContent_noLimitsAtAll(t *testing.T) {
|
||||
// a nil charge is what an extraction with no configured bounds gets: it must route content rather
|
||||
// than panic, holding everything in memory since nothing refuses it
|
||||
workDir := t.TempDir()
|
||||
body := []byte("small enough")
|
||||
|
||||
held, err := holdContent(bytes.NewReader(body), WorkDirAt(workDir), "app.zip", nil, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, held.Close()) })
|
||||
|
||||
assert.Equal(t, body, readAll(t, held))
|
||||
assert.Empty(t, filesIn(t, workDir))
|
||||
}
|
||||
|
||||
func TestOpenFileContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "app.zip")
|
||||
require.NoError(t, os.WriteFile(path, []byte("body"), 0o600))
|
||||
|
||||
content, err := openFileContent(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "app.zip", content.Name)
|
||||
assert.Equal(t, []byte("body"), readAll(t, content))
|
||||
require.NoError(t, content.Close())
|
||||
|
||||
_, err = openFileContent(filepath.Join(dir, "missing.zip"))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// filesIn lists the regular files directly in dir, which is how a test tells content held in memory
|
||||
// from content overflowed to disk.
|
||||
// from content spilled to disk.
|
||||
func filesIn(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
@@ -36,220 +240,10 @@ func readAll(t *testing.T, c Content) []byte {
|
||||
return body
|
||||
}
|
||||
|
||||
func TestHoldContent_heldWhileTheLimitAdmitsIt(t *testing.T) {
|
||||
// the referent for the memory limit: an archive small enough to hold needs no file on disk at
|
||||
// all, where every archive used to be copied to a temp file purely so an extractor could seek it
|
||||
workDir := t.TempDir()
|
||||
body := []byte(strings.Repeat("a", 100))
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: 1000, MaxDiskBytes: 1000})
|
||||
charge := limiter.Charge()
|
||||
|
||||
held, err := holdContent(bytes.NewReader(body), workDir, "app.zip", charge, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, held.Close()) })
|
||||
|
||||
assert.IsType(t, &bytes.Reader{}, held.Reader)
|
||||
assert.Equal(t, body, readAll(t, held))
|
||||
assert.Empty(t, filesIn(t, workDir), "content held in memory must write nothing to disk")
|
||||
|
||||
mem, disk := charge.Held()
|
||||
assert.Equal(t, int64(100), mem)
|
||||
assert.Zero(t, disk)
|
||||
}
|
||||
|
||||
func TestHoldContent_overflowsWhenTheLimitDoesNotAdmitIt(t *testing.T) {
|
||||
// where the boundary falls is decided by the memory limit and nothing else: an archive that does
|
||||
// not fit overflows in full, and the bytes already read to discover that lead the overflow rather than
|
||||
// being lost
|
||||
workDir := t.TempDir()
|
||||
body := []byte(strings.Repeat("b", 250))
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: 100, MaxDiskBytes: 10_000})
|
||||
charge := limiter.Charge()
|
||||
|
||||
held, err := holdContent(bytes.NewReader(body), workDir, "app.zip", charge, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, held.Close()) })
|
||||
|
||||
assert.Equal(t, []string{"app.zip"}, filesIn(t, workDir))
|
||||
assert.Equal(t, body, readAll(t, held),
|
||||
"the bytes read to decide the routing must lead the overflow rather than being lost")
|
||||
|
||||
mem, disk := charge.Held()
|
||||
assert.Zero(t, mem, "nothing is charged to a limit that refused")
|
||||
assert.Equal(t, int64(250), disk)
|
||||
}
|
||||
|
||||
// peakTrackingReader records the highest memory charge observed at any point while reading, which is
|
||||
// how a test tells that a large archive was never buffered beyond what the limit would admit -
|
||||
// nothing about the routing decision is otherwise observable from outside holdContent.
|
||||
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 {
|
||||
if mem, _ := p.charge.held(); mem > p.peak {
|
||||
p.peak = mem
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func TestHoldContent_largeArchiveNeverBuffersBeyondTheLimit(t *testing.T) {
|
||||
// an archive many times the memory limit must overflow, and at no point along the way may more than
|
||||
// the limit would admit be held while the routing is being decided - it is never read
|
||||
// speculatively into memory that has no room for it
|
||||
workDir := t.TempDir()
|
||||
const limit = int64(3 * copyChunkSize)
|
||||
body := []byte(strings.Repeat("c", 10*copyChunkSize))
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: limit, MaxDiskBytes: int64(len(body)) + 1})
|
||||
charge := limiter.Charge()
|
||||
tracked := &peakTrackingReader{r: bytes.NewReader(body), charge: charge}
|
||||
|
||||
held, err := holdContent(tracked, workDir, "app.zip", charge, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, held.Close()) })
|
||||
|
||||
assert.Equal(t, []string{"app.zip"}, filesIn(t, workDir), "an archive this much larger than the limit must overflow")
|
||||
assert.Equal(t, body, readAll(t, held))
|
||||
assert.LessOrEqual(t, tracked.peak, limit,
|
||||
"memory charged must never exceed the limit, even transiently, while routing is decided")
|
||||
|
||||
mem, disk := charge.Held()
|
||||
assert.Zero(t, mem)
|
||||
assert.Equal(t, int64(len(body)), disk)
|
||||
}
|
||||
|
||||
func TestHoldContent_diskLimitIsTerminal(t *testing.T) {
|
||||
// there is nowhere further to overflow, so content that will not fit is refused and the caller skips
|
||||
// the archive rather than waiting for capacity that nothing will release
|
||||
workDir := t.TempDir()
|
||||
body := []byte(strings.Repeat("d", 500))
|
||||
limiter := NewLimiter(Limits{MaxDiskBytes: 100})
|
||||
charge := limiter.Charge()
|
||||
|
||||
_, err := holdContent(bytes.NewReader(body), workDir, "app.zip", charge, nil)
|
||||
require.ErrorIs(t, err, ErrDiskLimitReached)
|
||||
|
||||
assert.Empty(t, filesIn(t, workDir), "the partial overflow must not be left behind")
|
||||
mem, disk := charge.Held()
|
||||
assert.Zero(t, mem)
|
||||
assert.Zero(t, disk, "bytes no longer on disk must be refunded, or the limit only ever rises")
|
||||
}
|
||||
|
||||
func TestHoldContent_zeroDiskLimitSkipsRatherThanFails(t *testing.T) {
|
||||
// a zero disk limit has the same outcome as a positive disk limit exceeded: content that will
|
||||
// not fit anywhere yields ErrDiskLimitReached so the caller skips the archive, rather than the
|
||||
// scan failing
|
||||
workDir := t.TempDir()
|
||||
body := []byte(strings.Repeat("f", 500))
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: 10, MaxDiskBytes: 0})
|
||||
charge := limiter.Charge()
|
||||
|
||||
_, err := holdContent(bytes.NewReader(body), workDir, "app.zip", charge, nil)
|
||||
require.ErrorIs(t, err, ErrDiskLimitReached)
|
||||
|
||||
assert.Empty(t, filesIn(t, workDir), "no partial overflow is left behind")
|
||||
mem, disk := charge.Held()
|
||||
assert.Zero(t, mem)
|
||||
assert.Zero(t, disk)
|
||||
}
|
||||
|
||||
func TestHoldContent_threeStateLimits(t *testing.T) {
|
||||
// both limits read positive/zero/negative the same way: positive is the limit, zero means none
|
||||
// of that resource at all, negative means unbounded. A caller can bound one without bounding the
|
||||
// other.
|
||||
body := []byte(strings.Repeat("e", 500))
|
||||
|
||||
t.Run("a zero memory limit sends everything to disk", func(t *testing.T) {
|
||||
// zero does not mean unlimited memory: the memory limit decides a routing question rather
|
||||
// than only capping an accumulator, so zero means the memory side of that routing is never
|
||||
// taken
|
||||
workDir := t.TempDir()
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: 10_000})
|
||||
charge := limiter.Charge()
|
||||
|
||||
held, err := holdContent(bytes.NewReader(body), workDir, "app.zip", charge, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, held.Close()) })
|
||||
|
||||
assert.Equal(t, []string{"app.zip"}, filesIn(t, workDir), "a zero memory limit holds nothing in memory")
|
||||
mem, disk := charge.Held()
|
||||
assert.Zero(t, mem)
|
||||
assert.Equal(t, int64(500), disk, "and the disk limit still applies to what it overflowed")
|
||||
})
|
||||
|
||||
t.Run("a negative memory limit holds content regardless of how much is already held", func(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: -1, MaxDiskBytes: 10_000})
|
||||
charge := limiter.Charge()
|
||||
require.True(t, charge.Memory(1_000_000), "a negative memory limit admits any amount already")
|
||||
|
||||
held, err := holdContent(bytes.NewReader(body), workDir, "app.zip", charge, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, held.Close()) })
|
||||
|
||||
assert.Empty(t, filesIn(t, workDir), "nothing overflows for want of memory when the limit is negative")
|
||||
mem, disk := charge.Held()
|
||||
assert.Equal(t, int64(1_000_000+len(body)), mem)
|
||||
assert.Zero(t, disk)
|
||||
})
|
||||
|
||||
t.Run("a zero disk limit writes nothing to disk while content the memory limit admits is still cataloged", func(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: 10_000, MaxDiskBytes: 0})
|
||||
charge := limiter.Charge()
|
||||
|
||||
held, err := holdContent(bytes.NewReader(body), workDir, "app.zip", charge, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, held.Close()) })
|
||||
|
||||
assert.Empty(t, filesIn(t, workDir), "content the memory limit admits never touches disk")
|
||||
mem, disk := charge.Held()
|
||||
assert.Equal(t, int64(len(body)), mem)
|
||||
assert.Zero(t, disk)
|
||||
})
|
||||
|
||||
t.Run("a negative disk limit overflows with no ceiling", func(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: -1})
|
||||
charge := limiter.Charge()
|
||||
|
||||
held, err := holdContent(bytes.NewReader(body), workDir, "app.zip", charge, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, held.Close()) })
|
||||
|
||||
assert.Equal(t, []string{"app.zip"}, filesIn(t, workDir))
|
||||
assert.Equal(t, body, readAll(t, held))
|
||||
})
|
||||
}
|
||||
|
||||
func TestHoldContent_noLimitsAtAll(t *testing.T) {
|
||||
// a nil charge is what an extraction with no configured bounds gets, and it must route content
|
||||
// rather than panic - holding everything in memory, since nothing is there to refuse it
|
||||
workDir := t.TempDir()
|
||||
body := []byte("small enough")
|
||||
|
||||
held, err := holdContent(bytes.NewReader(body), workDir, "app.zip", nil, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, held.Close()) })
|
||||
|
||||
assert.Equal(t, body, readAll(t, held))
|
||||
assert.Empty(t, filesIn(t, workDir))
|
||||
}
|
||||
|
||||
func TestOpenFileContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "app.zip")
|
||||
require.NoError(t, os.WriteFile(path, []byte("body"), 0o600))
|
||||
|
||||
content, err := OpenFileContent(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "app.zip", content.Name)
|
||||
assert.Equal(t, []byte("body"), readAll(t, content))
|
||||
require.NoError(t, content.Close())
|
||||
|
||||
_, err = OpenFileContent(filepath.Join(dir, "missing.zip"))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mholt/archives"
|
||||
|
||||
"github.com/anchore/syft/internal/log"
|
||||
)
|
||||
|
||||
// entryHeader builds the tar header for one archive entry's metadata, or reports false when the entry
|
||||
// is not worth carrying. The entry's bytes are held separately.
|
||||
//
|
||||
// Symlinks are recorded as headers, not real links; the target is resolved later inside the archive's
|
||||
// own filetree, so one naming the host filesystem resolves to nothing. Device nodes, fifos and
|
||||
// sockets are skipped - no cataloger reads them.
|
||||
func 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 nil, 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 nil, false
|
||||
}
|
||||
|
||||
// carried through as-is: nothing is written through this name, and the filetree sanitizes it
|
||||
hdr.Name = f.NameInArchive
|
||||
if f.IsDir() && !strings.HasSuffix(hdr.Name, "/") {
|
||||
hdr.Name += "/"
|
||||
}
|
||||
hdr.Linkname = f.LinkTarget
|
||||
|
||||
// dropped: no cataloger reads them, and the header is charged against the memory budget
|
||||
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
|
||||
}
|
||||
+63
-116
@@ -16,107 +16,80 @@ import (
|
||||
"github.com/anchore/syft/internal/log"
|
||||
)
|
||||
|
||||
// Entry is one archive entry held by an EntryStore: its header, and a reference to its bytes wherever
|
||||
// they currently live.
|
||||
//
|
||||
// The bytes move; the entry does not. That is the point of this type - an index built over these
|
||||
// entries stays valid when content is pushed out to disk, because what changes is the entry's
|
||||
// reference and not its identity or its place in any index.
|
||||
// Entry is one archive entry: its header and a reference to its bytes. The bytes move, the entry does
|
||||
// not, so an index built over these entries stays valid when content spills to disk.
|
||||
type Entry struct {
|
||||
Header tar.Header
|
||||
|
||||
// ref names where this entry's bytes are right now. Guarded by the store's mutex, because a swap
|
||||
// to disk rewrites it while readers may be asking for it.
|
||||
// ref locates this entry's bytes. Guarded by the store's mutex: a spill rewrites it while readers
|
||||
// may be asking for it.
|
||||
ref blobRef
|
||||
}
|
||||
|
||||
// blobRef is either bytes held in memory or a region of the store's overflow file. Exactly one of the
|
||||
// two is live: mem is nil once the entry has been written out.
|
||||
// blobRef is either bytes held in memory or a region of the store's overflow file; mem is nil once
|
||||
// the entry has been written out.
|
||||
type blobRef struct {
|
||||
mem []byte
|
||||
offset int64
|
||||
length int64
|
||||
}
|
||||
|
||||
// EntryStore holds the entries of one extracted archive, in memory while the memory limit admits them
|
||||
// and in a single overflow file once it does not.
|
||||
//
|
||||
// This replaces writing every archive's entries into a tar and indexing that tar by seek offset. A
|
||||
// tar had to be on disk before there was anything to index, so a four-kilobyte jar paid for a file
|
||||
// write and a tar walk before a cataloger could read a byte of it. Here the index is over the entries
|
||||
// themselves, so a small archive never touches the disk at all, and a large one moves its bytes
|
||||
// without the index noticing.
|
||||
//
|
||||
// The store owns the overflow file and closes it on Close. Readers handed out by Open borrow it, so
|
||||
// they are only valid until then - which matches the lifetime the archive cataloger already gives an
|
||||
// extracted archive.
|
||||
// EntryStore holds one extracted archive's entries in memory while the memory limit admits them, and
|
||||
// in a single overflow blob once it does not; an archive that fits in memory never touches disk. The
|
||||
// store owns the overflow file and closes it on Close; readers from Open are valid only until then.
|
||||
type EntryStore struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
entries []*Entry
|
||||
|
||||
// heldInMemory is the bytes currently held by entries whose ref is memory-backed. It falls to zero
|
||||
// when the store spills.
|
||||
heldInMemory int64
|
||||
|
||||
workDir string
|
||||
workDir *WorkDir
|
||||
file *os.File
|
||||
written int64
|
||||
|
||||
// archive names this store's archive in the events it sends, so a spill can be attributed to the
|
||||
// file that caused it rather than reported as an anonymous byte count.
|
||||
// archive names this store's archive in the events it sends, attributing a spill to its cause.
|
||||
archive string
|
||||
notify Notify
|
||||
}
|
||||
|
||||
// overflowBlobName is the file an archive's entries are written into once they no longer fit in
|
||||
// memory. Unlike the tar it replaces it has no framing: the entries' offsets live in the store, so
|
||||
// nothing has to be parsed to find them again.
|
||||
// overflowBlobName is the file entries spill into. It has no framing: entry offsets live in the store.
|
||||
const overflowBlobName = "contents.blob"
|
||||
|
||||
// indexRecordOverhead approximates the fixed memory kept per entry no matter its content: the *Entry,
|
||||
// its tar.Header, and the resolver node built over it. It is an over-estimate of the fixed part, with
|
||||
// the variable part - the entry's name and link target - added on top, so the bound errs towards
|
||||
// charging more rather than letting the index outrun what was accounted for. See Charge.IndexRecord.
|
||||
var indexRecordOverhead = int64(reflect.TypeOf((*tar.Header)(nil)).Elem().Size())
|
||||
// indexRecordOverhead is the fixed memory kept per entry regardless of content: the *Entry (tar.Header
|
||||
// plus blobRef), its slot in the entries slice, and append's headroom.
|
||||
//
|
||||
// It covers only what the store keeps. The resolver built over these entries keeps several times as
|
||||
// much in nodes the store cannot count, and charges for that itself; see Charge.IndexRecord.
|
||||
var indexRecordOverhead = int64(reflect.TypeFor[Entry]().Size()) + 24
|
||||
|
||||
// indexRecordCost is what one entry's index record charges: the fixed overhead plus the bytes of the
|
||||
// two strings the header carries by value.
|
||||
// indexRecordCost is what one entry's record charges: the fixed overhead plus the two header strings.
|
||||
func indexRecordCost(hdr tar.Header) int64 {
|
||||
return indexRecordOverhead + int64(len(hdr.Name)) + int64(len(hdr.Linkname))
|
||||
}
|
||||
|
||||
var _ EntrySink = (*EntryStore)(nil)
|
||||
|
||||
// NewEntryStore returns a store that writes its overflow blob into workDir, naming archive in the
|
||||
// events it sends.
|
||||
// NewEntryStore returns a store that spills into workDir, naming archive in its events. The directory
|
||||
// is created only on spill, so an in-memory store creates nothing.
|
||||
//
|
||||
// How much it holds in memory is not a property of the store: it asks the scan's limiter, entry by
|
||||
// entry, and moves what it is holding out to disk the first time the limiter says no. That keeps one
|
||||
// answer to "how much memory is this scan using" rather than a per-archive budget that no one set.
|
||||
func NewEntryStore(workDir, archive string, notify Notify) *EntryStore {
|
||||
// The store has no memory budget of its own: it asks the limiter entry by entry and spills everything
|
||||
// it holds on the first refusal.
|
||||
func NewEntryStore(workDir *WorkDir, archive string, notify Notify) *EntryStore {
|
||||
return &EntryStore{workDir: workDir, archive: archive, notify: notify}
|
||||
}
|
||||
|
||||
// Add stores one entry's header and content, and reports the entry.
|
||||
// Add stores one entry's header and content. Content is read now, not referenced: the archive's
|
||||
// reader is sequential.
|
||||
//
|
||||
// Content is read here rather than referenced: an archive's reader is sequential, so an entry that is
|
||||
// not read now cannot be read later. Where it lands is decided as it arrives, which is what keeps the
|
||||
// decision out of every read path downstream.
|
||||
//
|
||||
// It is read and charged copyChunkSize at a time rather than into a buffer sized by anything the
|
||||
// entry declared, for the reason holdContent reads an archive's own bytes that way: a header's size
|
||||
// is the archive's claim about itself, so an entry far larger than the memory limit would be held
|
||||
// past it in full before the limiter was ever asked. Here the limiter is asked per chunk, and the
|
||||
// first refusal moves this entry and everything the store is holding out to the overflow blob.
|
||||
// It reads and charges copyChunkSize at a time rather than into a header-sized buffer (whose size is
|
||||
// the archive's own claim), so an entry larger than the memory limit is not held in full before the
|
||||
// limiter is asked. The first refusal spills this entry and everything else the store holds.
|
||||
func (s *EntryStore) Add(hdr tar.Header, contents io.Reader, charge *Charge) (*Entry, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// charge this entry's index record before it is held, so an archive of millions of near-empty
|
||||
// entries is bounded by the same budgets its content is rather than growing the index without
|
||||
// limit. The record overflows to disk when memory is full and is only refused when neither budget
|
||||
// admits it; a refusal leaves the archive truncated at the entries already stored.
|
||||
// charge the record before holding the entry, so an archive of many near-empty entries is bounded
|
||||
// by the same budgets its content is. Refused only when neither budget admits it, truncating the
|
||||
// archive at the entries already stored.
|
||||
if !charge.IndexRecord(indexRecordCost(hdr)) {
|
||||
return nil, ErrDiskLimitReached
|
||||
}
|
||||
@@ -134,10 +107,8 @@ func (s *EntryStore) Add(hdr tar.Header, contents io.Reader, charge *Charge) (*E
|
||||
n, readErr := contents.Read(chunk)
|
||||
if n > 0 {
|
||||
if !charge.Memory(int64(n)) {
|
||||
// memory is full: what is held of this entry joins the spill, and the rest of it
|
||||
// streams straight into the blob behind it rather than being read into memory first
|
||||
// memory full: held bytes join the spill, the rest streams straight into the blob
|
||||
entry.ref = memRef(&held)
|
||||
s.heldInMemory += entry.ref.length
|
||||
rest := io.MultiReader(bytes.NewReader(chunk[:n]), contents)
|
||||
return entry, s.spillThroughLocked(entry, rest, charge)
|
||||
}
|
||||
@@ -152,12 +123,11 @@ func (s *EntryStore) Add(hdr tar.Header, contents io.Reader, charge *Charge) (*E
|
||||
}
|
||||
|
||||
entry.ref = memRef(&held)
|
||||
s.heldInMemory += entry.ref.length
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// memRef is a reference to bytes held in memory. The slice is taken non-nil even when it is empty,
|
||||
// because nil is how blobRef says "these bytes are on disk" and a zero-length entry is not.
|
||||
// memRef references bytes held in memory. The slice stays non-nil even when empty, since a nil
|
||||
// blobRef.mem means the bytes are on disk - which a zero-length entry's are not.
|
||||
func memRef(held *bytes.Buffer) blobRef {
|
||||
body := held.Bytes()
|
||||
if body == nil {
|
||||
@@ -166,12 +136,11 @@ func memRef(held *bytes.Buffer) blobRef {
|
||||
return blobRef{mem: body, length: int64(len(body))}
|
||||
}
|
||||
|
||||
// spillThroughLocked moves everything the store is holding out to the overflow blob and writes the
|
||||
// rest of entry's content in behind it, charging the disk limit as it lands.
|
||||
// spillThroughLocked spills everything the store holds to the overflow blob and writes the rest of
|
||||
// entry's content in behind it, charging the disk limit as it lands.
|
||||
//
|
||||
// entry is the newest, so spillLocked writes it last and its region ends where the blob does - which
|
||||
// is exactly where the rest of its content is about to go, so the two are one contiguous region and
|
||||
// the entry keeps a single reference to it.
|
||||
// entry is the newest, so spillLocked writes it last, ending its region right where the rest of its
|
||||
// content goes. The two are contiguous, so the entry keeps one reference.
|
||||
func (s *EntryStore) spillThroughLocked(entry *Entry, rest io.Reader, charge *Charge) error {
|
||||
spilled := entry.ref.length
|
||||
moved, movedBytes, err := s.spillLocked(charge)
|
||||
@@ -183,8 +152,7 @@ func (s *EntryStore) spillThroughLocked(entry *Entry, rest io.Reader, charge *Ch
|
||||
s.written += written
|
||||
entry.ref = blobRef{offset: s.written - written - spilled, length: spilled + written}
|
||||
|
||||
// reported once the whole move is done, so the event counts the bytes that streamed through as
|
||||
// well as the ones that were being held: they all went to disk for the same reason.
|
||||
// reported after the move so the event counts streamed bytes as well as held ones
|
||||
if s.notify != nil && moved > 0 {
|
||||
s.notify(EntriesOverflowed{
|
||||
Archive: s.archive,
|
||||
@@ -204,15 +172,16 @@ func (s *EntryStore) spillThroughLocked(entry *Entry, rest io.Reader, charge *Ch
|
||||
}
|
||||
|
||||
// spillLocked writes every memory-backed entry into the overflow blob and repoints it there,
|
||||
// reporting what it moved. The index over these entries is untouched: an entry's identity is the
|
||||
// pointer, and only its reference to its bytes changes.
|
||||
//
|
||||
// It does not send the event for the move: the entry that triggered the spill is still streaming in
|
||||
// behind it, and an event sent from here would report a byte count short by whatever that entry has
|
||||
// left. spillThroughLocked sends it once both halves have landed.
|
||||
// reporting what it moved. Any index over these entries is untouched: only blobRef changes. It sends
|
||||
// no event; spillThroughLocked sends one once the triggering entry has also landed.
|
||||
func (s *EntryStore) spillLocked(charge *Charge) (moved int, movedBytes int64, err error) {
|
||||
if s.file == nil {
|
||||
f, err := os.OpenFile(filepath.Join(s.workDir, overflowBlobName), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
// first spill creates the work directory
|
||||
dir, err := s.workDir.Path()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(dir, overflowBlobName), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("unable to create overflow blob: %w", err)
|
||||
}
|
||||
@@ -227,7 +196,6 @@ func (s *EntryStore) spillLocked(charge *Charge) (moved int, movedBytes int64, e
|
||||
if !charge.Disk(int64(len(body))) {
|
||||
return moved, movedBytes, ErrDiskLimitReached
|
||||
}
|
||||
charged := charge != nil
|
||||
if _, err := s.file.WriteAt(body, s.written); err != nil {
|
||||
return moved, movedBytes, fmt.Errorf("unable to write archive entry %q to the overflow blob: %w", entry.Header.Name, err)
|
||||
}
|
||||
@@ -235,18 +203,14 @@ func (s *EntryStore) spillLocked(charge *Charge) (moved int, movedBytes int64, e
|
||||
s.written += int64(len(body))
|
||||
moved++
|
||||
movedBytes += int64(len(body))
|
||||
if charged {
|
||||
// the bytes are on disk now, so the memory they were charged for is no longer held
|
||||
charge.RefundMemory(int64(len(body)))
|
||||
}
|
||||
// on disk now, so refund the memory it was charged
|
||||
charge.RefundMemory(int64(len(body)))
|
||||
}
|
||||
s.heldInMemory = 0
|
||||
return moved, movedBytes, nil
|
||||
}
|
||||
|
||||
// overflowReason distinguishes an archive pushed to disk by pressure from one that was never going to
|
||||
// be held at all, which are different findings: the first says the scan is at its memory bound, the
|
||||
// second says the bound is zero.
|
||||
// overflowReason distinguishes an archive spilled under memory pressure from one never held because
|
||||
// the memory limit is zero.
|
||||
func overflowReason(charge *Charge) OverflowReason {
|
||||
if charge != nil && charge.limiter != nil && charge.limiter.limits.MaxMemoryBytes == 0 {
|
||||
return MemoryHoldsNothing
|
||||
@@ -263,9 +227,8 @@ func (s *EntryStore) Entries() []*Entry {
|
||||
return out
|
||||
}
|
||||
|
||||
// Open returns a reader over one entry's content, from memory or from the overflow blob as the entry
|
||||
// currently stands. The reader is Read, Seek and ReadAt either way, so a nested archive is handed to
|
||||
// an archive format where it lies rather than being copied out first.
|
||||
// Open returns a reader over one entry's content, from memory or the overflow blob. It supports Read,
|
||||
// Seek and ReadAt either way, so a nested archive is extracted in place rather than copied out.
|
||||
func (s *EntryStore) Open(entry *Entry) (ReaderAtSeeker, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
@@ -282,14 +245,6 @@ func (s *EntryStore) Open(entry *Entry) (ReaderAtSeeker, error) {
|
||||
return io.NewSectionReader(s.file, entry.ref.offset, entry.ref.length), nil
|
||||
}
|
||||
|
||||
// HeldInMemory reports the entry content currently held in memory, for tests and for the scan's own
|
||||
// statistics.
|
||||
func (s *EntryStore) HeldInMemory() int64 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.heldInMemory
|
||||
}
|
||||
|
||||
// Close releases the overflow blob. Readers handed out by Open do not outlive it.
|
||||
func (s *EntryStore) Close() error {
|
||||
s.mu.Lock()
|
||||
@@ -302,13 +257,9 @@ func (s *EntryStore) Close() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// AddEntry stores one archive entry, so an EntryStore can stand where an OverflowTar stands.
|
||||
//
|
||||
// Note what is NOT here: no header encoding, no block padding, and no end-of-archive marker, because
|
||||
// there is no framing to write. The disk limit is charged only when content actually reaches the disk,
|
||||
// which for a small archive is never - so an archive that fits in memory costs the limiter nothing on
|
||||
// disk and costs the machine no write at all.
|
||||
func (s *EntryStore) AddEntry(f archives.FileInfo, result *ExtractionResult, limits ExtractionLimits) error {
|
||||
// AddEntry stores one archive entry. There is no framing, so the disk limit is charged only when
|
||||
// content reaches disk - never, for an archive that fits in memory.
|
||||
func (s *EntryStore) AddEntry(f archives.FileInfo, result *ExtractionResult, charge *Charge) error {
|
||||
hdr, ok := entryHeader(f)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -325,33 +276,29 @@ func (s *EntryStore) AddEntry(f archives.FileInfo, result *ExtractionResult, lim
|
||||
contents = opened
|
||||
}
|
||||
|
||||
entry, err := s.Add(*hdr, contents, limits.Charge)
|
||||
_, err := s.Add(*hdr, contents, charge)
|
||||
if contents != nil {
|
||||
if closeErr := contents.Close(); closeErr != nil {
|
||||
log.WithFields("entry", f.NameInArchive, "error", closeErr).Trace("unable to close archive entry")
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, ErrDiskLimitReached):
|
||||
// nowhere left to put this archive's content: the entries stored so far stay, and the archive
|
||||
// is reported as truncated rather than discarded
|
||||
// keep the entries stored so far rather than discard the archive
|
||||
result.Truncation = TruncatedByDiskLimit
|
||||
return errTruncated
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
|
||||
result.FilesExtracted++
|
||||
result.BytesWritten = s.OnDisk()
|
||||
_ = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
// Finish has nothing to write: a store's entries are complete as they are added, with no framing to
|
||||
// close off.
|
||||
func (s *EntryStore) Finish(*ExtractionResult, ExtractionLimits) {}
|
||||
// Finish is a no-op: entries are complete as added, with no framing to close off.
|
||||
func (s *EntryStore) Finish(*ExtractionResult) {}
|
||||
|
||||
// OnDisk reports the entry content written out so far, which is what the disk limit was charged for.
|
||||
// OnDisk reports the entry content written out so far, which is what the disk limit was charged.
|
||||
func (s *EntryStore) OnDisk() int64 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
@@ -11,29 +11,16 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func regularHeader(name string, size int64) tar.Header {
|
||||
return tar.Header{Name: name, Size: size, Mode: 0o600, Typeflag: tar.TypeReg}
|
||||
}
|
||||
|
||||
func readEntry(t *testing.T, 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)
|
||||
}
|
||||
|
||||
func TestEntryStore_holdsSmallContentInMemory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewEntryStore(dir, "app.zip", nil)
|
||||
s := NewEntryStore(WorkDirAt(dir), "app.zip", nil)
|
||||
charge := memCharge(1024)
|
||||
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||
|
||||
entry, err := s.Add(regularHeader("a.txt", 5), bytes.NewReader([]byte("hello")), charge)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(5), s.HeldInMemory())
|
||||
assert.Equal(t, int64(5), s.heldInMemory())
|
||||
assert.NoFileExists(t, filepath.Join(dir, overflowBlobName),
|
||||
"a small archive must not touch the disk at all - that is the whole point of the store")
|
||||
|
||||
@@ -44,21 +31,21 @@ func TestEntryStore_holdsSmallContentInMemory(t *testing.T) {
|
||||
|
||||
func TestEntryStore_spillsToDiskAndKeepsTheSameEntries(t *testing.T) {
|
||||
// the index over these entries is not rebuilt when content moves, so the entry pointers and their
|
||||
// contents must both survive the move
|
||||
// contents must survive it
|
||||
dir := t.TempDir()
|
||||
s := NewEntryStore(dir, "app.zip", nil)
|
||||
s := NewEntryStore(WorkDirAt(dir), "app.zip", nil)
|
||||
charge := memCharge(8)
|
||||
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||
|
||||
first, err := s.Add(regularHeader("a.txt", 4), bytes.NewReader([]byte("aaaa")), charge)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(4), s.HeldInMemory())
|
||||
require.Equal(t, int64(4), s.heldInMemory())
|
||||
|
||||
// the second entry takes the store past its memory bound, so everything held moves out
|
||||
second, err := s.Add(regularHeader("b.txt", 6), bytes.NewReader([]byte("bbbbbb")), charge)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Zero(t, s.HeldInMemory(), "everything must have moved out, not just the entry that did not fit")
|
||||
assert.Zero(t, s.heldInMemory(), "everything must have moved out, not just the entry that did not fit")
|
||||
assert.FileExists(t, filepath.Join(dir, overflowBlobName))
|
||||
|
||||
// the same entry pointers still read their own content, from their new home
|
||||
@@ -76,14 +63,14 @@ func TestEntryStore_spillsToDiskAndKeepsTheSameEntries(t *testing.T) {
|
||||
func TestEntryStore_zeroMemoryHoldsNothing(t *testing.T) {
|
||||
// the reading a zero memory limit has everywhere else: none of that resource may be used
|
||||
dir := t.TempDir()
|
||||
s := NewEntryStore(dir, "app.zip", nil)
|
||||
s := NewEntryStore(WorkDirAt(dir), "app.zip", nil)
|
||||
charge := memCharge(0)
|
||||
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||
|
||||
entry, err := s.Add(regularHeader("a.txt", 3), bytes.NewReader([]byte("abc")), charge)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Zero(t, s.HeldInMemory())
|
||||
assert.Zero(t, s.heldInMemory())
|
||||
assert.FileExists(t, filepath.Join(dir, overflowBlobName))
|
||||
|
||||
r, err := s.Open(entry)
|
||||
@@ -93,7 +80,7 @@ func TestEntryStore_zeroMemoryHoldsNothing(t *testing.T) {
|
||||
|
||||
func TestEntryStore_negativeMemoryNeverSpills(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewEntryStore(dir, "app.zip", nil)
|
||||
s := NewEntryStore(WorkDirAt(dir), "app.zip", nil)
|
||||
charge := memCharge(-1)
|
||||
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||
|
||||
@@ -102,15 +89,15 @@ func TestEntryStore_negativeMemoryNeverSpills(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, int64(11), s.HeldInMemory())
|
||||
assert.Equal(t, int64(11), s.heldInMemory())
|
||||
assert.NoFileExists(t, filepath.Join(dir, overflowBlobName))
|
||||
}
|
||||
|
||||
func TestEntryStore_spillIsRefusedWhenDiskIsNotAvailable(t *testing.T) {
|
||||
// content that does not fit in memory and cannot be written has nowhere to go, and the archive is
|
||||
// content that does not fit in memory and cannot be written has nowhere to go, so the archive is
|
||||
// skipped rather than cataloged as if it were empty
|
||||
dir := t.TempDir()
|
||||
s := NewEntryStore(dir, "app.zip", nil)
|
||||
s := NewEntryStore(WorkDirAt(dir), "app.zip", nil)
|
||||
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||
|
||||
nowhereToPutIt := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: 0}).Charge()
|
||||
@@ -120,30 +107,24 @@ func TestEntryStore_spillIsRefusedWhenDiskIsNotAvailable(t *testing.T) {
|
||||
|
||||
func TestEntryStore_directoryEntriesHoldNoContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewEntryStore(dir, "app.zip", nil)
|
||||
s := NewEntryStore(WorkDirAt(dir), "app.zip", nil)
|
||||
charge := memCharge(1024)
|
||||
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||
|
||||
entry, err := s.Add(tar.Header{Name: "lib/", Typeflag: tar.TypeDir, Mode: 0o755}, nil, charge)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Zero(t, s.HeldInMemory())
|
||||
assert.Zero(t, s.heldInMemory())
|
||||
r, err := s.Open(entry)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, readEntry(t, r))
|
||||
}
|
||||
|
||||
// memCharge is a charge against a memory bound with disk left unbounded, which is the shape the store
|
||||
// reads: hold while memory admits, move out when it does not.
|
||||
func memCharge(maxMemory int64) *Charge {
|
||||
return NewLimiter(Limits{MaxMemoryBytes: maxMemory, MaxDiskBytes: -1}).Charge()
|
||||
}
|
||||
|
||||
func TestEntryStore_chargesIndexRecordEvenForEmptyContent(t *testing.T) {
|
||||
// an empty entry still charges its index record - the header and node held to reach it - which is
|
||||
// what bounds entry count, the dimension the content-byte budgets never bounded.
|
||||
// an empty entry still charges its index record - the header and node held to reach it - which bounds
|
||||
// entry count, the dimension the content-byte budgets do not cover
|
||||
dir := t.TempDir()
|
||||
s := NewEntryStore(dir, "app.zip", nil)
|
||||
s := NewEntryStore(WorkDirAt(dir), "app.zip", nil)
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: 1 << 20, MaxDiskBytes: -1})
|
||||
charge := limiter.Charge()
|
||||
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||
@@ -154,15 +135,15 @@ func TestEntryStore_chargesIndexRecordEvenForEmptyContent(t *testing.T) {
|
||||
|
||||
mem, _ := limiter.InUse()
|
||||
assert.Equal(t, indexRecordCost(hdr), mem, "the index record is charged to memory")
|
||||
assert.Equal(t, int64(0), s.HeldInMemory(), "no content was held")
|
||||
assert.Equal(t, int64(0), s.heldInMemory(), "no content was held")
|
||||
}
|
||||
|
||||
func TestEntryStore_indexOverflowsToDiskWhenMemoryIsZero(t *testing.T) {
|
||||
// the index cannot be refused just because memory is zero: a resolver still needs the records to
|
||||
// reach content that lives entirely on disk. With disk unbounded, every entry is admitted and its
|
||||
// record charged to disk instead.
|
||||
// the index cannot be refused just because memory is zero: a resolver needs the records to reach
|
||||
// content living entirely on disk. With disk unbounded, every entry is admitted and its record
|
||||
// charged to disk instead.
|
||||
dir := t.TempDir()
|
||||
s := NewEntryStore(dir, "app.zip", nil)
|
||||
s := NewEntryStore(WorkDirAt(dir), "app.zip", nil)
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: -1})
|
||||
charge := limiter.Charge()
|
||||
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||
@@ -177,11 +158,11 @@ func TestEntryStore_indexOverflowsToDiskWhenMemoryIsZero(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEntryStore_refusesEntriesWhenNoBudgetAdmitsTheIndex(t *testing.T) {
|
||||
// with memory zero and only a few index records' worth of disk, the store admits a bounded number
|
||||
// of entries and then refuses - an archive of millions of empty entries can no longer grow the
|
||||
// index without limit.
|
||||
// with memory zero and only a few index records' worth of disk, the store admits a bounded number of
|
||||
// entries and then refuses, so an archive of millions of empty entries cannot grow the index without
|
||||
// limit
|
||||
dir := t.TempDir()
|
||||
s := NewEntryStore(dir, "app.zip", nil)
|
||||
s := NewEntryStore(WorkDirAt(dir), "app.zip", nil)
|
||||
room := indexRecordCost(regularHeader("x", 0)) * 3
|
||||
charge := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: room}).Charge()
|
||||
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||
@@ -197,3 +178,22 @@ func TestEntryStore_refusesEntriesWhenNoBudgetAdmitsTheIndex(t *testing.T) {
|
||||
require.ErrorIs(t, err, ErrDiskLimitReached)
|
||||
assert.Equal(t, 3, admitted, "exactly the index records that fit the disk budget were admitted")
|
||||
}
|
||||
|
||||
func regularHeader(name string, size int64) tar.Header {
|
||||
return tar.Header{Name: name, Size: size, Mode: 0o600, Typeflag: tar.TypeReg}
|
||||
}
|
||||
|
||||
func readEntry(t *testing.T, 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)
|
||||
}
|
||||
|
||||
// memCharge is a charge against a memory bound with disk unbounded, the shape the store reads: hold
|
||||
// while memory admits, move out when it does not.
|
||||
func memCharge(maxMemory int64) *Charge {
|
||||
return NewLimiter(Limits{MaxMemoryBytes: maxMemory, MaxDiskBytes: -1}).Charge()
|
||||
}
|
||||
|
||||
+65
-237
@@ -18,111 +18,56 @@ import (
|
||||
"github.com/anchore/syft/syft/cataloging"
|
||||
)
|
||||
|
||||
// Extractor extracts archive contents to a destination directory.
|
||||
// Extractor reads the entries of one archive into an EntrySink.
|
||||
type Extractor interface {
|
||||
// CanExtract returns true if this extractor can handle the given archive content.
|
||||
CanExtract(ctx context.Context, content Content) bool
|
||||
|
||||
// Extract writes the archive's entries into sink - one store per archive, not one file per entry -
|
||||
// respecting the given limits. Returns how many entries were written and how many bytes that took.
|
||||
Extract(ctx context.Context, content Content, sink EntrySink, limits ExtractionLimits) (ExtractionResult, error)
|
||||
Extract(ctx context.Context, content Content, sink EntrySink, charge *Charge) (ExtractionResult, error)
|
||||
}
|
||||
|
||||
// EntrySink is where an extractor puts the entries it reads out of an archive.
|
||||
//
|
||||
// Two implementations exist because they store an archive differently and the difference is
|
||||
// measurable: OverflowTar writes every entry into one tar on disk and is indexed by seek offset,
|
||||
// while EntryStore holds entries in memory until a bound says otherwise and is indexed by the entries
|
||||
// themselves. The extractors do not care which they were given, with one exception - see
|
||||
// VerbatimSink.
|
||||
type EntrySink interface {
|
||||
// AddEntry stores one entry of the archive.
|
||||
AddEntry(f archives.FileInfo, result *ExtractionResult, limits ExtractionLimits) error
|
||||
AddEntry(f archives.FileInfo, result *ExtractionResult, charge *Charge) error
|
||||
|
||||
// Finish is called once the last entry has been offered, for whatever a sink has to write after
|
||||
// its content.
|
||||
Finish(result *ExtractionResult, limits ExtractionLimits)
|
||||
// Finish is called once the last entry has been offered.
|
||||
Finish(result *ExtractionResult)
|
||||
}
|
||||
|
||||
// VerbatimSink is an EntrySink that stores archives in tar form, so a tar-family archive can be
|
||||
// copied into it as it stands rather than decoded and re-encoded entry by entry.
|
||||
//
|
||||
// This is the one place the storage format shows through the extractor interface, and it is worth the
|
||||
// leak: a tar.gz copied verbatim is one streamed decompression, where walking it costs a read per
|
||||
// entry. A sink that does not store tars does not implement this and gets the entry-by-entry path.
|
||||
type VerbatimSink interface {
|
||||
EntrySink
|
||||
CopyVerbatim(r io.Reader, result *ExtractionResult, limits ExtractionLimits) error
|
||||
}
|
||||
|
||||
// ExtractionLimits defines safety limits for extracting one archive.
|
||||
type ExtractionLimits struct {
|
||||
|
||||
// Charge is this archive's draw on the scan's resource limiter. Entry bytes are charged to it as
|
||||
// they land, never from the size the entry declared, and a refused charge truncates the
|
||||
// extraction. A nil Charge enforces no byte bound.
|
||||
//
|
||||
// It is a handle rather than a remaining-bytes value because what it charges has to be given back
|
||||
// when the archive is released, and a value copied into a call cannot be.
|
||||
Charge *Charge
|
||||
}
|
||||
|
||||
// TruncationReason names the configured bound that stopped an extraction early. The empty value
|
||||
// means the archive was extracted in full.
|
||||
// TruncationReason names the bound that stopped an extraction early; empty means extracted in full.
|
||||
type TruncationReason string
|
||||
|
||||
const (
|
||||
// TruncatedByDiskLimit means placing more of this archive's content would have taken the scan's
|
||||
// disk usage past the disk limit. Named rather than reported as a bare truncation because a breach
|
||||
// has to be attributed to the archive that triggered it, and saying which bound was reached is
|
||||
// half of that attribution.
|
||||
// TruncatedByDiskLimit: storing more content would exceed the scan's disk limit.
|
||||
TruncatedByDiskLimit TruncationReason = "disk limit"
|
||||
|
||||
// TruncatedByLimit means a bound stopped the extraction but the walker replaced the error that
|
||||
// said which one. Defensive: both bounds record their own reason on the result before stopping.
|
||||
// TruncatedByIndexLimit: indexing more entries would exceed the record budget. Distinct from the
|
||||
// disk limit because the index is never written to disk, though the disk budget backstops it - see
|
||||
// Charge.IndexRecord.
|
||||
TruncatedByIndexLimit TruncationReason = "index limit"
|
||||
|
||||
// TruncatedByLimit: a bound stopped extraction but the walker replaced the error naming which one.
|
||||
TruncatedByLimit TruncationReason = "configured limit"
|
||||
)
|
||||
|
||||
// errTruncated stops an in-progress archive walk when a configured limit is reached. It never
|
||||
// escapes this package: the extractors convert it into a successful, truncated ExtractionResult.
|
||||
// errTruncated stops an in-progress walk at a configured limit. It never escapes this package:
|
||||
// extractors convert it into a successful, truncated ExtractionResult.
|
||||
var errTruncated = errors.New("archive extraction stopped at a configured limit")
|
||||
|
||||
// ExtractionResult holds the result of an extraction operation.
|
||||
//
|
||||
// Reaching a configured limit is NOT an error: it is a truncation. Whatever was written before the
|
||||
// limit is left on disk and is usable, so callers can still catalog what was extracted rather than
|
||||
// discarding the whole archive. Check Truncated to tell a complete extraction from a partial one;
|
||||
// a non-nil error from Extract means the extraction failed and nothing usable was produced.
|
||||
// ExtractionResult holds the result of an extraction. Reaching a limit is a truncation, not an error:
|
||||
// content stored before the limit stays usable. A non-nil error from Extract means nothing usable was
|
||||
// produced.
|
||||
type ExtractionResult struct {
|
||||
// FilesExtracted counts the entries this archive's filesystem holds - the records its overflow tar
|
||||
// was indexed into: regular files, symlinks, and directory entries. It is the count after indexing
|
||||
// rather than the count written, so an entry dropped by an exclusion pattern is not counted as
|
||||
// present. Reported, not bounded.
|
||||
FilesExtracted int
|
||||
|
||||
// BytesWritten is what this archive put on disk: every byte of the overflow tar, headers and block
|
||||
// padding included, because that is what the disk limit was charged for.
|
||||
BytesWritten int64
|
||||
|
||||
// Truncation names the limit that stopped extraction early, or "" when the archive was
|
||||
// extracted in full.
|
||||
// Truncation names the limit that stopped extraction early, or "" if none did.
|
||||
Truncation TruncationReason
|
||||
}
|
||||
|
||||
// Truncated reports whether a configured limit stopped this extraction before the end of the
|
||||
// archive.
|
||||
func (r ExtractionResult) Truncated() bool {
|
||||
return r.Truncation != ""
|
||||
}
|
||||
|
||||
// finishExtraction turns a walk that stopped at a configured limit into a successful, truncated
|
||||
// result.
|
||||
//
|
||||
// The limit signal is carried on the result rather than only in the returned error because the
|
||||
// underlying archive walkers are free to wrap or replace a handler's error, in which case
|
||||
// errors.Is(err, errTruncated) would not match and a truncation would be reported as a failure -
|
||||
// discarding an archive syft could have cataloged. The error is still checked, so a walker that
|
||||
// passes it through cleanly is handled too.
|
||||
// finishExtraction turns a walk stopped at a configured limit into a successful, truncated result.
|
||||
// Both result and error are checked: walkers may wrap or replace the handler's error, so
|
||||
// errors.Is(err, errTruncated) alone would report some truncations as failures.
|
||||
func finishExtraction(result ExtractionResult, err error) (ExtractionResult, error) {
|
||||
if result.Truncated() || errors.Is(err, errTruncated) {
|
||||
if !result.Truncated() {
|
||||
@@ -134,16 +79,6 @@ func finishExtraction(result ExtractionResult, err error) (ExtractionResult, err
|
||||
return result, err
|
||||
}
|
||||
|
||||
// DefaultExtractionLimits returns the per-archive limits from the given config.
|
||||
//
|
||||
// Nothing is read from the config: every bound this scan enforces is an in-use limit held by the
|
||||
// scan's limiter, and the only per-archive field left is the Charge the caller draws against it.
|
||||
// The parameter stays so the two Default* constructors are called the same way, and so a per-archive
|
||||
// bound that is genuinely per-archive has somewhere to land.
|
||||
func DefaultExtractionLimits(_ cataloging.ArchiveSearchConfig) ExtractionLimits {
|
||||
return ExtractionLimits{}
|
||||
}
|
||||
|
||||
// DefaultLimits returns the scan-wide in-use limits from the given config.
|
||||
func DefaultLimits(cfg cataloging.ArchiveSearchConfig) Limits {
|
||||
return Limits{
|
||||
@@ -178,27 +113,16 @@ func FindExtractor(ctx context.Context, extractors []Extractor, content Content)
|
||||
// ZipExtractor extracts zip-based archives (zip, jar, war, ear, etc.).
|
||||
type ZipExtractor struct{}
|
||||
|
||||
// CanExtract reports whether this content is a zip, asking two questions in an order that matters.
|
||||
// CanExtract reports whether this content is a zip. Test order matters: head identification wins,
|
||||
// because a tar holding one jar ends in that jar's EOCD record, so reading it as a zip would lose the
|
||||
// rest of the tar. The EOCD record is consulted only when nothing identifies from the head, where it
|
||||
// is the only test that sees a zip concatenated onto a launcher stub (e.g. a Spring Boot executable
|
||||
// jar).
|
||||
//
|
||||
// First, what does the content say it is, reading from the start? A format that identifies itself at
|
||||
// offset zero is what this content is, and if that format is not zip then it is not a zip - a tar
|
||||
// holding one jar ends in that jar's end-of-central-directory record, and reading it as a zip
|
||||
// instead of as a tar would lose everything else the tar holds.
|
||||
//
|
||||
// Only when nothing identifies from the head is the end-of-central-directory record consulted. That
|
||||
// record is what defines a zip - a reader locates the central directory through it - and it is the
|
||||
// only thing that can see an archive whose head is something else entirely. A self-extracting
|
||||
// archive is a launcher stub with a zip concatenated onto it, so no magic at offset zero can find
|
||||
// it; the canonical case is a Spring Boot executable jar, a shell script followed by a whole zip.
|
||||
//
|
||||
// Identification is intFile.IdentifyArchive, which matches on the filename as well as the content,
|
||||
// and it is left that way. Dropping the name makes the answer content's alone, which reads better -
|
||||
// but archives.Identify with no path has to try every format's content matcher against every
|
||||
// candidate, and measured on a stock ruby image that took the scan from 17 seconds to 45. The name
|
||||
// cannot admit a non-archive in practice: a file only reaches an extractor after its sniffed content
|
||||
// type put it in the candidate set, so the prose named notes.zip that this would wrongly identify is
|
||||
// never offered here at all. `#content-based-archive-detection` is upheld by the candidate set, and
|
||||
// Test_archiveCataloger_aMisnamedNonArchiveIsNotAnError is where that is asserted.
|
||||
// IdentifyArchive matches on filename as well as content; dropping the name would run every format's
|
||||
// content matcher against every candidate, far slower. The name cannot admit a non-archive, since
|
||||
// files reach an extractor only after content sniffing (see
|
||||
// Test_archiveCataloger_aMisnamedNonArchiveIsNotAnError).
|
||||
func (z *ZipExtractor) CanExtract(ctx context.Context, content Content) bool {
|
||||
format, _, err := intFile.IdentifyArchive(ctx, content.Name, content.Reader)
|
||||
if err == nil {
|
||||
@@ -212,21 +136,18 @@ func (z *ZipExtractor) CanExtract(ctx context.Context, content Content) bool {
|
||||
return HasZipEndOfCentralDirectory(content.Reader)
|
||||
}
|
||||
|
||||
func (z *ZipExtractor) Extract(ctx context.Context, content Content, sink EntrySink, limits ExtractionLimits) (ExtractionResult, error) {
|
||||
func (z *ZipExtractor) Extract(ctx context.Context, content Content, sink EntrySink, charge *Charge) (ExtractionResult, error) {
|
||||
var result ExtractionResult
|
||||
|
||||
if _, err := content.Reader.Seek(0, io.SeekStart); err != nil {
|
||||
return result, fmt.Errorf("unable to seek zip archive %q: %w", content.Name, err)
|
||||
}
|
||||
|
||||
// mholt/archives imposes no extraction target: the FileHandler is the caller's, so an entry can
|
||||
// be written into a tar header rather than onto the filesystem. Nothing about the library had to
|
||||
// be customized - the old code wrote files because the handler wrote files.
|
||||
err := archives.Zip{}.Extract(ctx, content.Reader, func(_ context.Context, file archives.FileInfo) error {
|
||||
return sink.AddEntry(file, &result, limits)
|
||||
return sink.AddEntry(file, &result, charge)
|
||||
})
|
||||
|
||||
sink.Finish(&result, limits)
|
||||
sink.Finish(&result)
|
||||
|
||||
return finishExtraction(result, err)
|
||||
}
|
||||
@@ -234,20 +155,21 @@ func (z *ZipExtractor) Extract(ctx context.Context, content Content, sink EntryS
|
||||
// TarExtractor extracts tar-based archives (tar, tar.gz, tar.bz2, tar.xz, tar.zst).
|
||||
type TarExtractor struct{}
|
||||
|
||||
// CanExtract reports whether the content identifies as an extractable non-zip format.
|
||||
func (t *TarExtractor) CanExtract(ctx context.Context, content Content) bool {
|
||||
format, _, err := intFile.IdentifyArchive(ctx, content.Name, content.Reader)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// mholt/archives returns a compound type for tar+compression, check if it's an extractor but not a zip
|
||||
if _, isZip := format.(archives.Zip); isZip {
|
||||
return false
|
||||
}
|
||||
// tar+compression comes back as a compound type, so test for the Extractor interface
|
||||
_, ok := format.(archives.Extractor)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (t *TarExtractor) Extract(ctx context.Context, content Content, sink EntrySink, limits ExtractionLimits) (ExtractionResult, error) {
|
||||
func (t *TarExtractor) Extract(ctx context.Context, content Content, sink EntrySink, charge *Charge) (ExtractionResult, error) {
|
||||
var result ExtractionResult
|
||||
|
||||
if _, err := content.Reader.Seek(0, io.SeekStart); err != nil {
|
||||
@@ -270,47 +192,24 @@ func (t *TarExtractor) Extract(ctx context.Context, content Content, sink EntryS
|
||||
extractReader = content.Reader
|
||||
}
|
||||
|
||||
// a tar handed to a sink that stores tars is not walked at all: a compressed tar is decompressed
|
||||
// once into the sink's file and a plain one is copied, and the entries are first looked at when
|
||||
// the file is indexed. Where the sink stores entries rather than a tar there is nothing to copy
|
||||
// into, so the tar is walked like any other archive.
|
||||
if verbatim, isVerbatim := sink.(VerbatimSink); isVerbatim {
|
||||
if plain, ok := tarStream(format, extractReader); ok {
|
||||
err = verbatim.CopyVerbatim(plain, &result, limits)
|
||||
if closeErr := plain.Close(); closeErr != nil {
|
||||
log.WithFields("archive", content.Name, "error", closeErr).Trace("unable to close decompressed tar stream")
|
||||
}
|
||||
return finishExtraction(result, err)
|
||||
}
|
||||
}
|
||||
|
||||
// anything else - 7z, rar, or a tar going into a sink that does not store tars - is walked entry
|
||||
// by entry
|
||||
err = extractor.Extract(ctx, extractReader, func(_ context.Context, file archives.FileInfo) error {
|
||||
return sink.AddEntry(file, &result, limits)
|
||||
return sink.AddEntry(file, &result, charge)
|
||||
})
|
||||
|
||||
sink.Finish(&result, limits)
|
||||
sink.Finish(&result)
|
||||
|
||||
return finishExtraction(result, err)
|
||||
}
|
||||
|
||||
// resolvesInsideRoot reports whether writing to path really lands inside root, following any symlink
|
||||
// that already exists along the way the same way the filesystem will.
|
||||
// resolvesInsideRoot reports whether writing to path lands inside root, following existing symlinks
|
||||
// as the filesystem would. It guards the one filesystem write left in this package: an archive
|
||||
// spilling into its work directory under an attacker-controlled name.
|
||||
//
|
||||
// The one filesystem write left in this package is the archive's own bytes overflowing into its work
|
||||
// directory, under a name derived from where the archive was found - which inside another archive is
|
||||
// attacker-controlled text. That is what this now guards; entries are tar headers and traverse no
|
||||
// path at all. See overflowContent.
|
||||
//
|
||||
// It walks path one component at a time rather than calling filepath.EvalSymlinks on the whole
|
||||
// thing, because the leaf of an entry being extracted does not exist yet, which makes EvalSymlinks
|
||||
// fail rather than answer. The first component that does not exist ends the walk: nothing below it
|
||||
// exists either, so whatever is created there is created fresh under a path already known to be
|
||||
// inside the root.
|
||||
// The walk is component by component rather than one EvalSymlinks call because the leaf does not yet
|
||||
// exist, which makes EvalSymlinks fail rather than answer. The first missing component ends the walk:
|
||||
// anything created below it lands under an already-checked path.
|
||||
func resolvesInsideRoot(root, path string) (bool, error) {
|
||||
// the root itself may sit under a symlinked directory (a temp dir on macOS, for one), so
|
||||
// containment is judged against its real location
|
||||
// the root itself may sit under a symlinked directory (e.g. a macOS temp dir)
|
||||
realRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to resolve extraction root: %w", err)
|
||||
@@ -355,8 +254,8 @@ func resolvesInsideRoot(root, path string) (bool, error) {
|
||||
return isWithin(current, realRoot), nil
|
||||
}
|
||||
|
||||
// resolveLink returns where a symlink really points, falling back to the lexically cleaned target
|
||||
// when the target does not exist yet - a dangling link still redirects a write.
|
||||
// resolveLink returns where a symlink points, falling back to the lexically cleaned target when it
|
||||
// does not exist yet - a dangling link still redirects a write.
|
||||
func resolveLink(path string) (string, error) {
|
||||
target, err := os.Readlink(path)
|
||||
if err != nil {
|
||||
@@ -375,8 +274,8 @@ func resolveLink(path string) (string, error) {
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// isWithin reports whether path is root or sits beneath it, compared on a path boundary rather than
|
||||
// as a string prefix so a sibling sharing the prefix does not pass.
|
||||
// isWithin reports whether path is root or beneath it, compared on path boundaries so a sibling
|
||||
// sharing the prefix does not pass.
|
||||
func isWithin(path, root string) bool {
|
||||
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
|
||||
if err != nil {
|
||||
@@ -385,93 +284,30 @@ func isWithin(path, root string) bool {
|
||||
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||
}
|
||||
|
||||
// writeSafeSymlink creates a symlink at destPath only if target is relative and really resolves,
|
||||
// relative to destPath's directory, to a location inside destDir.
|
||||
//
|
||||
// Absolute targets are rejected outright: os.Symlink writes the literal target
|
||||
// string, so an absolute target like "/etc/passwd" would resolve on the host
|
||||
// filesystem when read, regardless of any safety check we performed against
|
||||
// destDir at extraction time.
|
||||
//
|
||||
// A relative target is resolved against the REAL location of destPath's directory, not against its
|
||||
// lexical one, because two cooperating entries can otherwise each pass on their own and together
|
||||
// point outside: a link to "." followed by a link to ".." inside it resolves lexically to the root
|
||||
// while really landing above it, and repeating the pair climbs a level each time. The parent of
|
||||
// every write is checked the same way before the write happens, so a link that slipped through here
|
||||
// still could not be written through - this is the earlier of two refusals, not the only one.
|
||||
//
|
||||
// It has no caller since an archive's entries became headers in one tar rather than files on disk: a
|
||||
// symlink entry is now a header write, which creates nothing and traverses nothing, and where the
|
||||
// link points is decided inside the archive's own filetree. It is kept rather than retired because
|
||||
// that is a judgement about every path that could write a link, not about this one call site, and
|
||||
// because the reasoning above is the expensive part to reconstruct.
|
||||
//
|
||||
//nolint:unused // retained deliberately; see the note above
|
||||
func writeSafeSymlink(target, destPath, destDir string) error {
|
||||
if target == "" {
|
||||
return fmt.Errorf("empty link target")
|
||||
}
|
||||
if filepath.IsAbs(target) {
|
||||
return fmt.Errorf("absolute symlink target not allowed")
|
||||
}
|
||||
|
||||
realRoot, err := filepath.EvalSymlinks(destDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to resolve extraction root: %w", err)
|
||||
}
|
||||
realParent, err := filepath.EvalSymlinks(filepath.Dir(destPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to resolve link parent: %w", err)
|
||||
}
|
||||
if !isWithin(realParent, realRoot) {
|
||||
return fmt.Errorf("symlink parent escapes extraction root")
|
||||
}
|
||||
|
||||
resolved := filepath.Join(realParent, target)
|
||||
if !isWithin(resolved, realRoot) {
|
||||
return fmt.Errorf("symlink target escapes extraction root")
|
||||
}
|
||||
// the target itself may run through links planted by earlier entries
|
||||
if existing, err := filepath.EvalSymlinks(resolved); err == nil && !isWithin(existing, realRoot) {
|
||||
return fmt.Errorf("symlink target resolves outside extraction root")
|
||||
}
|
||||
|
||||
return os.Symlink(target, destPath)
|
||||
}
|
||||
|
||||
// zipEOCDSignature is the end-of-central-directory record's signature. A zip is defined by this
|
||||
// record: it is what a reader seeks to in order to find the central directory, which is why a zip
|
||||
// with something concatenated in front of it still opens.
|
||||
// zipEOCDSignature is the end-of-central-directory record's signature. Readers seek to this record
|
||||
// to find the central directory, which is why a zip with something prepended still opens.
|
||||
var zipEOCDSignature = []byte{'P', 'K', 0x05, 0x06}
|
||||
|
||||
// maxZipEOCDSearch is how far back the record can be. The EOCD is 22 bytes and ends with a comment
|
||||
// of up to 65535 bytes, so it starts no earlier than this many bytes from the end.
|
||||
// maxZipEOCDSearch is how far back from the end the EOCD record can start: a 22 byte record plus a
|
||||
// comment of up to 65535 bytes.
|
||||
const maxZipEOCDSearch = 22 + 65535
|
||||
|
||||
// zipLocalFileHeaderSignature starts every entry in a zip. In a self-extracting archive the first
|
||||
// one follows the stub, so it is what a bounded read of the head can find.
|
||||
// one follows the stub, within reach of a bounded read of the head.
|
||||
var zipLocalFileHeaderSignature = []byte{'P', 'K', 0x03, 0x04}
|
||||
|
||||
// MaxAppendedArchiveStubBytes is how far into a file a hidden archive is looked for. A Spring Boot
|
||||
// launcher stub is about 9 KB, so this is generous by a wide margin; a stub longer than this hides
|
||||
// its archive from the walk, which is the cost of not reading every candidate to the end.
|
||||
const MaxAppendedArchiveStubBytes = 64 * 1024
|
||||
// maxAppendedArchiveStubBytes is how far into a file an appended archive is looked for. A Spring
|
||||
// Boot launcher stub is about 9 KB; a longer stub hides its archive from the walk.
|
||||
const maxAppendedArchiveStubBytes = 64 * 1024
|
||||
|
||||
// MayHideAnAppendedArchive reports whether the head of this stream contains a zip local file header,
|
||||
// and returns a reader that replays what it consumed.
|
||||
//
|
||||
// It exists to make the candidate set affordable. A file whose head sniffs as a script, an
|
||||
// executable, or as nothing recognizable may still be an archive wearing a stub, but almost none of
|
||||
// them are - and the authoritative test, the end-of-central-directory record, is at the far end of
|
||||
// the file, so applying it to every such candidate means reading every executable in a scan to its
|
||||
// end. Measured on a stock ruby image that doubled the scan.
|
||||
//
|
||||
// So the head is checked first, cheaply and with a bound, and only a file that shows an entry
|
||||
// signature there is read in full and put to the real test. This narrows what is examined; it never
|
||||
// admits anything, because a file that passes here is still refused by the extractor unless it ends
|
||||
// in a valid central directory.
|
||||
// A cheap, bounded pre-filter for HasZipEndOfCentralDirectory: reading every script, executable and
|
||||
// unrecognized file to its end roughly doubles a scan, so only files showing an entry signature in
|
||||
// their head are read in full. It narrows what is examined; it admits nothing on its own.
|
||||
func MayHideAnAppendedArchive(r io.Reader) (io.Reader, bool) {
|
||||
head := make([]byte, MaxAppendedArchiveStubBytes)
|
||||
head := make([]byte, maxAppendedArchiveStubBytes)
|
||||
n, err := io.ReadFull(r, head)
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return io.MultiReader(bytes.NewReader(head[:n]), r), false
|
||||
@@ -481,17 +317,9 @@ func MayHideAnAppendedArchive(r io.Reader) (io.Reader, bool) {
|
||||
}
|
||||
|
||||
// HasZipEndOfCentralDirectory reports whether the content ends in a zip end-of-central-directory
|
||||
// record, which is the definition of a zip and the only part of one whose position is knowable
|
||||
// without parsing.
|
||||
//
|
||||
// This is what identifies an archive that content sniffing types by its head: a self-extracting
|
||||
// archive is a launcher stub with a zip concatenated onto it, so its first bytes are the stub's and
|
||||
// no magic-at-offset-zero test can see the archive. The record at the end is unambiguous, and
|
||||
// mholt/archives extracts such a file correctly once it is asked to - it reads through
|
||||
// archive/zip, which locates the central directory from the end.
|
||||
//
|
||||
// It is a content test, so it cannot make a non-archive extractable: a file named `notes.zip` that
|
||||
// is prose has no such record and is refused here exactly as it is refused by format identification.
|
||||
// record - the only part of a zip whose position is knowable without parsing. It identifies archives
|
||||
// that head-based sniffing types as something else, such as a launcher stub with a zip appended,
|
||||
// which mholt/archives extracts correctly via archive/zip.
|
||||
func HasZipEndOfCentralDirectory(r ReaderAtSeeker) bool {
|
||||
size, err := r.Seek(0, io.SeekEnd)
|
||||
if err != nil || size < int64(len(zipEOCDSignature)) {
|
||||
|
||||
+973
-1013
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Seams the package itself does not need, kept here so no production file carries a function only
|
||||
// tests call.
|
||||
|
||||
// WorkDirAt returns a WorkDir over a directory that already exists. Production always creates its own
|
||||
// (NewWorkDir); a test lends one it can inspect afterwards, so Remove leaves it alone.
|
||||
func WorkDirAt(dir string) *WorkDir {
|
||||
return &WorkDir{path: dir, done: true}
|
||||
}
|
||||
|
||||
// heldInMemory reports the entry content this store is still holding in memory. Production never asks:
|
||||
// the limiter is what bounds it, and the store charges as it goes rather than tracking a total.
|
||||
func (s *EntryStore) heldInMemory() int64 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var total int64
|
||||
for _, entry := range s.entries {
|
||||
if entry.ref.mem != nil {
|
||||
total += entry.ref.length
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// created reports whether anything ever asked for the directory, and so whether there is anything to
|
||||
// remove.
|
||||
func (w *WorkDir) created() bool {
|
||||
if w == nil {
|
||||
return false
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.remove != nil
|
||||
}
|
||||
|
||||
// held reports what this charge is currently holding, in memory and on disk.
|
||||
func (c *Charge) held() (memory, disk int64) {
|
||||
if c == nil || c.limiter == nil {
|
||||
return 0, 0
|
||||
}
|
||||
c.limiter.mu.Lock()
|
||||
defer c.limiter.mu.Unlock()
|
||||
return c.memory, c.disk
|
||||
}
|
||||
|
||||
// openFileContent opens an archive already on disk as Content. The caller closes it.
|
||||
func openFileContent(path string) (Content, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return Content{}, err
|
||||
}
|
||||
return Content{Name: filepath.Base(path), Reader: f, closer: f}, nil
|
||||
}
|
||||
+43
-92
@@ -5,57 +5,42 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ErrDiskLimitReached reports that an archive's content could not be admitted because placing it
|
||||
// would have taken disk usage past the disk limit. It is not a failure of the scan: the archive is
|
||||
// skipped and the walk continues.
|
||||
// ErrDiskLimitReached reports that placing an archive's content would exceed the disk limit. Not a
|
||||
// scan failure: the archive is skipped and the walk continues.
|
||||
var ErrDiskLimitReached = errors.New("archive content would exceed the disk limit")
|
||||
|
||||
// Limits bounds how much archive content one scan may hold at once, and decides where an
|
||||
// archive's content is held.
|
||||
// Limits bounds how much archive content one scan may hold at once, and so where content is held.
|
||||
//
|
||||
// These are in-use limits rather than counters. An in-use limit measures what is held right now and
|
||||
// falls when an archive is released, so what it bounds is a scan's peak concurrent usage. A bound on
|
||||
// bytes ever written measures no state that ever exists, because every archive's extraction directory
|
||||
// is removed on the way out: it would restrict a long scan of small archives that never held much at
|
||||
// once, and permit a deep scan that held far more.
|
||||
// These are in-use limits, not counters: each measures what is held right now and falls when an
|
||||
// archive is released, bounding peak concurrent usage. Work directories are removed on release, so a
|
||||
// bound on bytes ever written would measure state that never exists at once.
|
||||
//
|
||||
// Both fields read three states the same way: positive is the limit, zero means none of that
|
||||
// resource may be used at all, and negative means that resource is not bounded. Unbounded is a real
|
||||
// hazard on either - a crafted archive can exhaust memory or disk - so it is reachable only by asking
|
||||
// for it explicitly with a negative value, never as the reading of an unset or zeroed field.
|
||||
// Both fields read three states the same way: positive is the limit, zero forbids that resource, and
|
||||
// negative is unbounded. Unbounded is a real hazard, so it must be asked for explicitly.
|
||||
type Limits struct {
|
||||
// MaxMemoryBytes bounds the archive content held in memory at once. Content is held in memory
|
||||
// while this admits it and overflows to disk when it does not - there is no separate configured size
|
||||
// at which overflowing begins. Zero holds nothing, sending every archive's content to disk;
|
||||
// negative holds content regardless of how much is already held.
|
||||
// MaxMemoryBytes bounds archive content held in memory at once; content spills to disk when it does
|
||||
// not admit more, so there is no separate spill threshold. Zero sends all content to disk; negative
|
||||
// is unbounded.
|
||||
MaxMemoryBytes int64
|
||||
|
||||
// MaxDiskBytes bounds the archive content placed on disk at once: content overflowed there instead
|
||||
// of being held in memory, plus the bytes of the entries extracted from it. Zero writes nothing
|
||||
// to disk, so content that does not fit in memory has nowhere to go and its archive is skipped;
|
||||
// negative overflows as needed with no ceiling.
|
||||
// MaxDiskBytes bounds archive content on disk at once: content spilled there plus the entry bytes
|
||||
// extracted from it. Zero writes nothing, so content that does not fit in memory has nowhere to go
|
||||
// and its archive is skipped; negative is unbounded.
|
||||
MaxDiskBytes int64
|
||||
}
|
||||
|
||||
// Limiter measures how much archive content a scan is holding right now. One instance per task run,
|
||||
// which is one scan - the only scoping under which "held at once" means anything.
|
||||
//
|
||||
// Guarded by a mutex even though the archive walk is sequential today: the archive task merges into
|
||||
// a shared builder while top-level catalogers run, so this code already lives in a concurrent
|
||||
// neighbourhood and an unguarded total here would be a data race the day someone parallelises the
|
||||
// walk.
|
||||
//
|
||||
// A nil *Limiter enforces nothing, which is what an extraction with no configured bounds gets.
|
||||
// Limiter measures how much archive content a scan is holding right now. One instance per task run
|
||||
// (one scan), the only scope under which "held at once" means anything. A nil *Limiter enforces
|
||||
// nothing. Mutex-guarded because the archive task merges into a shared builder while top-level
|
||||
// catalogers run.
|
||||
type Limiter struct {
|
||||
limits Limits
|
||||
mu sync.Mutex
|
||||
memory int64
|
||||
disk int64
|
||||
|
||||
// peaks are the high-water marks of the two gauges above: the most this scan held at any one
|
||||
// moment, rather than the sum of what every archive held. They only ever rise, which is the one
|
||||
// place in this type where that is correct - a peak that fell on release would be measuring the
|
||||
// same thing the gauge already measures.
|
||||
// peaks are high-water marks of the two gauges above: the most held at any one moment, not the sum
|
||||
// across archives. They only rise.
|
||||
peakMemory int64
|
||||
peakDisk int64
|
||||
}
|
||||
@@ -65,16 +50,7 @@ func NewLimiter(limits Limits) *Limiter {
|
||||
return &Limiter{limits: limits}
|
||||
}
|
||||
|
||||
// MaxDiskBytes returns the configured disk bound, for attributing a skip in a log line.
|
||||
func (l *Limiter) MaxDiskBytes() int64 {
|
||||
if l == nil {
|
||||
return 0
|
||||
}
|
||||
return l.limits.MaxDiskBytes
|
||||
}
|
||||
|
||||
// Peak reports the most archive content this scan held at any one moment, in memory and on disk.
|
||||
// These are the same quantities the limits bound, so they say how close a scan came to them.
|
||||
// Peak reports the most archive content held at any one moment, the quantities the limits bound.
|
||||
func (l *Limiter) Peak() (memory, disk int64) {
|
||||
if l == nil {
|
||||
return 0, 0
|
||||
@@ -94,8 +70,7 @@ func (l *Limiter) InUse() (memory, disk int64) {
|
||||
return l.memory, l.disk
|
||||
}
|
||||
|
||||
// Charge starts one archive's accounting against this limiter. Releasing the returned charge gives
|
||||
// back exactly what it took, which is what makes this a limiter rather than a counter.
|
||||
// Charge starts one archive's accounting. Releasing the returned charge gives back what it took.
|
||||
func (l *Limiter) Charge() *Charge {
|
||||
if l == nil {
|
||||
return nil
|
||||
@@ -103,25 +78,18 @@ func (l *Limiter) Charge() *Charge {
|
||||
return &Charge{limiter: l}
|
||||
}
|
||||
|
||||
// Charge is one archive's accumulated draw on the limiter.
|
||||
// Charge is one archive's accumulated draw on the limiter. A nil *Charge charges and refuses nothing.
|
||||
//
|
||||
// Bytes are charged to it as they land, never from a size an entry declared: both tar and zip
|
||||
// headers carry an entry size and a crafted archive is free to lie about it, so a reservation made
|
||||
// against a declared size bounds what an attacker claims rather than what the machine does. The
|
||||
// charge accumulates what was actually taken, so Release returns exactly that.
|
||||
//
|
||||
// A nil *Charge charges nothing and refuses nothing.
|
||||
// Bytes are charged as they land, never from a declared size: tar and zip headers both carry a size a
|
||||
// crafted archive can lie about, so reserving against it would bound the claim, not the usage.
|
||||
type Charge struct {
|
||||
limiter *Limiter
|
||||
memory int64
|
||||
disk int64
|
||||
}
|
||||
|
||||
// admitsThreeState reports whether n more bytes may be charged against a limit that is already
|
||||
// holding held, reading the limit the same way on both bounds it governs: positive is the limit
|
||||
// itself, zero refuses unconditionally (none of that resource may be used at all), and negative
|
||||
// admits unconditionally (that resource is not bounded). One reading shared by Memory and Disk
|
||||
// rather than the same three-way condition written twice, since the two have already drifted once.
|
||||
// admitsThreeState reports whether n more bytes may be charged against a limit already holding held.
|
||||
// Shared by Memory and Disk so the two readings of Limits cannot drift.
|
||||
func admitsThreeState(limit, held, n int64) bool {
|
||||
switch {
|
||||
case limit < 0:
|
||||
@@ -133,10 +101,8 @@ func admitsThreeState(limit, held, n int64) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Memory charges n bytes of content held in memory, reporting false when the memory limit does not
|
||||
// admit it - which for a zero limit is every chunk, sending content to disk unconditionally, since
|
||||
// there is no separate threshold naming a size at which content overflows. Nothing is charged when it
|
||||
// reports false.
|
||||
// Memory charges n bytes held in memory, reporting false when the memory limit does not admit it (for
|
||||
// a zero limit, always). Nothing is charged when it reports false.
|
||||
func (c *Charge) Memory(n int64) bool {
|
||||
if c == nil || c.limiter == nil || n <= 0 {
|
||||
return true
|
||||
@@ -155,10 +121,8 @@ func (c *Charge) Memory(n int64) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Disk charges n bytes of content placed on disk, reporting false when the disk limit does not admit
|
||||
// it - which for a zero limit is every chunk, so content with nowhere further to go leaves its
|
||||
// archive skipped exactly as content exceeding a positive disk limit does. Nothing is charged when it
|
||||
// reports false.
|
||||
// Disk charges n bytes placed on disk, reporting false when the disk limit does not admit it (for a
|
||||
// zero limit, always, which skips the archive). Nothing is charged when it reports false.
|
||||
func (c *Charge) Disk(n int64) bool {
|
||||
if c == nil || c.limiter == nil || n <= 0 {
|
||||
return true
|
||||
@@ -177,18 +141,16 @@ func (c *Charge) Disk(n int64) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// IndexRecord charges the in-memory cost of holding one entry's index record - the *Entry, its
|
||||
// header, and the resolver node built over it - preferring the memory budget and overflowing to the
|
||||
// disk budget when memory will not admit it.
|
||||
// IndexRecord charges the memory an archive's index records keep regardless of content weight,
|
||||
// preferring the memory budget and falling back to disk. It reports false only when neither budget
|
||||
// admits the record, truncating the archive.
|
||||
//
|
||||
// It overflows rather than refusing because the index is not content: a resolver needs an entry's
|
||||
// record to reach that entry's bytes at all, and the memory budget may legitimately be zero, so an
|
||||
// index that could be refused would leave an archive whose content is entirely on disk with no way to
|
||||
// read it back. What this bounds instead is entry count - the one dimension the byte budgets never
|
||||
// bounded, so an archive of millions of empty entries could grow the index without limit. It reports
|
||||
// false only when neither budget admits the record, which truncates the archive. What it charges is
|
||||
// released by Release along with everything else this charge took, so the index accounting falls when
|
||||
// the archive does.
|
||||
// It bounds the dimension the byte budgets miss: an archive of many empty entries, or one entry named
|
||||
// a thousand components deep, weighs almost nothing as content yet would grow the index without limit.
|
||||
//
|
||||
// The disk fallback exists because a resolver needs an entry's record to reach its bytes at all, and
|
||||
// MaxMemoryBytes may legitimately be zero (the configuration that spills every archive). So records
|
||||
// are bounded by the disk budget once memory is full, though they are only ever held in memory.
|
||||
func (c *Charge) IndexRecord(n int64) bool {
|
||||
if c == nil || c.limiter == nil || n <= 0 {
|
||||
return true
|
||||
@@ -199,8 +161,8 @@ func (c *Charge) IndexRecord(n int64) bool {
|
||||
return c.Disk(n)
|
||||
}
|
||||
|
||||
// RefundDisk gives back n bytes charged for content that was removed again, so a partial file
|
||||
// dropped at a limit does not leave the limiter holding bytes that are no longer on disk.
|
||||
// RefundDisk gives back n bytes charged for content since removed, so a partial file dropped at a
|
||||
// limit does not leave the limiter holding bytes no longer on disk.
|
||||
func (c *Charge) RefundDisk(n int64) {
|
||||
if c == nil || c.limiter == nil || n <= 0 {
|
||||
return
|
||||
@@ -230,18 +192,7 @@ func (c *Charge) RefundMemory(n int64) {
|
||||
c.memory -= n
|
||||
}
|
||||
|
||||
// Held reports what this charge is currently holding, in memory and on disk.
|
||||
func (c *Charge) Held() (memory, disk int64) {
|
||||
if c == nil || c.limiter == nil {
|
||||
return 0, 0
|
||||
}
|
||||
c.limiter.mu.Lock()
|
||||
defer c.limiter.mu.Unlock()
|
||||
return c.memory, c.disk
|
||||
}
|
||||
|
||||
// Release returns everything this charge took, so both limits fall by what this archive held. Safe
|
||||
// to call more than once.
|
||||
// Release returns everything this charge took. Safe to call more than once.
|
||||
func (c *Charge) Release() {
|
||||
if c == nil || c.limiter == nil {
|
||||
return
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestLimiter_chargeAndRelease(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLimiter_refund(t *testing.T) {
|
||||
// a partial entry dropped at a bound is no longer on disk, so the limiter must not go on holding it
|
||||
// a partial entry dropped at a bound is no longer on disk, so the limiter must not still hold it
|
||||
limiter := NewLimiter(Limits{MaxDiskBytes: 100})
|
||||
charge := limiter.Charge()
|
||||
|
||||
@@ -55,8 +55,8 @@ func TestLimiter_refund(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLimiter_boundsAreIndependent(t *testing.T) {
|
||||
// a caller may bound one limit without bounding the other: a limit on one side of the pair does
|
||||
// not change how the other side reads its own value.
|
||||
// a caller may bound one limit without the other: a limit on one side does not change how the other
|
||||
// reads its own value
|
||||
memoryBounded := NewLimiter(Limits{MaxMemoryBytes: 10, MaxDiskBytes: -1}).Charge()
|
||||
assert.False(t, memoryBounded.Memory(50))
|
||||
assert.True(t, memoryBounded.Disk(1_000_000), "a negative disk limit admits anything")
|
||||
@@ -67,9 +67,8 @@ func TestLimiter_boundsAreIndependent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLimiter_threeStateReadingIsUniform(t *testing.T) {
|
||||
// Memory and Disk read their limit the same way: positive is the limit, zero refuses
|
||||
// unconditionally, negative admits unconditionally. The two used to disagree in opposite
|
||||
// directions - Memory got zero right and negative wrong, Disk got negative right and zero wrong.
|
||||
// Memory and Disk read their limit the same way: positive is the limit, zero refuses, negative
|
||||
// admits
|
||||
t.Run("positive is the limit", func(t *testing.T) {
|
||||
c := NewLimiter(Limits{MaxMemoryBytes: 100, MaxDiskBytes: 100}).Charge()
|
||||
assert.True(t, c.Memory(100))
|
||||
@@ -93,8 +92,7 @@ func TestLimiter_threeStateReadingIsUniform(t *testing.T) {
|
||||
|
||||
func TestLimiter_nilIsUnbounded(t *testing.T) {
|
||||
// what an extraction with no configured bounds gets: charges succeed and nothing is measured.
|
||||
// This is distinct from a configured but non-positive MaxMemoryBytes, which refuses instead -
|
||||
// there is no limiter here at all to refuse anything.
|
||||
// Distinct from a configured but non-positive MaxMemoryBytes, which refuses instead.
|
||||
var limiter *Limiter
|
||||
charge := limiter.Charge()
|
||||
assert.Nil(t, charge)
|
||||
@@ -104,22 +102,19 @@ func TestLimiter_nilIsUnbounded(t *testing.T) {
|
||||
charge.RefundDisk(10)
|
||||
charge.Release()
|
||||
|
||||
mem, disk := charge.Held()
|
||||
mem, disk := charge.held()
|
||||
assert.Zero(t, mem)
|
||||
assert.Zero(t, disk)
|
||||
|
||||
mem, disk = limiter.InUse()
|
||||
assert.Zero(t, mem)
|
||||
assert.Zero(t, disk)
|
||||
assert.Zero(t, limiter.MaxDiskBytes())
|
||||
}
|
||||
|
||||
func TestLimiter_concurrentChargesAgree(t *testing.T) {
|
||||
// the walk is sequential today, but the archive task merges into a shared builder while top-level
|
||||
// catalogers run, so the accounting is guarded rather than left for whoever parallelises it.
|
||||
// Both limits need an explicit value here: zero now refuses every charge on either bound, so
|
||||
// MaxMemoryBytes is set comfortably above what this test charges and MaxDiskBytes is negative
|
||||
// for unbounded, rather than leaving either at its zero value.
|
||||
// catalogers run, so the accounting is guarded. Both limits need an explicit value, since zero
|
||||
// refuses every charge: memory is set above what this test charges and disk is negative.
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: 1_000_000, MaxDiskBytes: -1})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
@@ -150,8 +145,8 @@ func TestLimiter_concurrentChargesAgree(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_Limiter_peakIsAHighWaterMark(t *testing.T) {
|
||||
// the peak is what the scan held at its worst moment, so it must survive the release that takes
|
||||
// the in-use gauge back down - a peak that fell on release would measure what InUse already does
|
||||
// the peak is what the scan held at its worst moment, so it must survive the release that takes the
|
||||
// in-use gauge back down
|
||||
l := NewLimiter(Limits{MaxMemoryBytes: -1, MaxDiskBytes: -1})
|
||||
|
||||
first := l.Charge()
|
||||
@@ -173,8 +168,7 @@ func Test_Limiter_peakIsAHighWaterMark(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_Limiter_peakCountsConcurrentHoldersTogether(t *testing.T) {
|
||||
// two archives held at the same time peak at their sum, which is the number the limits are
|
||||
// enforced against and therefore the number worth reporting
|
||||
// two archives held at once peak at their sum, the number the limits are enforced against
|
||||
l := NewLimiter(Limits{MaxMemoryBytes: -1, MaxDiskBytes: -1})
|
||||
|
||||
outer := l.Charge()
|
||||
@@ -196,8 +190,8 @@ func Test_Limiter_peakOfANilLimiterIsZero(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_Limiter_refusedChargeDoesNotMoveThePeak(t *testing.T) {
|
||||
// nothing is charged when a limit refuses, so nothing may be recorded either: a peak that rose
|
||||
// on refusal would report bytes the scan never held
|
||||
// nothing is charged when a limit refuses, so nothing may be recorded: a peak that rose on refusal
|
||||
// would report bytes the scan never held
|
||||
l := NewLimiter(Limits{MaxMemoryBytes: 50, MaxDiskBytes: 50})
|
||||
|
||||
c := l.Charge()
|
||||
|
||||
+14
-55
@@ -6,54 +6,15 @@ import (
|
||||
|
||||
// Notify receives structured events from archive extraction. A nil Notify is the no-op.
|
||||
//
|
||||
// The signature is deliberately `func(any)` rather than a set of typed callbacks or an interface with
|
||||
// a method per event. Extraction is a hot path - a scan of a big image enters thousands of archives -
|
||||
// and what instrumentation costs there has to be nothing when it is off. A single func field is one
|
||||
// nil check, and a handler that cares about one event type asserts for it and ignores the rest, so
|
||||
// adding an event never changes a signature or breaks a handler that did not ask for it.
|
||||
//
|
||||
// Guard the call rather than relying on a nil check inside the callee:
|
||||
// func(any) rather than typed callbacks so adding an event changes no signature: a handler asserts
|
||||
// for the events it wants and ignores the rest. Boxing into an any allocates, so on this hot path
|
||||
// guard the call rather than relying on a nil check inside the callee:
|
||||
//
|
||||
// if notify != nil {
|
||||
// notify(EntriesOverflowed{Archive: name, Bytes: n})
|
||||
// }
|
||||
//
|
||||
// Building the event value is what costs - putting a struct in an `any` allocates - so the guard is
|
||||
// what makes "off" free rather than merely cheap. Send exists for the paths where that does not
|
||||
// matter.
|
||||
type Notify func(msg any)
|
||||
|
||||
// Send delivers one event, doing nothing when there is no handler. Convenient, but it builds the
|
||||
// event whether or not anyone is listening: on a hot path, guard the call instead.
|
||||
func (n Notify) Send(msg any) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
n(msg)
|
||||
}
|
||||
|
||||
// Tee returns a Notify that delivers to each of the given handlers, skipping the nil ones, and nil
|
||||
// when none of them are live - so a tee of nothing stays free.
|
||||
func Tee(handlers ...Notify) Notify {
|
||||
var live []Notify
|
||||
for _, h := range handlers {
|
||||
if h != nil {
|
||||
live = append(live, h)
|
||||
}
|
||||
}
|
||||
switch len(live) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return live[0]
|
||||
}
|
||||
return func(msg any) {
|
||||
for _, h := range live {
|
||||
h(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OverflowReason says why content was written to disk rather than held.
|
||||
type OverflowReason string
|
||||
|
||||
@@ -61,12 +22,12 @@ const (
|
||||
// MemoryLimitReached means the scan was already holding as much as the memory limit admits.
|
||||
MemoryLimitReached OverflowReason = "memory limit reached"
|
||||
|
||||
// MemoryHoldsNothing means the memory limit is zero, so nothing is ever held and every archive's
|
||||
// content goes to disk by policy rather than by pressure.
|
||||
// MemoryHoldsNothing means the memory limit is zero, so content goes to disk by policy rather than
|
||||
// under pressure.
|
||||
MemoryHoldsNothing OverflowReason = "memory limit is zero"
|
||||
)
|
||||
|
||||
// ContentOverflowed reports that one archive's own bytes were written to disk instead of being held in
|
||||
// ContentOverflowed reports that one archive's own bytes were written to disk rather than held in
|
||||
// memory. Bytes is the size of the archive.
|
||||
type ContentOverflowed struct {
|
||||
Archive string
|
||||
@@ -74,9 +35,9 @@ type ContentOverflowed struct {
|
||||
Reason OverflowReason
|
||||
}
|
||||
|
||||
// EntriesOverflowed reports that the entries an archive's store was holding have been written to its
|
||||
// overflow blob. Entries and Bytes are what moved, not what the archive holds in total: a store spills
|
||||
// everything it is holding at once, and may spill more than once as it fills again.
|
||||
// EntriesOverflowed reports that the entries a store was holding were written to its overflow blob.
|
||||
// Entries and Bytes are what moved in this spill, not the archive's totals: a store spills everything
|
||||
// it holds at once, and may spill again as it refills.
|
||||
type EntriesOverflowed struct {
|
||||
Archive string
|
||||
Entries int
|
||||
@@ -84,23 +45,21 @@ type EntriesOverflowed struct {
|
||||
Reason OverflowReason
|
||||
}
|
||||
|
||||
// Skipped reports that an archive contributed nothing, and why. Not a failure - the scan
|
||||
// continues - but the packages inside it are absent from the SBOM, which is worth being able to see.
|
||||
// Skipped reports that an archive contributed nothing, and why. The scan continues, but any packages
|
||||
// inside that archive are absent from the SBOM.
|
||||
type Skipped struct {
|
||||
Archive string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Truncated reports that an archive was cataloged from part of its contents, naming the bound
|
||||
// that stopped it.
|
||||
// Truncated reports that an archive was cataloged from part of its contents, naming the bound that
|
||||
// stopped it.
|
||||
type Truncated struct {
|
||||
Archive string
|
||||
Reason TruncationReason
|
||||
}
|
||||
|
||||
// LogNotify is the default handler: it writes each event to the debug log. Anything it does not
|
||||
// recognize is ignored rather than logged blindly, so an event added for one consumer does not turn
|
||||
// into noise for everyone.
|
||||
// LogNotify is the default handler: it writes each known event to the debug log and ignores the rest.
|
||||
func LogNotify(msg any) {
|
||||
switch e := msg.(type) {
|
||||
case ContentOverflowed:
|
||||
|
||||
@@ -10,23 +10,15 @@ import (
|
||||
)
|
||||
|
||||
// recorder is what a consumer of these events looks like: one func, a type assertion for what it
|
||||
// cares about, and silence for everything else.
|
||||
// cares about, silence for everything else.
|
||||
type recorder struct {
|
||||
overflows []EntriesOverflowed
|
||||
other int
|
||||
}
|
||||
|
||||
func (r *recorder) notify(msg any) {
|
||||
if e, ok := msg.(EntriesOverflowed); ok {
|
||||
r.overflows = append(r.overflows, e)
|
||||
return
|
||||
}
|
||||
r.other++
|
||||
}
|
||||
|
||||
func TestNotify_reportsWhichArchiveSpilledAndWhy(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
s := NewEntryStore(t.TempDir(), "lib/app.jar", rec.notify)
|
||||
s := NewEntryStore(WorkDirAt(t.TempDir()), "lib/app.jar", rec.notify)
|
||||
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||
|
||||
charge := memCharge(8)
|
||||
@@ -44,9 +36,9 @@ func TestNotify_reportsWhichArchiveSpilledAndWhy(t *testing.T) {
|
||||
|
||||
func TestNotify_distinguishesPressureFromPolicy(t *testing.T) {
|
||||
// a scan at its memory bound and a scan whose bound is zero are different findings: the first says
|
||||
// the limit is biting, the second says nothing was ever going to be held
|
||||
// the limit is biting, the second that nothing was ever going to be held
|
||||
rec := &recorder{}
|
||||
s := NewEntryStore(t.TempDir(), "app.jar", rec.notify)
|
||||
s := NewEntryStore(WorkDirAt(t.TempDir()), "app.jar", rec.notify)
|
||||
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||
|
||||
_, err := s.Add(regularHeader("a.txt", 3), bytes.NewReader([]byte("abc")), memCharge(0))
|
||||
@@ -57,42 +49,28 @@ func TestNotify_distinguishesPressureFromPolicy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNotify_nilHandlerIsTheNoOp(t *testing.T) {
|
||||
s := NewEntryStore(t.TempDir(), "app.jar", nil)
|
||||
s := NewEntryStore(WorkDirAt(t.TempDir()), "app.jar", nil)
|
||||
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||
|
||||
_, err := s.Add(regularHeader("a.txt", 3), bytes.NewReader([]byte("abc")), memCharge(0))
|
||||
assert.NoError(t, err, "no handler must not mean no extraction")
|
||||
}
|
||||
|
||||
func TestNotify_teeDeliversToEveryLiveHandler(t *testing.T) {
|
||||
first, second := &recorder{}, &recorder{}
|
||||
tee := Tee(nil, first.notify, nil, second.notify)
|
||||
require.NotNil(t, tee)
|
||||
|
||||
tee(EntriesOverflowed{Archive: "app.jar", Entries: 1})
|
||||
|
||||
assert.Len(t, first.overflows, 1)
|
||||
assert.Len(t, second.overflows, 1)
|
||||
}
|
||||
|
||||
func TestNotify_teeOfNothingIsNil(t *testing.T) {
|
||||
// a tee that would deliver to no one stays nil, so the guard at every call site keeps it free
|
||||
assert.Nil(t, Tee())
|
||||
assert.Nil(t, Tee(nil, nil))
|
||||
}
|
||||
|
||||
func TestNotify_sendIsNilSafe(t *testing.T) {
|
||||
var n Notify
|
||||
n.Send(EntriesOverflowed{})
|
||||
}
|
||||
|
||||
func TestNotify_directoryEntriesDoNotSpill(t *testing.T) {
|
||||
// a directory carries no content, so it can never be the thing that pushes a store to disk
|
||||
rec := &recorder{}
|
||||
s := NewEntryStore(t.TempDir(), "app.jar", rec.notify)
|
||||
s := NewEntryStore(WorkDirAt(t.TempDir()), "app.jar", rec.notify)
|
||||
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||
|
||||
_, err := s.Add(tar.Header{Name: "lib/", Typeflag: tar.TypeDir, Mode: 0o755}, nil, memCharge(0))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, rec.overflows)
|
||||
}
|
||||
|
||||
func (r *recorder) notify(msg any) {
|
||||
if e, ok := msg.(EntriesOverflowed); ok {
|
||||
r.overflows = append(r.overflows, e)
|
||||
return
|
||||
}
|
||||
r.other++
|
||||
}
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mholt/archives"
|
||||
|
||||
"github.com/anchore/syft/internal/log"
|
||||
)
|
||||
|
||||
// tarBlockSize is the tar format's fixed block size. Every header occupies a whole number of blocks
|
||||
// and every entry's data is padded up to one, which is why the bytes an archive puts on disk are not
|
||||
// the sum of its entry sizes - see OverflowTar.
|
||||
const tarBlockSize = 512
|
||||
|
||||
// overflowTarName is the basename of the one file an archive's entries are written into. It sits beside
|
||||
// the archive's logical root rather than inside it, so nothing in the archive's own filesystem can
|
||||
// name it.
|
||||
const overflowTarName = "contents.tar"
|
||||
|
||||
// OverflowTar is the single file one archive's entries are overflowed into.
|
||||
//
|
||||
// The point of the whole shape is that a scan's filesystem cost tracks the number of archives it
|
||||
// finds rather than the number of entries those archives hold. Expanding every entry as its own file
|
||||
// makes an archive of a hundred thousand entries a hundred thousand inodes - exhausting inodes on a
|
||||
// filesystem with bytes to spare, and making cleanup proportional to content as well. One tar per
|
||||
// archive is one inode per archive, and stereoscope's TarIndex then gives direct access to any entry
|
||||
// by seek offset, so nothing has to be re-read or re-decompressed to reach the end of it.
|
||||
//
|
||||
// It is written by hand rather than through a tar.Writer because tar wants an entry's size in the
|
||||
// header, before its data, and a size an archive declares is exactly what must not be trusted: both
|
||||
// tar and zip headers carry a size and a crafted archive is free to lie about it. So each entry's
|
||||
// header is reserved, its data is copied and charged as it lands, and the header is then written over
|
||||
// the reservation with the size that actually arrived. The reservation is exact because the header is
|
||||
// encoded as GNU, whose numeric fields are fixed width - the encoded length of a header therefore
|
||||
// depends on its name and link target and not at all on its size. That is asserted per entry rather
|
||||
// than assumed, and an entry whose two encodings disagree is dropped rather than corrupting the tar.
|
||||
type OverflowTar struct {
|
||||
path string
|
||||
file *os.File
|
||||
|
||||
// offset is where the next byte written will land, which is also the number of bytes this tar
|
||||
// holds. Tracked rather than queried so a header can be written back over its reservation.
|
||||
offset int64
|
||||
|
||||
// verbatim records that this tar was copied wholesale from an already-tar-shaped stream rather
|
||||
// than assembled entry by entry, in which case it carries the source's own end-of-archive marker
|
||||
// and must not be given a second one.
|
||||
verbatim bool
|
||||
}
|
||||
|
||||
func newOverflowTar(path string) (*OverflowTar, error) {
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to create overflow tar %q: %w", path, err)
|
||||
}
|
||||
return &OverflowTar{path: path, file: f}, nil
|
||||
}
|
||||
|
||||
// Path is where this tar lives, which is what stereoscope's TarIndex needs: it indexes by seek
|
||||
// offset and therefore wants a seekable, uncompressed file rather than a stream.
|
||||
func (s *OverflowTar) Path() string {
|
||||
return s.path
|
||||
}
|
||||
|
||||
func (s *OverflowTar) Close() error {
|
||||
if s == nil || s.file == nil {
|
||||
return nil
|
||||
}
|
||||
err := s.file.Close()
|
||||
s.file = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// put charges b to the disk limit and writes it, reporting whether the limit refused it. Nothing is
|
||||
// written when the charge is refused, so the tar never holds bytes the limiter is not accounting for.
|
||||
func (s *OverflowTar) put(b []byte, result *ExtractionResult, limits ExtractionLimits) (refused bool, err error) {
|
||||
if len(b) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if !limits.Charge.Disk(int64(len(b))) {
|
||||
return true, nil
|
||||
}
|
||||
n, err := s.file.Write(b)
|
||||
s.offset += int64(n)
|
||||
result.BytesWritten += int64(n)
|
||||
if n < len(b) {
|
||||
limits.Charge.RefundDisk(int64(len(b) - n))
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// AddEntry writes one archive entry into the tar: a header, its data, and the padding that takes the
|
||||
// entry up to a block boundary.
|
||||
//
|
||||
// Directory entries carry no data, so what bounds an archive of a million of them is the disk limit
|
||||
// charging their headers and padding, which is the only resource they actually consume.
|
||||
func (s *OverflowTar) AddEntry(f archives.FileInfo, result *ExtractionResult, limits ExtractionLimits) error {
|
||||
hdr, ok := entryHeader(f)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
reserved, err := encodeHeader(hdr, 0)
|
||||
if err != nil {
|
||||
// GNU could not represent this entry; let the tar package pick a format it can, and rely on
|
||||
// the length check below to drop the entry if that choice makes the length size-dependent
|
||||
hdr.Format = tar.FormatUnknown
|
||||
reserved, err = encodeHeader(hdr, 0)
|
||||
if err != nil {
|
||||
log.WithFields("entry", f.NameInArchive, "error", err).
|
||||
Debug("skipping archive entry whose header cannot be encoded")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
start := s.offset
|
||||
if refused, err := s.put(reserved, result, limits); err != nil {
|
||||
return err
|
||||
} else if refused {
|
||||
return s.dropEntry(start, result, limits)
|
||||
}
|
||||
|
||||
written, refused, err := s.copyEntryData(f, hdr, result, limits)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if refused {
|
||||
// this entry would have taken the scan's disk usage past the disk limit. Drop it rather than
|
||||
// leaving a silently short one behind: a half-written jar or pom is worse than an absent one,
|
||||
// because a cataloger will try to parse it and may believe what it finds.
|
||||
return s.dropEntry(start, result, limits)
|
||||
}
|
||||
|
||||
final, err := encodeHeader(hdr, written)
|
||||
if err != nil || len(final) != len(reserved) {
|
||||
// the reservation and the real header disagree on length, so writing the real header would
|
||||
// either overwrite the entry's first bytes or leave a gap - either way an unreadable tar.
|
||||
// GNU's fixed-width numeric fields make this unreachable in practice; it is checked because
|
||||
// the alternative to checking is silent corruption.
|
||||
log.WithFields("entry", f.NameInArchive, "error", err).
|
||||
Debug("skipping archive entry whose header length depends on its size")
|
||||
return s.dropEntry(start, result, limits)
|
||||
}
|
||||
if _, err := s.file.WriteAt(final, start); err != nil {
|
||||
return fmt.Errorf("unable to write header for archive entry %q: %w", f.NameInArchive, err)
|
||||
}
|
||||
|
||||
if pad := (tarBlockSize - written%tarBlockSize) % tarBlockSize; pad > 0 {
|
||||
if refused, err := s.put(make([]byte, pad), result, limits); err != nil {
|
||||
return err
|
||||
} else if refused {
|
||||
return s.dropEntry(start, result, limits)
|
||||
}
|
||||
}
|
||||
|
||||
result.FilesExtracted++
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyEntryData copies one entry's content into the tar, charging every chunk as it lands, and reports
|
||||
// how much actually arrived. A size the archive declared is never used: both tar and zip headers carry
|
||||
// one and a crafted archive is free to lie about it, so the header is written afterwards from what was
|
||||
// counted here. An entry carrying no data - a directory, a link - copies nothing.
|
||||
func (s *OverflowTar) copyEntryData(f archives.FileInfo, hdr *tar.Header, result *ExtractionResult, limits ExtractionLimits) (written int64, refused bool, err error) {
|
||||
if hdr.Typeflag != tar.TypeReg {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
src, err := f.Open()
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("unable to open archive entry %q: %w", f.NameInArchive, err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := src.Close(); closeErr != nil {
|
||||
log.WithFields("entry", f.NameInArchive, "error", closeErr).Trace("unable to close archive entry")
|
||||
}
|
||||
}()
|
||||
|
||||
written, refused, err = copyCharged(s.file, src, limits.Charge)
|
||||
s.offset += written
|
||||
result.BytesWritten += written
|
||||
return written, refused, err
|
||||
}
|
||||
|
||||
// dropEntry rewinds the tar to where the entry being written began, refunding what that entry
|
||||
// charged since those bytes are no longer on disk, and stops the extraction. Whatever was written
|
||||
// before this entry is a complete, readable tar and is still cataloged; see ExtractionResult.
|
||||
func (s *OverflowTar) dropEntry(start int64, result *ExtractionResult, limits ExtractionLimits) error {
|
||||
spent := s.offset - start
|
||||
if err := s.file.Truncate(start); err != nil {
|
||||
log.WithFields("path", s.path, "error", err).Trace("unable to rewind overflow tar past an over-limit entry")
|
||||
}
|
||||
if _, err := s.file.Seek(start, io.SeekStart); err != nil {
|
||||
log.WithFields("path", s.path, "error", err).Trace("unable to seek overflow tar past an over-limit entry")
|
||||
}
|
||||
s.offset = start
|
||||
limits.Charge.RefundDisk(spent)
|
||||
result.BytesWritten -= spent
|
||||
if result.Truncation == "" {
|
||||
result.Truncation = TruncatedByDiskLimit
|
||||
}
|
||||
return errTruncated
|
||||
}
|
||||
|
||||
// CopyVerbatim writes an already-tar-shaped stream into the overflow file as it stands, which is what a
|
||||
// tar-family archive gets: a compressed tar is decompressed once into a plain tar rather than
|
||||
// expanded entry by entry, and a plain tar is copied. Charging is per chunk as the bytes land, so a
|
||||
// decompression bomb is bounded by the disk limit rather than by what the archive claimed.
|
||||
//
|
||||
// A refused chunk leaves the tar ending part way through an entry. That is deliberately not repaired
|
||||
// here: indexing keeps every entry it could read and reports the tail as a truncation, which is the
|
||||
// same outcome as stopping on a whole-entry boundary and needs no second mechanism.
|
||||
func (s *OverflowTar) CopyVerbatim(r io.Reader, result *ExtractionResult, limits ExtractionLimits) error {
|
||||
s.verbatim = true
|
||||
|
||||
written, refused, err := copyCharged(s.file, r, limits.Charge)
|
||||
s.offset += written
|
||||
result.BytesWritten += written
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if refused {
|
||||
result.Truncation = TruncatedByDiskLimit
|
||||
return errTruncated
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Finish writes the tar's end-of-archive marker.
|
||||
//
|
||||
// A marker that does not fit within the disk limit is not an error: Go's tar reader stops at the
|
||||
// first zero block or at the end of the file, so a tar that simply ends reads exactly the same.
|
||||
func (s *OverflowTar) Finish(result *ExtractionResult, limits ExtractionLimits) {
|
||||
if s.verbatim {
|
||||
// the source stream carried its own marker
|
||||
return
|
||||
}
|
||||
if refused, err := s.put(make([]byte, 2*tarBlockSize), result, limits); err != nil || refused {
|
||||
log.WithFields("path", s.path, "error", err).Trace("overflow tar written without an end-of-archive marker")
|
||||
}
|
||||
}
|
||||
|
||||
// entryHeader builds the tar header for one archive entry, or reports false when the entry is not
|
||||
// something worth carrying.
|
||||
//
|
||||
// Symlinks are recorded as symlink headers rather than being created and checked as real links,
|
||||
// because writing a header traverses no path: nothing is created, so there is nothing for a crafted
|
||||
// target to redirect. Where a link points is decided when the archive's filetree is built, inside
|
||||
// that tree, so a target naming the host filesystem resolves to nothing rather than to the host.
|
||||
//
|
||||
// Device nodes, fifos and sockets are skipped, as they were when entries were files: they cannot
|
||||
// round-trip and no cataloger can read one.
|
||||
func 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 nil, 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 nil, false
|
||||
}
|
||||
|
||||
// the name is carried through as the archive gave it, unsanitized. Nothing is written through it
|
||||
// - it is a field in a header - and the one place it can still mislead is the logical filetree,
|
||||
// so it is sanitized there, once, rather than in each of the two paths that write a tar.
|
||||
hdr.Name = f.NameInArchive
|
||||
if f.IsDir() && !strings.HasSuffix(hdr.Name, "/") {
|
||||
hdr.Name += "/"
|
||||
}
|
||||
hdr.Linkname = f.LinkTarget
|
||||
|
||||
// GNU rather than the format the tar package would pick: its numeric fields are fixed width, so
|
||||
// the encoded length of a header does not depend on the size recorded in it. That is what lets
|
||||
// the header be reserved before the entry's real size is known and written afterwards. Fields GNU
|
||||
// cannot carry are cleared instead of forcing the encoder into PAX, where a large size becomes an
|
||||
// extra record and the length stops being predictable.
|
||||
hdr.Format = tar.FormatGNU
|
||||
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
|
||||
}
|
||||
|
||||
// encodeHeader returns the bytes one header occupies in a tar, for the given size. It includes
|
||||
// whatever the format needs around it - a GNU long-name record for a name a header field cannot
|
||||
// hold - because all of it is written before the entry's data and all of it has to be reserved.
|
||||
func encodeHeader(hdr *tar.Header, size int64) ([]byte, error) {
|
||||
h := *hdr
|
||||
h.Size = size
|
||||
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
// WriteHeader emits the header, and any record the format needs ahead of it, before it returns.
|
||||
// Close is deliberately not called: it would append the end-of-archive marker, which belongs to
|
||||
// the tar being assembled and not to one header in it.
|
||||
if err := tw.WriteHeader(&h); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// tarStream returns the plain-tar byte stream for a tar-family archive, decompressing once where the
|
||||
// archive is a compressed tar, and reports false for anything that is not a tar.
|
||||
//
|
||||
// This is what makes a compressed tar strictly cheaper than it was: it used to be expanded entry by
|
||||
// entry into a tree of files, and here it is decompressed once into a file that is already the
|
||||
// storage format, then indexed by seek offset. A plain tar is copied for the same reason - TarIndex
|
||||
// needs a seekable uncompressed file, and the archive's own bytes may be held in memory.
|
||||
func tarStream(format archives.Format, r io.Reader) (io.ReadCloser, bool) {
|
||||
switch f := format.(type) {
|
||||
case archives.Tar:
|
||||
return io.NopCloser(r), true
|
||||
case archives.CompressedArchive:
|
||||
if !isTarFormat(f) {
|
||||
return nil, false
|
||||
}
|
||||
if f.Compression == nil {
|
||||
return io.NopCloser(r), true
|
||||
}
|
||||
rc, err := f.OpenReader(r)
|
||||
if err != nil {
|
||||
log.WithFields("error", err).Debug("unable to decompress tar-family archive")
|
||||
return nil, false
|
||||
}
|
||||
return rc, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// isTarFormat reports whether the archival half of a compressed archive is a tar. Identify sets both
|
||||
// halves for a compressed tar, so either answers; both are checked because only one is guaranteed to
|
||||
// be non-nil in general.
|
||||
func isTarFormat(f archives.CompressedArchive) bool {
|
||||
if _, ok := f.Archival.(archives.Tar); ok {
|
||||
return true
|
||||
}
|
||||
_, ok := f.Extraction.(archives.Tar)
|
||||
return ok
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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"
|
||||
)
|
||||
|
||||
// manyEntryZip builds a zip of count small entries, in sorted name order.
|
||||
func manyEntryZip(t *testing.T, count int) *bytes.Buffer {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for i := range count {
|
||||
w, err := zw.Create(fmt.Sprintf("e%06d.txt", i))
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte("x"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, zw.Close())
|
||||
return &buf
|
||||
}
|
||||
|
||||
// extractedOverflow extracts one archive and hands back where its content went, along with the work
|
||||
// directory holding it - which is what a filesystem cost has to be measured against.
|
||||
func extractedOverflow(t *testing.T, content io.Reader, limits ExtractionLimits) (*ExtractedArchive, Overflow, string) {
|
||||
t.Helper()
|
||||
|
||||
var got Overflow
|
||||
factory := func(overflow Overflow) (file.Resolver, IndexResult, error) {
|
||||
got = overflow
|
||||
return nil, IndexResult{}, nil
|
||||
}
|
||||
|
||||
extracted, err := ExtractToResolver(
|
||||
context.Background(), content, "app.zip", "", "app.zip",
|
||||
DefaultExtractors(), NewLimiter(Limits{MaxMemoryBytes: -1, MaxDiskBytes: -1}), limits, factory, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, extracted)
|
||||
t.Cleanup(extracted.Cleanup)
|
||||
|
||||
return extracted, got, filepath.Dir(got.TarPath)
|
||||
}
|
||||
|
||||
// filesystemEntries is every directory entry under dir, path by path, which is the cost the
|
||||
// requirement is about: an archive must not put one of these on the filesystem per entry it holds.
|
||||
func filesystemEntries(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
require.NoError(t, filepath.WalkDir(dir, func(p string, _ fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, relErr := filepath.Rel(dir, p)
|
||||
if relErr != nil {
|
||||
return relErr
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
out = append(out, filepath.ToSlash(rel))
|
||||
return nil
|
||||
}))
|
||||
return out
|
||||
}
|
||||
|
||||
func TestOverflow_filesystemCostDoesNotTrackEntryCount(t *testing.T) {
|
||||
// the requirement this whole shape exists for: an archive of a hundred thousand entries must not
|
||||
// put a hundred thousand files on the filesystem. Asserted as an exact set of paths for two
|
||||
// archives three orders of magnitude apart in entry count, rather than as a bound - a count that
|
||||
// merely grows slowly would pass a bound and still be proportional to content.
|
||||
small, smallOverflow, smallWork := extractedOverflow(t, manyEntryZip(t, 2), ExtractionLimits{})
|
||||
large, largeOverflow, largeWork := extractedOverflow(t, manyEntryZip(t, 5000), ExtractionLimits{})
|
||||
|
||||
want := []string{"contents", "contents.tar"}
|
||||
assert.Equal(t, want, filesystemEntries(t, smallWork))
|
||||
assert.Equal(t, want, filesystemEntries(t, largeWork),
|
||||
"5000 entries must cost the same filesystem entries as 2: one tar, plus the empty root their "+
|
||||
"paths are reported relative to")
|
||||
|
||||
// and the entries really are all there, so this is not passing because nothing was extracted
|
||||
assert.Equal(t, 2, countTarEntries(t, smallOverflow.TarPath))
|
||||
assert.Equal(t, 5000, countTarEntries(t, largeOverflow.TarPath))
|
||||
// what one archive costs on disk, exactly: a header block and a padded data block per entry, plus
|
||||
// the two-block end-of-archive marker. This is the figure the disk limit is charged, which is why
|
||||
// a limit expressed in entry bytes now under-counts.
|
||||
assert.Equal(t, int64((5000*2+2)*tarBlockSize), large.Result.BytesWritten)
|
||||
assert.Equal(t, int64((2*2+2)*tarBlockSize), small.Result.BytesWritten)
|
||||
}
|
||||
|
||||
func TestOverflow_cleanupRemovesOneFileAndDoesNotWalkPerEntry(t *testing.T) {
|
||||
// cleanup was proportional to content too: releasing an archive meant removing one filesystem
|
||||
// entry per archive entry. It is now bounded by the same constant as the extraction, so the count
|
||||
// of things removed is asserted rather than just the fact that the directory is gone.
|
||||
extracted, overflow, workDir := extractedOverflow(t, manyEntryZip(t, 5000), ExtractionLimits{})
|
||||
|
||||
before := filesystemEntries(t, workDir)
|
||||
require.Len(t, before, 2, "the release has two things to remove whatever the archive held")
|
||||
|
||||
extracted.Cleanup()
|
||||
|
||||
_, err := os.Stat(overflow.TarPath)
|
||||
assert.True(t, os.IsNotExist(err), "the one file holding every entry must be gone")
|
||||
_, err = os.Stat(workDir)
|
||||
assert.True(t, os.IsNotExist(err), "and so must the work directory")
|
||||
|
||||
// safe to call more than once, since the cataloger defers it past the sub-pipeline and past
|
||||
// recursion
|
||||
extracted.Cleanup()
|
||||
}
|
||||
|
||||
func TestOverflow_anEntryAtTheEndIsReadBySeeking(t *testing.T) {
|
||||
// "overflow content MUST remain directly readable without re-reading or re-decompressing what
|
||||
// precedes it". A design that met the entry-count requirement by re-streaming the whole archive
|
||||
// per read would not satisfy it, so what is asserted here is the mechanism: the index records
|
||||
// where the last entry's content begins, near the end of a tar whose earlier entries are large,
|
||||
// and the reader it hands back is a bounded window onto the file at that offset.
|
||||
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())
|
||||
|
||||
_, overflow, _ := extractedOverflow(t, &buf, ExtractionLimits{})
|
||||
|
||||
info, err := os.Stat(overflow.TarPath)
|
||||
require.NoError(t, err)
|
||||
total := info.Size()
|
||||
require.Greater(t, total, int64(8*filler), "the fixture must put real bulk ahead of the last entry")
|
||||
|
||||
// the last entry's content sits near the end of the tar, which is the only reason a seek to it is
|
||||
// worth anything: everything before it is what a re-streaming design would have had to read
|
||||
offset := int64(bytes.Index(readFile(t, overflow.TarPath), []byte(wanted)))
|
||||
require.Positive(t, offset)
|
||||
assert.Greater(t, offset, total*9/10, "the last entry must really be at the end of the tar")
|
||||
|
||||
entry := tarEntryNamed(t, overflow.TarPath, "zz-last.txt")
|
||||
|
||||
contents := entry.Open()
|
||||
t.Cleanup(func() { require.NoError(t, contents.Close()) })
|
||||
|
||||
random, ok := contents.(interface {
|
||||
io.ReaderAt
|
||||
io.Seeker
|
||||
})
|
||||
require.True(t, ok, "the entry reader must be random access, or a read cannot be a seek")
|
||||
|
||||
// the reader is bounded to this entry rather than being a view of the whole tar: its end is the
|
||||
// entry's own size, so reading it cannot have read what precedes it
|
||||
end, err := random.Seek(0, io.SeekEnd)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(len(wanted)), end)
|
||||
|
||||
// and offset zero of the reader is the last entry's first byte, not the tar's. A reader over the
|
||||
// whole file would answer this with the first filler entry's content.
|
||||
head := make([]byte, 8)
|
||||
_, err = random.ReadAt(head, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, wanted[:8], string(head))
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
return body
|
||||
}
|
||||
|
||||
// countTarEntries is how many entries a tar on disk holds, read through the same index the resolver
|
||||
// builds its filesystem from.
|
||||
func countTarEntries(t *testing.T, tarPath string) int {
|
||||
t.Helper()
|
||||
count := 0
|
||||
_, err := stereoscopeFile.NewTarIndex(tarPath, func(stereoscopeFile.TarIndexEntry) error {
|
||||
count++
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return count
|
||||
}
|
||||
|
||||
// tarEntryNamed is the index record for one entry of a tar, which is what carries its seek offset.
|
||||
func tarEntryNamed(t *testing.T, tarPath, name string) stereoscopeFile.TarIndexEntry {
|
||||
t.Helper()
|
||||
var found *stereoscopeFile.TarIndexEntry
|
||||
_, err := stereoscopeFile.NewTarIndex(tarPath, func(entry stereoscopeFile.TarIndexEntry) error {
|
||||
if entry.ToTarFileEntry().Header.Name == name {
|
||||
e := entry
|
||||
found = &e
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, found, "no entry %q in %q", name, tarPath)
|
||||
return *found
|
||||
}
|
||||
@@ -9,25 +9,21 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The path-safety guards used to be exercised through extraction, because extraction created a file
|
||||
// or a link per archive entry and every one of them went through them. An entry is a tar header now,
|
||||
// so those tests assert something else and these guards need their own.
|
||||
// The path-safety guards are covered here directly, since extraction no longer writes a file or link
|
||||
// per archive entry:
|
||||
//
|
||||
// They are covered here rather than retired because their situations differ:
|
||||
// resolvesInsideRoot, resolveLink and isWithin guard the one filesystem write left in this package -
|
||||
// an archive's own bytes spilling into its work directory, under a name derived from where the
|
||||
// archive was found, which inside another archive is attacker-supplied text.
|
||||
//
|
||||
// - resolvesInsideRoot, resolveLink and isWithin guard the one filesystem write left in this
|
||||
// package - an archive's own bytes overflowing into its work directory, under a name derived from
|
||||
// where the archive was found, which inside another archive is text the archive supplied.
|
||||
// - writeSafeSymlink has no caller at all. A symlink entry is a header write, which creates
|
||||
// nothing, and where the link points is decided inside the archive's own filetree. It is kept
|
||||
// with its reasoning intact, and whether it retires is a judgement about every path that could
|
||||
// write a link rather than about one call site.
|
||||
// A symlink entry creates nothing on disk: it is stored as a header, and where the link points is
|
||||
// decided inside the archive's own filetree.
|
||||
|
||||
func TestResolvesInsideRoot_multiStepSymlinkChainIsRefused(t *testing.T) {
|
||||
// the escape the guard exists for: two cooperating links each pass a lexical check and together
|
||||
// resolve above the root. "d" -> "." lands on the root, so "d/up" -> ".." is created as
|
||||
// "<root>/up" -> ".." and points above it, and a write through "d/up/x" lands outside. A lexical
|
||||
// check sees <root>/d/../x and allows it; only resolving against the real filesystem catches it.
|
||||
// resolve above the root. "d" -> "." lands on the root, so "d/up" -> ".." becomes "<root>/up" ->
|
||||
// ".." pointing above it, and a write through "d/up/x" lands outside. A lexical check sees
|
||||
// <root>/d/../x and allows it; only resolving against the real filesystem catches it.
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "contents")
|
||||
require.NoError(t, os.MkdirAll(root, 0o755))
|
||||
@@ -67,104 +63,34 @@ func TestResolvesInsideRoot_siblingSharingThePrefixIsRefused(t *testing.T) {
|
||||
assert.False(t, inside)
|
||||
}
|
||||
|
||||
func TestWriteSafeSymlink(t *testing.T) {
|
||||
// asserted with no production caller, deliberately: see the note at the top of this file. The
|
||||
// reasoning in these cases is what makes the function worth keeping, so it is kept executable
|
||||
// rather than left as prose about code nothing runs.
|
||||
newRoot := func(t *testing.T) string {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "contents")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "bin"), 0o755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "lib"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "lib", "real.txt"), []byte("real"), 0o600))
|
||||
return root
|
||||
}
|
||||
func TestResolvesInsideRoot_KNOWN_LIMIT_aLinkWhoseOwnTargetPathLeavesTheRoot(t *testing.T) {
|
||||
// KNOWN GAP, recorded as a finding rather than a passing assertion of safety.
|
||||
//
|
||||
// Two links then a write: "up" -> "..", then "bin/link.txt" -> "../up/PWNED", then a write to
|
||||
// "bin/link.txt/through". resolvesInsideRoot walks the write path, meets bin/link.txt, and resolves
|
||||
// its target lexically without walking the target's own path, never seeing that <root>/up is itself
|
||||
// a link out of the root. On the real filesystem the write lands above the root.
|
||||
//
|
||||
// Not a live exposure: resolvesInsideRoot's only caller writes one flat name into a work directory
|
||||
// it just created, where no link can be planted. Anything that gives it a caller over
|
||||
// archive-supplied paths must close this first.
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "contents")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "bin"), 0o755))
|
||||
require.NoError(t, os.Symlink("..", filepath.Join(root, "up")))
|
||||
require.NoError(t, os.Symlink("../up/PWNED", filepath.Join(root, "bin", "link.txt")))
|
||||
|
||||
t.Run("a relative target inside the root is written", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
dest := filepath.Join(root, "bin", "link.txt")
|
||||
require.NoError(t, writeSafeSymlink("../lib/real.txt", dest, root))
|
||||
|
||||
body, err := os.ReadFile(dest)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "real", string(body))
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
target string
|
||||
}{
|
||||
{name: "an empty target", target: ""},
|
||||
{
|
||||
// os.Symlink writes the literal string, so reading the link resolves on the host whatever
|
||||
// was checked when it was created
|
||||
name: "an absolute target",
|
||||
target: "/etc/passwd",
|
||||
},
|
||||
{name: "a target climbing out of the root", target: "../../../../etc/passwd"},
|
||||
} {
|
||||
t.Run(tc.name+" is refused", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
dest := filepath.Join(root, "bin", "link.txt")
|
||||
require.Error(t, writeSafeSymlink(tc.target, dest, root))
|
||||
|
||||
_, err := os.Lstat(dest)
|
||||
assert.True(t, os.IsNotExist(err), "nothing may be created for a refused link")
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a target that resolves out of the root through an earlier link is refused", func(t *testing.T) {
|
||||
// the target itself can run through a link an earlier entry left behind
|
||||
root := newRoot(t)
|
||||
base := filepath.Dir(root)
|
||||
require.NoError(t, os.Symlink("..", filepath.Join(root, "up")))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(base, "PWNED"), []byte("x"), 0o600))
|
||||
|
||||
dest := filepath.Join(root, "bin", "link.txt")
|
||||
require.Error(t, writeSafeSymlink("../up/PWNED", dest, root))
|
||||
|
||||
_, err := os.Lstat(dest)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
})
|
||||
|
||||
t.Run("KNOWN LIMIT: a target whose own path runs out of the root is not caught by either guard", func(t *testing.T) {
|
||||
// Recorded as a finding rather than a passing assertion of safety, because it is one.
|
||||
//
|
||||
// Three entries: "up" -> "..", then "bin/link.txt" -> "../up/PWNED", then a write to
|
||||
// "bin/link.txt/through". writeSafeSymlink computes <root>/up/PWNED, which is lexically inside
|
||||
// the root, and its last check - does the target already resolve outside? - does not fire,
|
||||
// because <root>/up/PWNED does not exist yet. So the link is created. resolvesInsideRoot then
|
||||
// walks the write path, meets bin/link.txt, and resolves its target the same lexical way: the
|
||||
// target's own path is not walked, so the fact that <root>/up is itself a link out of the root
|
||||
// is never seen, and the write is allowed. On the real filesystem it lands above the root.
|
||||
//
|
||||
// It is not a live exposure. The only caller of resolvesInsideRoot writes one flat name into a
|
||||
// work directory it just created, where no link exists and none can be planted, and
|
||||
// writeSafeSymlink has no caller at all - a symlink entry is a header write now. It matters
|
||||
// because it is what the retirement question actually hangs on: these two are not a working
|
||||
// defence being kept in reserve, they are a defence with a hole in it and no exposure, and
|
||||
// anything that gives them a caller again has to close this first.
|
||||
root := newRoot(t)
|
||||
require.NoError(t, os.Symlink("..", filepath.Join(root, "up")))
|
||||
|
||||
dest := filepath.Join(root, "bin", "link.txt")
|
||||
require.NoError(t, writeSafeSymlink("../up/PWNED", dest, root),
|
||||
"the link is created: its target does not resolve outside the root YET")
|
||||
|
||||
inside, err := resolvesInsideRoot(root, filepath.Join(root, "bin", "link.txt", "through"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, inside,
|
||||
"the guard allows this write, and the real filesystem puts it above the root - the hole this "+
|
||||
"case exists to record")
|
||||
})
|
||||
inside, err := resolvesInsideRoot(root, filepath.Join(root, "bin", "link.txt", "through"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, inside,
|
||||
"the guard allows this write, and the real filesystem puts it above the root - the hole this "+
|
||||
"case exists to record")
|
||||
}
|
||||
|
||||
func TestArchiveFileName_refusesNamesThatWouldLeaveTheWorkDirectory(t *testing.T) {
|
||||
// the name an archive's own bytes are overflowed under is the basename of where the archive was
|
||||
// found, and Base returns ".." for an access path ending in it - which joined to the work
|
||||
// directory would name the directory above. overflowContent checks its destination as well, so this
|
||||
// is the earlier of two refusals rather than the only one.
|
||||
// an archive spills under the basename of where it was found, and Base returns ".." for an access
|
||||
// path ending in it, which joined to the work directory would name the directory above.
|
||||
// overflowContent checks its destination too, so this is the earlier of two refusals.
|
||||
for _, accessPath := range []string{"", ".", "..", "a/..", string(filepath.Separator)} {
|
||||
assert.Equal(t, "archive", archiveFileName(accessPath),
|
||||
"%q must not become a name joined to the work directory", accessPath)
|
||||
|
||||
+75
-237
@@ -6,77 +6,48 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// ResolverFactory builds an indexed resolver over one archive's overflow tar. It is injected (rather
|
||||
// than calling syft/internal/fileresolver directly) so this module-root package does not depend on
|
||||
// the syft-subtree internal fileresolver package.
|
||||
//
|
||||
// It reports what indexing found as well as the resolver, because a tar-family archive is copied
|
||||
// wholesale and its entries are first counted where the tar is indexed.
|
||||
type ResolverFactory func(Overflow) (file.Resolver, IndexResult, error)
|
||||
|
||||
// StoreResolverFactory builds a resolver over an archive's entries rather than over a tar of them.
|
||||
//
|
||||
// Where a ResolverFactory can only index content already written to a file - its index IS that file's
|
||||
// offsets - this one indexes the entries themselves, so an archive small enough to stay in memory
|
||||
// never reaches the disk and one that outgrows memory moves its bytes without the index changing.
|
||||
// Injected the same way and for the same reason: this package must not depend on the resolver
|
||||
// packages it hands work to.
|
||||
// StoreResolverFactory builds an indexed resolver over one archive's entries. Injected so this
|
||||
// package does not depend on the internal fileresolver package.
|
||||
type StoreResolverFactory func(*EntryStore, Overflow) (file.Resolver, IndexResult, error)
|
||||
|
||||
// Overflow names what one archive's entries were written into, and what the resolver built over them
|
||||
// must know.
|
||||
// Overflow names what the resolver built over one archive's entries must know.
|
||||
type Overflow struct {
|
||||
// RootDir is the archive's logical root: an empty directory that every entry's path is reported
|
||||
// relative to. It exists on disk, and stays empty - nothing is written into it - because the
|
||||
// resolver relativizes an entry's path against a real root the way a directory scan does, and
|
||||
// that is what keeps a path in the SBOM relative to its own archive rather than naming the
|
||||
// scan's scratch space.
|
||||
RootDir string
|
||||
|
||||
// TarPath is the one file holding every entry of this archive, indexed by seek offset.
|
||||
TarPath string
|
||||
|
||||
// FileSystemID is the identifier of the filesystem the archive file itself lives on (a layer
|
||||
// digest, or blank), inherited unchanged down the nesting chain, stamped onto every Location.
|
||||
FileSystemID string
|
||||
|
||||
// ArchivePath is the colon-delimited chain of archive paths from the scan root to this archive,
|
||||
// stamped onto every Location. It is what keeps identically-named files in different archives
|
||||
// from colliding in the coordinate-keyed SBOM tables.
|
||||
// stamped onto every Location. It keeps identically-named files in different archives from
|
||||
// colliding in the coordinate-keyed SBOM tables.
|
||||
ArchivePath string
|
||||
|
||||
// Charge is this archive's draw on the resource limiter; nil enforces nothing. The index keeps more
|
||||
// per entry than the store does (one node per path component), so it charges for what it keeps
|
||||
// rather than letting the store estimate for it.
|
||||
Charge *Charge
|
||||
}
|
||||
|
||||
// IndexResult reports what indexing one archive's overflow tar produced.
|
||||
// IndexResult reports what indexing one archive's entries produced.
|
||||
type IndexResult struct {
|
||||
// Records is how many entries the archive's filesystem holds.
|
||||
Records int
|
||||
|
||||
// Truncated reports that indexing stopped before the end of the tar, which now means only one
|
||||
// thing: the tar itself ends part way through an entry because the disk limit stopped the write.
|
||||
// What was indexed is complete and usable; there is simply less of it.
|
||||
// Truncated reports that the budget refused an index record and the remaining entries were never
|
||||
// indexed. What was indexed is complete and usable.
|
||||
Truncated bool
|
||||
}
|
||||
|
||||
// archiveDigestHashes are the digests taken of an archive file as a whole. SHA-1 alone, matching
|
||||
// what the java archive cataloger has always reported for a jar, because these are the digests it
|
||||
// reads when it can no longer open the archive itself.
|
||||
// archiveDigestHashes are the digests taken of an archive as a whole: SHA-1 alone, matching what the
|
||||
// java archive cataloger reports for a jar.
|
||||
var archiveDigestHashes = []crypto.Hash{crypto.SHA1}
|
||||
|
||||
// digestsOf takes the archive's own digests from the content already acquired. One pass over a
|
||||
// seekable source - memory or an overflow file - and it replaces a read the java cataloger used to
|
||||
// do for itself, so it is not new work so much as work moved to where the bytes already are.
|
||||
//
|
||||
// A failure is logged and returns nothing: a digest is metadata about a package, and an archive
|
||||
// whose contents cataloged fine should not be discarded because its hash could not be taken.
|
||||
// digestsOf takes the archive's own digests in one pass over the acquired content, which the java
|
||||
// cataloger reads when it can no longer open the archive itself. A failure is logged and returns
|
||||
// nothing: an archive that cataloged fine is not discarded for a missing hash.
|
||||
func digestsOf(ctx context.Context, content Content, accessPath string) []file.Digest {
|
||||
if _, err := content.Reader.Seek(0, io.SeekStart); err != nil {
|
||||
log.WithFields("archive", accessPath, "error", err).Trace("unable to rewind archive content for digests")
|
||||
@@ -92,40 +63,34 @@ func digestsOf(ctx context.Context, content Content, accessPath string) []file.D
|
||||
|
||||
// ExtractedArchive is a standalone, fully-indexed filesystem built from an extracted archive.
|
||||
type ExtractedArchive struct {
|
||||
// Resolver is an indexed resolver over the extracted contents; its Locations are stamped with FileSystemID.
|
||||
// Resolver is an indexed resolver over the extracted contents, its Locations stamped with
|
||||
// FileSystemID.
|
||||
Resolver file.Resolver
|
||||
|
||||
// Digests are the digests of the archive file itself, taken while its bytes were in hand.
|
||||
Digests []file.Digest
|
||||
// FileSystemID is the identifier of the filesystem the archive file itself lives on (inherited unchanged down the nesting chain).
|
||||
// FileSystemID is the filesystem the archive file itself lives on, inherited unchanged down the
|
||||
// nesting chain.
|
||||
FileSystemID string
|
||||
// ArchivePath is the colon-delimited chain of archive paths from the scan root to this archive.
|
||||
ArchivePath string
|
||||
// Result reports how much was extracted and whether a configured bound truncated the
|
||||
// extraction. A truncated archive still yields a usable Resolver over whatever was written
|
||||
// before the bound; see ExtractionResult.
|
||||
// Result reports how much was extracted and whether a bound truncated it. A truncated archive
|
||||
// still yields a usable Resolver over what was written before the bound; see ExtractionResult.
|
||||
Result ExtractionResult
|
||||
|
||||
// content is this archive's own bytes, held in memory or overflowed to disk. It is kept here rather
|
||||
// than dropped after extraction for two reasons: content held in memory must stay reachable for
|
||||
// as long as the memory limit accounts for it as held, otherwise the limiter measures something
|
||||
// the garbage collector has already taken back; and openers over the extracted tree are heading
|
||||
// towards reading lazily, which cannot work if the backing bytes are gone.
|
||||
// content is this archive's own bytes, in memory or spilled to disk. Retained until Cleanup so the
|
||||
// bytes the limiter counts as held stay reachable and lazy readers keep their backing.
|
||||
content Content
|
||||
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
// Cleanup removes the temp directory backing this archive's filesystem and releases what this
|
||||
// archive was holding against the scan's limiter, so both limits fall by exactly what it took. Safe
|
||||
// to call more than once.
|
||||
// Cleanup removes the temp directory backing this archive's filesystem and releases what it held
|
||||
// against the limiter. Safe to call more than once.
|
||||
func (e *ExtractedArchive) Cleanup() {
|
||||
if e == nil || e.cleanup == nil {
|
||||
return
|
||||
}
|
||||
// close the resolver before the work directory is removed, so the entry store's overflow blob
|
||||
// handle is released while the file it points at still exists. A resolver that holds nothing open
|
||||
// - the tar path opens its file per read - does not implement io.Closer and is skipped.
|
||||
// close before removing the work directory, so the overflow blob handle is released while the file
|
||||
// still exists. A resolver holding nothing open is not an io.Closer.
|
||||
if closer, ok := e.Resolver.(io.Closer); ok {
|
||||
if err := closer.Close(); err != nil {
|
||||
log.WithFields("fsid", e.FileSystemID, "error", err).Trace("unable to close archive resolver")
|
||||
@@ -133,38 +98,28 @@ func (e *ExtractedArchive) Cleanup() {
|
||||
}
|
||||
e.cleanup()
|
||||
e.cleanup = nil
|
||||
// drop the last reference to content held in memory, so the limit falling and the bytes becoming
|
||||
// collectable happen together
|
||||
// drop the last reference so the limit falls as the bytes become collectable
|
||||
e.content = Content{}
|
||||
}
|
||||
|
||||
// ExtractToResolver holds the given archive content - in memory while the memory limit admits it, on
|
||||
// disk once it does not - extracts it, and returns a standalone indexed resolver over its contents
|
||||
// whose Locations are stamped with the given fileSystemID (the filesystem the archive file lives on)
|
||||
// and archivePath (the colon-delimited archive nesting chain from the scan root to this archive). It
|
||||
// returns (nil, nil) when the content is not a supported/extractable archive. The caller owns the
|
||||
// returned value and must call Cleanup on it.
|
||||
// ExtractToResolver holds the given archive content (in memory while the memory limit admits it, on
|
||||
// disk once it does not), extracts it, and returns a standalone indexed resolver over its contents
|
||||
// whose Locations are stamped with fileSystemID and archivePath. Returns (nil, nil) when the content
|
||||
// is not an extractable archive. The caller owns the result and must call Cleanup on it. Everything
|
||||
// the archive holds is charged to one limiter handle and released by Cleanup.
|
||||
//
|
||||
// Everything this archive holds is charged to one handle against the scan's limiter and released by
|
||||
// Cleanup, which processArchive already defers past the sub-pipeline and past recursion - so the
|
||||
// limits fall exactly where the resources are freed, and nothing new has to remember to do it.
|
||||
// Reaching a limit never waits for capacity: the walk descends before it unwinds, so a containing
|
||||
// archive still holds its content while its children are cataloged, and nothing would be released
|
||||
// while this one waited. Content that cannot be admitted yields ErrDiskLimitReached.
|
||||
//
|
||||
// Reaching a limit never waits for capacity. The walk descends before it unwinds, so a containing
|
||||
// archive is still holding its content while its children are cataloged and nothing will be released
|
||||
// while this archive waits: waiting would be a deadlock dressed as backpressure. Content that cannot
|
||||
// be admitted yields ErrDiskLimitReached and the caller skips the archive.
|
||||
//
|
||||
// Reaching a bound part way through is not an error either: the returned ExtractedArchive is indexed
|
||||
// over the partial contents and Result.Truncated() reports it, so the caller can still catalog what
|
||||
// was extracted. Any other non-nil error means nothing usable was produced.
|
||||
func ExtractToResolver(ctx context.Context, content io.Reader, archiveAccessPath, fileSystemID, archivePath string, extractors []Extractor, limiter *Limiter, limits ExtractionLimits, newResolver ResolverFactory, newStoreResolver StoreResolverFactory, notify Notify) (*ExtractedArchive, error) {
|
||||
workDir, removeWorkDir, err := newWorkDir(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Reaching a bound part way through is not an error: the ExtractedArchive is indexed over the partial
|
||||
// contents and Result.Truncated() reports it. Any other non-nil error means nothing usable was
|
||||
// produced.
|
||||
func ExtractToResolver(ctx context.Context, content io.Reader, archiveAccessPath, fileSystemID, archivePath string, extractors []Extractor, limiter *Limiter, newStoreResolver StoreResolverFactory, notify Notify) (*ExtractedArchive, error) {
|
||||
// created only if this archive writes something; most never do
|
||||
workDir := NewWorkDir(ctx)
|
||||
|
||||
charge := limiter.Charge()
|
||||
limits.Charge = charge
|
||||
|
||||
var held Content
|
||||
cleanup := func() {
|
||||
@@ -172,28 +127,28 @@ func ExtractToResolver(ctx context.Context, content io.Reader, archiveAccessPath
|
||||
log.WithFields("archive", archiveAccessPath, "error", closeErr).Trace("unable to close archive content")
|
||||
}
|
||||
held = Content{}
|
||||
removeWorkDir()
|
||||
workDir.Remove()
|
||||
charge.Release()
|
||||
}
|
||||
|
||||
held, err = acquireContent(content, workDir, archiveFileName(archiveAccessPath), charge, notify)
|
||||
held, err := acquireContent(content, workDir, archiveFileName(archiveAccessPath), charge, notify)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
if notify != nil && errors.Is(err, ErrDiskLimitReached) {
|
||||
notify(Skipped{Archive: archiveAccessPath, Reason: "content would exceed the disk limit"})
|
||||
}
|
||||
// ErrDiskLimitReached travels unwrapped so the caller can attribute the skip to this archive
|
||||
// unwrapped so the caller can attribute an ErrDiskLimitReached skip to this archive
|
||||
return nil, err
|
||||
}
|
||||
|
||||
extractor := FindExtractor(ctx, extractors, held)
|
||||
if extractor == nil {
|
||||
// not an archive we can extract (e.g. an executable that happened to match a broad mime filter)
|
||||
// not extractable (e.g. an executable that matched a broad mime filter)
|
||||
cleanup()
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
resolver, result, err := overflowAndIndex(ctx, extractor, held, workDir, fileSystemID, archivePath, archiveAccessPath, limits, newResolver, newStoreResolver, notify)
|
||||
resolver, result, err := storeAndIndex(ctx, extractor, held, workDir, fileSystemID, archivePath, archiveAccessPath, charge, newStoreResolver, notify)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, err
|
||||
@@ -203,138 +158,31 @@ func ExtractToResolver(ctx context.Context, content io.Reader, archiveAccessPath
|
||||
Resolver: resolver,
|
||||
Digests: digestsOf(ctx, held, archiveAccessPath),
|
||||
FileSystemID: fileSystemID,
|
||||
ArchivePath: archivePath,
|
||||
Result: result,
|
||||
content: held,
|
||||
cleanup: cleanup,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// workDirName is the prefix given to the directory holding one archive's content and entries. Named
|
||||
// so a scan's leftovers are recognizable, and matched by the tests that sample what is on disk.
|
||||
const workDirName = "syft-archive"
|
||||
|
||||
// newWorkDir creates the directory one archive's content and entries are written into, under the
|
||||
// scan's own temp root when the context carries one.
|
||||
// inPlaceContent is content already random-access and already charged for by the archive holding it.
|
||||
// A nested archive arrives as a reader over one entry of its parent's EntryStore supporting Read,
|
||||
// Seek and ReadAt, so it is read where it lies rather than copied or spilled again.
|
||||
//
|
||||
// The root is where every other cataloger that spills to disk already writes (see internal/tmpdir),
|
||||
// which is what makes a scan's temp files land where the caller configured them and get removed
|
||||
// together if an archive's own cleanup is ever missed. A context with no root - a caller reaching
|
||||
// this package directly - still gets a directory, from the same place it would have come from
|
||||
// before.
|
||||
func newWorkDir(ctx context.Context) (string, func(), error) {
|
||||
if td := tmpdir.FromContext(ctx); td != nil {
|
||||
dir, cleanup, err := td.NewChild(workDirName) //nolint:gocritic // cleanup is returned to the caller, not deferred here
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("unable to create temp dir for archive extraction: %w", err)
|
||||
}
|
||||
return dir, cleanup, nil
|
||||
}
|
||||
|
||||
dir, err := os.MkdirTemp("", workDirName+"-")
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("unable to create temp dir for archive extraction: %w", err)
|
||||
}
|
||||
return dir, func() {
|
||||
if rmErr := os.RemoveAll(dir); rmErr != nil {
|
||||
log.WithFields("dir", dir, "error", rmErr).Trace("unable to remove archive temp dir")
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// overflowAndIndex writes one archive's entries into the tar in workDir and builds the resolver over it.
|
||||
//
|
||||
// Errors travel unwrapped where the caller has to tell them apart: ErrDiskLimitReached attributes a
|
||||
// skip to this archive, and everything else is a failure of this archive alone.
|
||||
func overflowAndIndex(ctx context.Context, extractor Extractor, held Content, workDir, fsID, archivePath, accessPath string, limits ExtractionLimits, newResolver ResolverFactory, newStoreResolver StoreResolverFactory, notify Notify) (file.Resolver, ExtractionResult, error) {
|
||||
if newStoreResolver != nil {
|
||||
return storeAndIndex(ctx, extractor, held, workDir, fsID, archivePath, accessPath, limits, newStoreResolver, notify)
|
||||
}
|
||||
var result ExtractionResult
|
||||
|
||||
// the archive's logical root, which stays empty: entries live in the tar beside it, and this is
|
||||
// only what their paths are reported relative to
|
||||
contentsDir := filepath.Join(workDir, "contents")
|
||||
if err := os.MkdirAll(contentsDir, 0o755); err != nil {
|
||||
return nil, result, fmt.Errorf("unable to create contents dir for archive %q: %w", fsID, err)
|
||||
}
|
||||
|
||||
overflow, err := newOverflowTar(filepath.Join(workDir, overflowTarName))
|
||||
if err != nil {
|
||||
return nil, result, err
|
||||
}
|
||||
|
||||
result, err = extractor.Extract(ctx, held, overflow, limits)
|
||||
if closeErr := overflow.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return nil, result, fmt.Errorf("unable to extract archive %q: %w", fsID, err)
|
||||
}
|
||||
|
||||
// nothing of this archive could be placed: the disk limit refused its first byte, so there is
|
||||
// nowhere for its content to go and the archive is skipped rather than cataloged as empty. This is
|
||||
// where the terminal reading of the disk limit lands now that a nested archive is read where it
|
||||
// lies: its own bytes are never copied, so they are never the thing refused, and what is refused
|
||||
// instead is the file its entries are written into. An archive that placed SOME of its entries is
|
||||
// a truncation, not a skip - see ExtractionResult.
|
||||
if result.Truncation == TruncatedByDiskLimit && result.BytesWritten == 0 {
|
||||
return nil, result, ErrDiskLimitReached
|
||||
}
|
||||
|
||||
resolver, indexed, err := newResolver(Overflow{
|
||||
RootDir: contentsDir,
|
||||
TarPath: overflow.Path(),
|
||||
FileSystemID: fsID,
|
||||
ArchivePath: archivePath,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, result, fmt.Errorf("unable to index extracted archive %q: %w", fsID, err)
|
||||
}
|
||||
|
||||
// the count of entries the archive's filesystem holds is the index's to report, not the writer's:
|
||||
// a tar-family archive is copied wholesale and never walked, and an exclusion pattern drops
|
||||
// entries after they were written
|
||||
result.FilesExtracted = indexed.Records
|
||||
if indexed.Truncated && !result.Truncated() {
|
||||
// the tar ends part way through an entry and nothing recorded why. The disk limit is the only
|
||||
// bound that can end a write early and it names itself, so reaching here means a ragged tar
|
||||
// with no attributed cause - reported as a truncation without claiming which bound did it.
|
||||
result.Truncation = TruncatedByLimit
|
||||
}
|
||||
|
||||
return resolver, result, nil
|
||||
}
|
||||
|
||||
// inPlaceContent is content that is already random-access on a file this scan wrote and is already
|
||||
// accounted for by the archive that wrote it.
|
||||
//
|
||||
// A nested archive arrives as a reader over one entry of its parent's overflow tar, and that reader is
|
||||
// Read, Seek AND ReadAt - which is everything an archive format needs, a zip's central directory
|
||||
// included. So it is read in place rather than copied into memory or overflowed again: the payoff for
|
||||
// choosing tar as the storage format, and the reason an inner zip needs no special handling.
|
||||
//
|
||||
// Declared as a marker method rather than by asserting on the reader's type, because the type belongs
|
||||
// to the resolver package that this one deliberately does not import. Nothing else in the process
|
||||
// answers to it, so nothing else is read in place by accident.
|
||||
// A marker method rather than a type assertion, because the concrete type belongs to the fileresolver
|
||||
// package, which this one does not import. Nothing else satisfies it.
|
||||
type inPlaceContent interface {
|
||||
io.Reader
|
||||
io.ReaderAt
|
||||
io.Seeker
|
||||
|
||||
// OverflowArchiveEntry marks a reader over one entry of an archive's overflow tar.
|
||||
// OverflowArchiveEntry marks a reader over one entry of an archive's EntryStore.
|
||||
OverflowArchiveEntry()
|
||||
}
|
||||
|
||||
// acquireContent gets one archive's bytes ready to be read at random, which is what every archive
|
||||
// format needs: it reads content already sitting in a file this scan wrote in place, and otherwise
|
||||
// routes the bytes through the limiter - held in memory while the memory limit admits them, overflowed
|
||||
// to disk once it does not.
|
||||
//
|
||||
// Content read in place is charged nothing, because nothing new is held: the bytes are part of the
|
||||
// parent archive's tar, which the parent already charged for and releases on its own Cleanup. Its
|
||||
// handle is the caller's to close, as it was before this ever reached here.
|
||||
func acquireContent(r io.Reader, workDir, name string, charge *Charge, notify Notify) (Content, error) {
|
||||
// acquireContent gets one archive's bytes ready for random access: content already held by a parent
|
||||
// archive is used in place, anything else goes through the limiter. In-place content is charged
|
||||
// nothing, since the parent already charged for those bytes and releases them on its own Cleanup.
|
||||
func acquireContent(r io.Reader, workDir *WorkDir, name string, charge *Charge, notify Notify) (Content, error) {
|
||||
if entry, ok := r.(inPlaceContent); ok {
|
||||
return Content{Name: name, Reader: entry}, nil
|
||||
}
|
||||
@@ -342,11 +190,9 @@ func acquireContent(r io.Reader, workDir, name string, charge *Charge, notify No
|
||||
}
|
||||
|
||||
// archiveFileName derives a safe basename for the saved archive, preserving compound extensions
|
||||
// (e.g. ".tar.gz") so format detection has the best chance to identify the archive.
|
||||
//
|
||||
// ".." is refused along with the empty and root cases: Base returns it for an access path ending in
|
||||
// it, and joining it to the work directory would name the directory above. overflowContent checks its
|
||||
// destination as well, so this is the earlier of two refusals rather than the only one.
|
||||
// (e.g. ".tar.gz") so format detection can identify it. "..", empty and root are refused, since
|
||||
// joining them to the work directory would name the directory above; the destination is rechecked
|
||||
// before the write.
|
||||
func archiveFileName(accessPath string) string {
|
||||
name := filepath.Base(accessPath)
|
||||
if name == "" || name == "." || name == ".." || name == string(filepath.Separator) {
|
||||
@@ -355,25 +201,15 @@ func archiveFileName(accessPath string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// storeAndIndex is the entry-store path: the archive's entries go into an EntryStore, which holds
|
||||
// them in memory while the memory limit admits them, and the resolver is built over the entries.
|
||||
// storeAndIndex puts the archive's entries into an EntryStore and builds the resolver over them.
|
||||
//
|
||||
// Compared to the tar path this writes nothing for an archive that fits in memory - no tar to encode,
|
||||
// no file to index - and where the archive does not fit, the content moves without the index being
|
||||
// rebuilt.
|
||||
func storeAndIndex(ctx context.Context, extractor Extractor, held Content, workDir, fsID, archivePath, accessPath string, limits ExtractionLimits, newStoreResolver StoreResolverFactory, notify Notify) (file.Resolver, ExtractionResult, error) {
|
||||
// ErrDiskLimitReached travels unwrapped so the caller can attribute the skip to this archive.
|
||||
func storeAndIndex(ctx context.Context, extractor Extractor, held Content, workDir *WorkDir, fsID, archivePath, accessPath string, charge *Charge, newStoreResolver StoreResolverFactory, notify Notify) (file.Resolver, ExtractionResult, error) {
|
||||
var result ExtractionResult
|
||||
|
||||
// the archive's logical root, which stays empty: entries live in the store, and this is only what
|
||||
// their paths are reported relative to
|
||||
contentsDir := filepath.Join(workDir, "contents")
|
||||
if err := os.MkdirAll(contentsDir, 0o755); err != nil {
|
||||
return nil, result, fmt.Errorf("unable to create contents dir for archive %q: %w", fsID, err)
|
||||
}
|
||||
|
||||
store := NewEntryStore(workDir, accessPath, notify)
|
||||
|
||||
result, err := extractor.Extract(ctx, held, store, limits)
|
||||
result, err := extractor.Extract(ctx, held, store, charge)
|
||||
if err != nil {
|
||||
if closeErr := store.Close(); closeErr != nil {
|
||||
log.WithFields("archive", fsID, "error", closeErr).Trace("unable to close archive entry store")
|
||||
@@ -381,10 +217,8 @@ func storeAndIndex(ctx context.Context, extractor Extractor, held Content, workD
|
||||
return nil, result, fmt.Errorf("unable to extract archive %q: %w", fsID, err)
|
||||
}
|
||||
|
||||
// nothing of this archive could be placed: the disk limit refused its first byte, so the entries
|
||||
// it holds are content-less husks and the archive is skipped rather than cataloged as empty. The
|
||||
// same terminal reading of the disk limit the tar path takes, measured the way this path stores
|
||||
// its content - an archive that placed SOME of its entries, in memory or on disk, is a truncation.
|
||||
// the disk limit refused the first byte, so every entry is content-less: skip the archive rather
|
||||
// than catalog it as empty. An archive that stored some entries is a truncation instead.
|
||||
if result.Truncation == TruncatedByDiskLimit && store.OnDisk() == 0 {
|
||||
if closeErr := store.Close(); closeErr != nil {
|
||||
log.WithFields("archive", fsID, "error", closeErr).Trace("unable to close archive entry store")
|
||||
@@ -393,9 +227,9 @@ func storeAndIndex(ctx context.Context, extractor Extractor, held Content, workD
|
||||
}
|
||||
|
||||
resolver, indexed, err := newStoreResolver(store, Overflow{
|
||||
RootDir: contentsDir,
|
||||
FileSystemID: fsID,
|
||||
ArchivePath: archivePath,
|
||||
Charge: charge,
|
||||
})
|
||||
if err != nil {
|
||||
if closeErr := store.Close(); closeErr != nil {
|
||||
@@ -404,7 +238,11 @@ func storeAndIndex(ctx context.Context, extractor Extractor, held Content, workD
|
||||
return nil, result, fmt.Errorf("unable to index extracted archive %q: %w", fsID, err)
|
||||
}
|
||||
|
||||
result.FilesExtracted = indexed.Records
|
||||
// an extraction already stopped at a bound keeps that reason: it names where the archive was cut
|
||||
// short, and the index only ran out of budget behind it
|
||||
if indexed.Truncated && !result.Truncated() {
|
||||
result.Truncation = TruncatedByIndexLimit
|
||||
}
|
||||
if notify != nil && result.Truncated() {
|
||||
notify(Truncated{Archive: accessPath, Reason: result.Truncation})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -19,48 +18,75 @@ import (
|
||||
)
|
||||
|
||||
func TestExtractToResolver_writesUnderTheScansTempRoot(t *testing.T) {
|
||||
// where an archive's scratch space lands is the scan's decision, not this package's: the root on
|
||||
// the context is what a caller configured and what gets cleaned up if an archive's own cleanup is
|
||||
// ever missed. Reaching for os.MkdirTemp instead put archive work outside all of that.
|
||||
// where an archive's scratch space lands is the scan's decision: the root on the context is what the
|
||||
// caller configured, and what gets cleaned up if an archive's own cleanup is missed
|
||||
root := t.TempDir()
|
||||
ctx := tmpdir.WithValue(context.Background(), tmpdir.FromPath(root))
|
||||
|
||||
var got Overflow
|
||||
factory := func(overflow Overflow) (file.Resolver, IndexResult, error) {
|
||||
got = overflow
|
||||
return nil, IndexResult{Records: 1}, nil
|
||||
factory := func(_ *EntryStore, _ Overflow) (file.Resolver, IndexResult, error) {
|
||||
return nil, IndexResult{}, nil
|
||||
}
|
||||
|
||||
// a zero memory bound, so this archive really does write: a work directory exists only once
|
||||
// something has spilled into it
|
||||
extracted, err := ExtractToResolver(
|
||||
ctx, newTestZip(t, map[string]string{"hello.txt": "hi"}), "app.zip", "", "app.zip",
|
||||
DefaultExtractors(), nil, ExtractionLimits{}, factory, nil, nil,
|
||||
DefaultExtractors(), NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: -1}), factory, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, extracted)
|
||||
t.Cleanup(extracted.Cleanup)
|
||||
|
||||
assert.True(t, strings.HasPrefix(got.TarPath, root+string(os.PathSeparator)),
|
||||
"the archive's entries must be written under the scan's temp root, got %q", got.TarPath)
|
||||
// the work directory is not named by anything the resolver is handed, so it is observed where it
|
||||
// lands: under the temp root the scan configured
|
||||
workDirs := workDirsUnder(t, root)
|
||||
require.Len(t, workDirs, 1, "expected one archive work directory under the scan's temp root")
|
||||
|
||||
// and the archive still cleans up after itself, so the root is a safety net rather than the only
|
||||
// thing reclaiming the space
|
||||
extracted.Cleanup()
|
||||
_, statErr := os.Stat(got.RootDir)
|
||||
_, statErr := os.Stat(workDirs[0])
|
||||
assert.True(t, os.IsNotExist(statErr), "expected the archive work directory to be removed by Cleanup")
|
||||
}
|
||||
|
||||
func TestExtractToResolver_anArchiveThatNeverWritesCreatesNoWorkDir(t *testing.T) {
|
||||
// the scratch directory is created at the point of writing, so an archive held entirely in memory -
|
||||
// which is most of them - costs no directory on the filesystem at all
|
||||
root := t.TempDir()
|
||||
ctx := tmpdir.WithValue(context.Background(), tmpdir.FromPath(root))
|
||||
|
||||
factory := func(_ *EntryStore, _ Overflow) (file.Resolver, IndexResult, error) {
|
||||
return nil, IndexResult{}, nil
|
||||
}
|
||||
|
||||
extracted, err := ExtractToResolver(
|
||||
ctx, newTestZip(t, map[string]string{"hello.txt": "hi"}), "app.zip", "", "app.zip",
|
||||
DefaultExtractors(), NewLimiter(Limits{MaxMemoryBytes: -1, MaxDiskBytes: -1}), factory, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, extracted)
|
||||
t.Cleanup(extracted.Cleanup)
|
||||
|
||||
assert.Empty(t, workDirsUnder(t, root), "nothing was written, so no work directory was created")
|
||||
|
||||
// and cleanup of an archive that created nothing is still safe
|
||||
extracted.Cleanup()
|
||||
assert.Empty(t, workDirsUnder(t, root))
|
||||
}
|
||||
|
||||
func TestExtractToResolver(t *testing.T) {
|
||||
content := newTestZip(t, map[string]string{"dir/hello.txt": "hello world"})
|
||||
|
||||
var got Overflow
|
||||
factory := func(overflow Overflow) (file.Resolver, IndexResult, error) {
|
||||
got = overflow
|
||||
return nil, IndexResult{Records: 1}, nil
|
||||
var gotStore *EntryStore
|
||||
factory := func(store *EntryStore, overflow Overflow) (file.Resolver, IndexResult, error) {
|
||||
got, gotStore = overflow, store
|
||||
return nil, IndexResult{}, nil
|
||||
}
|
||||
|
||||
extracted, err := ExtractToResolver(
|
||||
context.Background(), content, "some/path/app.zip", "parentFS", "app.war:some/path/app.zip",
|
||||
DefaultExtractors(), nil, ExtractionLimits{}, factory, nil, nil,
|
||||
DefaultExtractors(), nil, factory, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, extracted)
|
||||
@@ -70,66 +96,108 @@ func TestExtractToResolver(t *testing.T) {
|
||||
// separately as the archive path, and both are handed to the resolver factory
|
||||
assert.Equal(t, "parentFS", extracted.FileSystemID)
|
||||
assert.Equal(t, "parentFS", got.FileSystemID)
|
||||
assert.Equal(t, "app.war:some/path/app.zip", extracted.ArchivePath)
|
||||
assert.Equal(t, "app.war:some/path/app.zip", got.ArchivePath)
|
||||
|
||||
// the archive's entries are in the one tar handed to the factory, and its logical root is an
|
||||
// empty directory beside it: an entry's path is reported relative to that root, and nothing is
|
||||
// written into it
|
||||
entries, err := os.ReadDir(got.RootDir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries, "the archive root holds no files: the entries are in the tar")
|
||||
// the archive's entries are in the store handed to the factory, keyed by their archive-relative
|
||||
// path: nothing was unpacked to a directory for the resolver to be pointed at
|
||||
assert.Equal(t, "hello world", readStoreEntry(t, gotStore, "dir/hello.txt"))
|
||||
|
||||
body := readTarEntry(t, got.TarPath, "dir/hello.txt")
|
||||
assert.Equal(t, "hello world", body)
|
||||
|
||||
// the count of entries this archive contributes is the index's answer, not the extractor's
|
||||
assert.Equal(t, 1, extracted.Result.FilesExtracted)
|
||||
|
||||
// Cleanup removes the temp tree
|
||||
extracted.Cleanup()
|
||||
_, statErr := os.Stat(got.TarPath)
|
||||
assert.True(t, os.IsNotExist(statErr), "expected the overflow tar to be removed by Cleanup")
|
||||
_, statErr = os.Stat(got.RootDir)
|
||||
assert.True(t, os.IsNotExist(statErr), "expected temp dir to be removed by Cleanup")
|
||||
}
|
||||
|
||||
// readTarEntry returns the content of one entry of a tar on disk.
|
||||
func readTarEntry(t *testing.T, tarPath, name string) string {
|
||||
t.Helper()
|
||||
f, err := os.Open(tarPath)
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
|
||||
tr := tar.NewReader(f)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
require.NoError(t, err)
|
||||
if hdr.Name != name {
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(tr)
|
||||
require.NoError(t, err)
|
||||
return string(body)
|
||||
}
|
||||
t.Fatalf("no entry %q in %q", name, tarPath)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestExtractToResolver_notAnArchive(t *testing.T) {
|
||||
factory := func(Overflow) (file.Resolver, IndexResult, error) { return nil, IndexResult{}, nil }
|
||||
factory := func(*EntryStore, Overflow) (file.Resolver, IndexResult, error) { return nil, IndexResult{}, nil }
|
||||
|
||||
extracted, err := ExtractToResolver(
|
||||
context.Background(), strings.NewReader("this is not an archive"), "notes.txt", "", "notes.txt",
|
||||
DefaultExtractors(), nil, ExtractionLimits{}, factory, nil, nil,
|
||||
DefaultExtractors(), nil, factory, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, extracted, "non-archive content should yield a nil ExtractedArchive")
|
||||
}
|
||||
|
||||
func TestExtractToResolver_passesTheArchivesChargeToTheIndex(t *testing.T) {
|
||||
// the index keeps more per entry than the store can measure - one node per path component - so it
|
||||
// charges the scan's limiter itself, against this archive's handle
|
||||
limiter := NewLimiter(Limits{MaxMemoryBytes: 1 << 20, MaxDiskBytes: 1 << 20})
|
||||
|
||||
var got Overflow
|
||||
factory := func(_ *EntryStore, overflow Overflow) (file.Resolver, IndexResult, error) {
|
||||
got = overflow
|
||||
return nil, IndexResult{}, nil
|
||||
}
|
||||
|
||||
extracted, err := ExtractToResolver(
|
||||
context.Background(), newTestZip(t, map[string]string{"hello.txt": "hi"}), "app.zip", "", "app.zip",
|
||||
DefaultExtractors(), limiter, factory, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, extracted)
|
||||
t.Cleanup(extracted.Cleanup)
|
||||
|
||||
require.NotNil(t, got.Charge, "the index must be handed this archive's charge, not left unbounded")
|
||||
|
||||
// and it is this archive's handle, so Cleanup gives back whatever the index charged through it
|
||||
require.True(t, got.Charge.Memory(4096))
|
||||
before, _ := limiter.InUse()
|
||||
require.Positive(t, before)
|
||||
|
||||
extracted.Cleanup()
|
||||
after, _ := limiter.InUse()
|
||||
assert.Zero(t, after, "everything the archive charged, the index included, is released with it")
|
||||
}
|
||||
|
||||
func TestExtractToResolver_reportsAnIndexThatRanOutOfBudget(t *testing.T) {
|
||||
// an index that stops part way is a truncation, not a failure: the archive is cataloged from what was
|
||||
// indexed, and the reason names the index rather than the disk, which nothing was written to
|
||||
factory := func(_ *EntryStore, _ Overflow) (file.Resolver, IndexResult, error) {
|
||||
return nil, IndexResult{Truncated: true}, nil
|
||||
}
|
||||
|
||||
extracted, err := ExtractToResolver(
|
||||
context.Background(), newTestZip(t, map[string]string{"a.txt": "a", "b.txt": "b"}), "app.zip", "", "app.zip",
|
||||
DefaultExtractors(), nil, factory, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, extracted)
|
||||
t.Cleanup(extracted.Cleanup)
|
||||
|
||||
assert.True(t, extracted.Result.Truncated())
|
||||
assert.Equal(t, TruncatedByIndexLimit, extracted.Result.Truncation)
|
||||
}
|
||||
|
||||
// workDirsUnder returns the archive work directories directly under the scan's temp root.
|
||||
func workDirsUnder(t *testing.T, root string) []string {
|
||||
t.Helper()
|
||||
children, err := os.ReadDir(root)
|
||||
require.NoError(t, err)
|
||||
|
||||
var out []string
|
||||
for _, child := range children {
|
||||
if child.IsDir() && strings.HasPrefix(child.Name(), workDirName) {
|
||||
out = append(out, filepath.Join(root, child.Name()))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readStoreEntry(t *testing.T, store *EntryStore, name string) string {
|
||||
t.Helper()
|
||||
require.NotNil(t, store)
|
||||
for _, entry := range store.Entries() {
|
||||
if entry.Header.Name != name {
|
||||
continue
|
||||
}
|
||||
reader, err := store.Open(entry)
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
return string(body)
|
||||
}
|
||||
t.Fatalf("no entry %q in the store", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
func newTestZip(t *testing.T, files map[string]string) *bytes.Buffer {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/internal/tmpdir"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
)
|
||||
|
||||
// unboundedLimits hold everything in memory, as an archive well inside the default bounds does.
|
||||
var unboundedLimits = Limits{MaxMemoryBytes: -1, MaxDiskBytes: -1}
|
||||
|
||||
// spillingLimits hold nothing in memory, so every entry lands in the store's overflow blob. The
|
||||
// filesystem-cost requirement must be measured in this shape: an archive that never touches disk
|
||||
// satisfies it trivially.
|
||||
var spillingLimits = Limits{MaxMemoryBytes: 0, MaxDiskBytes: -1}
|
||||
|
||||
func TestStorage_filesystemCostDoesNotTrackEntryCount(t *testing.T) {
|
||||
// an archive of a hundred thousand entries must not put a hundred thousand files on the filesystem.
|
||||
// Asserted as an exact set of paths for two archives three orders of magnitude apart in entry count,
|
||||
// since a count that merely grows slowly would pass a bound and still be proportional to content.
|
||||
_, smallStore, smallRoot := extractedStore(t, manyEntryZip(t, 2), spillingLimits)
|
||||
_, largeStore, largeRoot := extractedStore(t, manyEntryZip(t, 5000), spillingLimits)
|
||||
smallWork, largeWork := workDirIn(t, smallRoot), workDirIn(t, largeRoot)
|
||||
|
||||
// the archive's own bytes spill too under a zero memory bound, so the set is the spilled archive and
|
||||
// the blob its entries went into - two, whatever it holds
|
||||
want := []string{"app.zip", "contents.blob"}
|
||||
assert.Equal(t, want, filesystemEntries(t, smallWork))
|
||||
assert.Equal(t, want, filesystemEntries(t, largeWork),
|
||||
"5000 entries must cost the same filesystem entries as 2")
|
||||
|
||||
// and the entries really are all there, so this is not passing because nothing was extracted
|
||||
assert.Len(t, smallStore.Entries(), 2)
|
||||
assert.Len(t, largeStore.Entries(), 5000)
|
||||
|
||||
// an archive costs its entry content and nothing else on disk: no headers, no padding, so a one-byte
|
||||
// entry costs one byte
|
||||
assert.Equal(t, int64(5000), largeStore.OnDisk())
|
||||
assert.Equal(t, int64(2), smallStore.OnDisk())
|
||||
}
|
||||
|
||||
func TestStorage_anArchiveWithinTheMemoryBoundTouchesNoFilesystemAtAll(t *testing.T) {
|
||||
// an archive that fits in memory pays for no write at all - not even the directory it would have
|
||||
// written into, which is created only when something actually spills
|
||||
_, store, root := extractedStore(t, manyEntryZip(t, 5000), unboundedLimits)
|
||||
|
||||
assert.Len(t, store.Entries(), 5000)
|
||||
assert.Zero(t, store.OnDisk())
|
||||
assert.Empty(t, filesystemEntries(t, root),
|
||||
"an archive held entirely in memory creates no work directory at all")
|
||||
}
|
||||
|
||||
func TestStorage_cleanupRemovesOneFileAndDoesNotWalkPerEntry(t *testing.T) {
|
||||
// cleanup is bounded by the same constant as the extraction, so the count of things removed is
|
||||
// asserted rather than only that the directory is gone
|
||||
extracted, _, root := extractedStore(t, manyEntryZip(t, 5000), spillingLimits)
|
||||
workDir := workDirIn(t, root)
|
||||
|
||||
before := filesystemEntries(t, workDir)
|
||||
require.Len(t, before, 2, "the release has two things to remove whatever the archive held")
|
||||
|
||||
extracted.Cleanup()
|
||||
|
||||
_, err := os.Stat(filepath.Join(workDir, overflowBlobName))
|
||||
assert.True(t, os.IsNotExist(err), "the one file holding every entry must be gone")
|
||||
_, err = os.Stat(workDir)
|
||||
assert.True(t, os.IsNotExist(err), "and so must the work directory")
|
||||
|
||||
// safe to call more than once, since the cataloger defers it past the sub-pipeline and recursion
|
||||
extracted.Cleanup()
|
||||
}
|
||||
|
||||
func TestStorage_anEntryAtTheEndIsReadBySeeking(t *testing.T) {
|
||||
// "overflow content MUST remain directly readable without re-reading or re-decompressing what
|
||||
// precedes it". Asserted through the mechanism: the store records where the last entry's content
|
||||
// begins, near the end of a blob whose earlier entries are large, and hands back a bounded window
|
||||
// onto the file at that offset.
|
||||
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())
|
||||
|
||||
_, store, root := extractedStore(t, &buf, spillingLimits)
|
||||
|
||||
blobPath := filepath.Join(workDirIn(t, root), overflowBlobName)
|
||||
info, err := os.Stat(blobPath)
|
||||
require.NoError(t, err)
|
||||
total := info.Size()
|
||||
require.Greater(t, total, int64(8*filler), "the fixture must put real bulk ahead of the last entry")
|
||||
|
||||
// the last entry's content sits near the end of the blob, so everything before it is what a
|
||||
// re-streaming design would have had to read
|
||||
body, err := os.ReadFile(blobPath)
|
||||
require.NoError(t, err)
|
||||
offset := int64(bytes.Index(body, []byte(wanted)))
|
||||
require.Positive(t, offset)
|
||||
assert.Greater(t, offset, total*9/10, "the last entry must really be at the end of the blob")
|
||||
|
||||
entries := store.Entries()
|
||||
entry := entries[len(entries)-1]
|
||||
require.Equal(t, "zz-last.txt", entry.Header.Name)
|
||||
|
||||
// the reader is bounded to this entry rather than the whole blob: its end is the entry's own size
|
||||
random, err := store.Open(entry)
|
||||
require.NoError(t, err)
|
||||
|
||||
end, err := random.Seek(0, io.SeekEnd)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(len(wanted)), end)
|
||||
|
||||
// offset zero of the reader is the last entry's first byte, not the blob's; a reader over the whole
|
||||
// file would answer with the first filler entry's content
|
||||
head := make([]byte, 8)
|
||||
_, err = random.ReadAt(head, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, wanted[:8], string(head))
|
||||
}
|
||||
|
||||
func manyEntryZip(t *testing.T, count int) *bytes.Buffer {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for i := range count {
|
||||
w, err := zw.Create(fmt.Sprintf("e%06d.txt", i))
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte("x"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, zw.Close())
|
||||
return &buf
|
||||
}
|
||||
|
||||
// extractedStore extracts one archive and returns the store its entries went into, with the scan temp
|
||||
// root the extraction was pointed at.
|
||||
//
|
||||
// The root is where a work directory would appear: nothing the resolver is handed names a path on the
|
||||
// host, and an archive that never writes creates no directory to name.
|
||||
func extractedStore(t *testing.T, content io.Reader, limits Limits) (*ExtractedArchive, *EntryStore, string) {
|
||||
t.Helper()
|
||||
|
||||
root := t.TempDir()
|
||||
ctx := tmpdir.WithValue(context.Background(), tmpdir.FromPath(root))
|
||||
|
||||
var got *EntryStore
|
||||
factory := func(store *EntryStore, _ Overflow) (file.Resolver, IndexResult, error) {
|
||||
got = store
|
||||
return nil, IndexResult{}, nil
|
||||
}
|
||||
|
||||
extracted, err := ExtractToResolver(
|
||||
ctx, content, "app.zip", "", "app.zip",
|
||||
DefaultExtractors(), NewLimiter(limits), factory, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, extracted)
|
||||
t.Cleanup(extracted.Cleanup)
|
||||
|
||||
return extracted, got, root
|
||||
}
|
||||
|
||||
// workDirIn returns the one work directory an archive that spilled created under the scan temp root.
|
||||
func workDirIn(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
children, err := os.ReadDir(root)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, children, 1, "expected exactly one archive work directory under the temp root")
|
||||
return filepath.Join(root, children[0].Name())
|
||||
}
|
||||
|
||||
// filesystemEntries is every directory entry under dir: an archive must not put one of these on the
|
||||
// filesystem per entry it holds.
|
||||
func filesystemEntries(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
require.NoError(t, filepath.WalkDir(dir, func(p string, _ fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, relErr := filepath.Rel(dir, p)
|
||||
if relErr != nil {
|
||||
return relErr
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
out = append(out, filepath.ToSlash(rel))
|
||||
return nil
|
||||
}))
|
||||
return out
|
||||
}
|
||||
@@ -7,11 +7,10 @@ import (
|
||||
"github.com/anchore/syft/syft/file"
|
||||
)
|
||||
|
||||
// Traversal describes one level of archive nesting during recursive archive cataloging. The archive
|
||||
// cataloger task places one on the context before running the cataloger sub-pipeline against an
|
||||
// archive's contents, so ecosystem catalogers (e.g. java) can reconstruct nesting-aware identity
|
||||
// such as colon-delimited virtual paths (e.g. "app.war:WEB-INF/lib/dep.jar") without owning the
|
||||
// recursion themselves.
|
||||
// Traversal describes one level of archive nesting. The archive cataloger task places one on the
|
||||
// context before running the sub-pipeline against an archive's contents, so ecosystem catalogers
|
||||
// (e.g. java) can reconstruct nesting-aware identity like "app.war:WEB-INF/lib/dep.jar" without
|
||||
// owning the recursion.
|
||||
type Traversal struct {
|
||||
// Location is the archive file's location within its parent filesystem.
|
||||
Location file.Location
|
||||
@@ -20,28 +19,14 @@ type Traversal struct {
|
||||
// e.g. "app.war:WEB-INF/lib/dep.jar".
|
||||
VirtualPath string
|
||||
|
||||
// FileSystemID is the identifier of the filesystem the archive file itself lives on (a container
|
||||
// image layer digest, or empty for a directory source), inherited unchanged down the nesting
|
||||
// chain. The nesting chain is carried by VirtualPath, not by this field.
|
||||
FileSystemID string
|
||||
|
||||
// Depth is 1 for an archive found directly in the scan source, incrementing per nesting level.
|
||||
Depth int
|
||||
|
||||
// Digests are the digests of the archive file itself. They are carried here because they are a
|
||||
// property of the file and not of its contents: a cataloger running against an archive's extracted
|
||||
// filesystem cannot compute them, since the archive is not inside itself. Taken once by the
|
||||
// extraction, which already has the bytes in hand.
|
||||
// Digests are of the archive file itself, taken once during extraction. A cataloger running against
|
||||
// the extracted filesystem cannot compute them, since the archive is not inside itself.
|
||||
Digests []file.Digest
|
||||
|
||||
// Parent is the traversal for the containing archive; nil at depth 1.
|
||||
Parent *Traversal
|
||||
}
|
||||
|
||||
// VirtualPathOf returns the colon-delimited virtual path for an entry within this archive. It is
|
||||
// nil-safe: with no traversal the entry path is returned unchanged, matching non-nested behavior.
|
||||
// The entry path's leading slash is trimmed so chains match the historical java cataloger format,
|
||||
// which joins slash-less zip entry names onto the containing archive's path.
|
||||
// VirtualPathOf returns the colon-delimited virtual path for an entry within this archive, or the
|
||||
// entry path unchanged on a nil traversal. The leading slash is trimmed to match the java cataloger
|
||||
// format, which joins slash-less zip entry names onto the containing archive's path.
|
||||
func (t *Traversal) VirtualPathOf(entryPath string) string {
|
||||
if t == nil {
|
||||
return entryPath
|
||||
@@ -55,16 +40,14 @@ func (t *Traversal) VirtualPathOf(entryPath string) string {
|
||||
|
||||
type limiterCtxKey struct{}
|
||||
|
||||
// WithLimiter returns a context carrying the scan's archive limiter, so a cataloger running within
|
||||
// the recursive walk can read the scan's live draw on the memory and disk budgets (Limiter.InUse) -
|
||||
// the same totals the limits are enforced against. Carried on the context rather than copied onto
|
||||
// each Traversal so there is one source of truth for the charge, not a snapshot per level.
|
||||
// WithLimiter returns a context carrying the scan's archive limiter, so a cataloger inside the walk
|
||||
// can read the live draw on the budgets (Limiter.InUse). It rides the context rather than each
|
||||
// Traversal so every level reads one shared total.
|
||||
func WithLimiter(ctx context.Context, l *Limiter) context.Context {
|
||||
return context.WithValue(ctx, limiterCtxKey{}, l)
|
||||
}
|
||||
|
||||
// LimiterFromContext returns the scan's archive limiter, or nil when the current pass is not running
|
||||
// within the recursive archive walk.
|
||||
// LimiterFromContext returns the scan's archive limiter, or nil outside the recursive archive walk.
|
||||
func LimiterFromContext(ctx context.Context) *Limiter {
|
||||
l, _ := ctx.Value(limiterCtxKey{}).(*Limiter)
|
||||
return l
|
||||
@@ -77,8 +60,7 @@ 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 when the current
|
||||
// cataloging pass is not running within an archive.
|
||||
// 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
|
||||
|
||||
@@ -15,24 +15,18 @@ func TestTraversalContextRoundTrip(t *testing.T) {
|
||||
assert.Nil(t, TraversalFromContext(context.Background()))
|
||||
|
||||
trav := &Traversal{
|
||||
Location: file.NewLocation("app.war"),
|
||||
VirtualPath: "app.war",
|
||||
FileSystemID: "",
|
||||
Depth: 1,
|
||||
Location: file.NewLocation("app.war"),
|
||||
VirtualPath: "app.war",
|
||||
}
|
||||
ctx := WithTraversal(context.Background(), trav)
|
||||
require.Same(t, trav, TraversalFromContext(ctx))
|
||||
|
||||
child := &Traversal{
|
||||
Location: file.NewLocation("/WEB-INF/lib/dep.jar"),
|
||||
VirtualPath: trav.VirtualPathOf("/WEB-INF/lib/dep.jar"),
|
||||
FileSystemID: "",
|
||||
Depth: 2,
|
||||
Parent: trav,
|
||||
Location: file.NewLocation("/WEB-INF/lib/dep.jar"),
|
||||
VirtualPath: trav.VirtualPathOf("/WEB-INF/lib/dep.jar"),
|
||||
}
|
||||
ctx = WithTraversal(ctx, child)
|
||||
require.Same(t, child, TraversalFromContext(ctx))
|
||||
assert.Same(t, trav, TraversalFromContext(ctx).Parent)
|
||||
}
|
||||
|
||||
func TestTraversal_VirtualPathOf(t *testing.T) {
|
||||
@@ -73,8 +67,8 @@ func TestTraversal_VirtualPathOf(t *testing.T) {
|
||||
expected: "app.war:WEB-INF/lib/dep.jar:inner/most.jar",
|
||||
},
|
||||
{
|
||||
// a colon in the entry name itself must not be mistaken for a chain delimiter -
|
||||
// see decisions.md#colon-in-an-entry-path-is-escaped
|
||||
// a colon in the entry name must not be mistaken for a chain delimiter - see
|
||||
// decisions.md#colon-in-an-entry-path-is-escaped
|
||||
name: "colon in entry path is escaped",
|
||||
traversal: &Traversal{VirtualPath: "app.war"},
|
||||
entryPath: "WEB-INF/lib/dep:special.jar",
|
||||
@@ -87,8 +81,8 @@ func TestTraversal_VirtualPathOf(t *testing.T) {
|
||||
expected: "app.war:lib/a%3Ab%3Ac.jar",
|
||||
},
|
||||
{
|
||||
// a traversal carrying no virtual path (as distinct from a nil traversal) still
|
||||
// yields the entry path alone, never a bare leading colon
|
||||
// a traversal carrying no virtual path (as distinct from a nil traversal) still yields
|
||||
// the entry path alone, never a bare leading colon
|
||||
name: "empty virtual path yields the entry path alone, no leading colon",
|
||||
traversal: &Traversal{VirtualPath: ""},
|
||||
entryPath: "WEB-INF/lib/dep.jar",
|
||||
@@ -102,13 +96,11 @@ func TestTraversal_VirtualPathOf(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTraversal_VirtualPathOf_noColonIsByteIdenticalToPreEscaping is the assertion that carries
|
||||
// the compatibility guarantee behind java-virtual-path-identity-preserved
|
||||
// (decisions.md#colon-in-an-entry-path-is-escaped): escaping a colon in an entry path must not
|
||||
// alter the result for any entry path that does not contain one. Every case below has no colon,
|
||||
// and every expected value is exactly what VirtualPathOf produced before escaping existed - do
|
||||
// not "fix" a failure here by updating the expected string, since that is precisely the
|
||||
// regression this test exists to catch.
|
||||
// TestTraversal_VirtualPathOf_noColonIsByteIdenticalToPreEscaping carries the compatibility guarantee
|
||||
// behind java-virtual-path-identity-preserved (decisions.md#colon-in-an-entry-path-is-escaped):
|
||||
// escaping a colon must not alter the result for any entry path without one. Every case below is
|
||||
// colon-free, and every expected value is what VirtualPathOf produced before escaping existed. Do not
|
||||
// fix a failure here by updating the expected string - that is the regression this test catches.
|
||||
func TestTraversal_VirtualPathOf_noColonIsByteIdenticalToPreEscaping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -154,19 +146,13 @@ func TestTraversal_VirtualPathOf_noColonIsByteIdenticalToPreEscaping(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
// TestTraversal_VirtualPathOf_escapingComposesAcrossNestingLevels asserts that escaping is
|
||||
// applied at every level of a chain, not only the first: a colon in an entry name at a deeper
|
||||
// nesting level is just as ambiguous as one at the outermost level, so it gets the same
|
||||
// treatment. After composing, splitting the result on ':' must yield exactly one segment per
|
||||
// real archive boundary - none of the escaped colons survive as a literal ':' byte, so they
|
||||
// cannot be mistaken for delimiters by a naive splitter.
|
||||
// TestTraversal_VirtualPathOf_escapingComposesAcrossNestingLevels asserts escaping applies at every
|
||||
// level of a chain, not only the first: splitting the composed result on ':' yields exactly one
|
||||
// segment per archive boundary, since no escaped colon survives as a literal ':' byte.
|
||||
func TestTraversal_VirtualPathOf_theEscapeCharacterIsNotItselfEscaped(t *testing.T) {
|
||||
// a known and accepted limit, not a defect: an entry path holding the literal text %3A is
|
||||
// indistinguishable from one holding a colon. Escaping % as %25 would close it and is
|
||||
// deliberately not done, because % is far more common in real paths than : and closing this
|
||||
// would move a large number of well-defined identities to resolve a collision between two
|
||||
// inputs both already assumed absent. Asserted so the limit is a decision on the record
|
||||
// rather than something a later reader discovers and "fixes".
|
||||
// a known and accepted limit: an entry path holding the literal text %3A is indistinguishable from
|
||||
// one holding a colon. Escaping % as %25 would close it and is deliberately not done, since % is far
|
||||
// more common in real paths than : and doing so would move many well-defined identities.
|
||||
t.Parallel()
|
||||
|
||||
outer := &Traversal{VirtualPath: "outer.zip"}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/internal/tmpdir"
|
||||
)
|
||||
|
||||
// workDirName prefixes the directory holding one archive's content and entries, so a scan's leftovers
|
||||
// are recognizable. Tests that sample what is on disk match on it.
|
||||
const workDirName = "syft-archive"
|
||||
|
||||
// WorkDir is the scratch space one archive writes into, created lazily the first time something needs
|
||||
// to write - an archive's own bytes spilling out of memory, or its entries spilling into the overflow
|
||||
// blob.
|
||||
//
|
||||
// Eager creation would put one directory on disk per archive opened, whether or not it ever wrote a
|
||||
// byte; most do not, and a scan walks far more archives than it spills. So the path is asked for at
|
||||
// the point of writing. A nil *WorkDir has no path and fails the write.
|
||||
type WorkDir struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// create makes the directory and returns it with the func that removes it. Called at most once.
|
||||
create func() (string, func(), error)
|
||||
|
||||
path string
|
||||
remove func()
|
||||
err error
|
||||
done bool
|
||||
}
|
||||
|
||||
// NewWorkDir returns the lazily-created work directory for one archive, rooted under the scan's temp
|
||||
// root (internal/tmpdir) when the context carries one - so files land where the caller configured and
|
||||
// are removed together if an archive's own cleanup is missed - otherwise the system temp dir.
|
||||
func NewWorkDir(ctx context.Context) *WorkDir {
|
||||
return &WorkDir{create: func() (string, func(), error) {
|
||||
if td := tmpdir.FromContext(ctx); td != nil {
|
||||
dir, cleanup, err := td.NewChild(workDirName) //nolint:gocritic // cleanup is returned, not deferred here
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("unable to create temp dir for archive extraction: %w", err)
|
||||
}
|
||||
return dir, cleanup, nil
|
||||
}
|
||||
|
||||
dir, err := os.MkdirTemp("", workDirName+"-")
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("unable to create temp dir for archive extraction: %w", err)
|
||||
}
|
||||
return dir, func() {
|
||||
if rmErr := os.RemoveAll(dir); rmErr != nil {
|
||||
log.WithFields("dir", dir, "error", rmErr).Trace("unable to remove archive temp dir")
|
||||
}
|
||||
}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// Path returns the directory, creating it on the first call. Later calls return the same result,
|
||||
// including the same failure.
|
||||
func (w *WorkDir) Path() (string, error) {
|
||||
if w == nil {
|
||||
return "", fmt.Errorf("no work directory for archive content")
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if !w.done {
|
||||
w.path, w.remove, w.err = w.create()
|
||||
w.done = true
|
||||
}
|
||||
return w.path, w.err
|
||||
}
|
||||
|
||||
// Remove deletes the directory and its contents, and does nothing if it was never created. Safe to
|
||||
// call more than once.
|
||||
func (w *WorkDir) Remove() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.remove == nil {
|
||||
return
|
||||
}
|
||||
w.remove()
|
||||
w.remove = nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/internal/tmpdir"
|
||||
)
|
||||
|
||||
func TestWorkDir_isNotCreatedUntilAskedFor(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
w := NewWorkDir(tmpdir.WithValue(context.Background(), tmpdir.FromPath(root)))
|
||||
|
||||
assert.False(t, w.created(), "constructing a work directory must touch nothing")
|
||||
entries, err := os.ReadDir(root)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries)
|
||||
|
||||
dir, err := w.Path()
|
||||
require.NoError(t, err)
|
||||
assert.DirExists(t, dir)
|
||||
assert.True(t, w.created())
|
||||
}
|
||||
|
||||
func TestWorkDir_pathIsStableAcrossCalls(t *testing.T) {
|
||||
w := NewWorkDir(tmpdir.WithValue(context.Background(), tmpdir.FromPath(t.TempDir())))
|
||||
|
||||
first, err := w.Path()
|
||||
require.NoError(t, err)
|
||||
second, err := w.Path()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first, second, "the directory is created once, not once per writer")
|
||||
}
|
||||
|
||||
func TestWorkDir_concurrentCallersShareOneDirectory(t *testing.T) {
|
||||
// the store spills under its own lock while the archive's content may be spilling too, so the
|
||||
// creation has to be safe to race
|
||||
root := t.TempDir()
|
||||
w := NewWorkDir(tmpdir.WithValue(context.Background(), tmpdir.FromPath(root)))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
paths := make([]string, 8)
|
||||
for i := range paths {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
dir, err := w.Path()
|
||||
assert.NoError(t, err)
|
||||
paths[i] = dir
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, p := range paths {
|
||||
assert.Equal(t, paths[0], p)
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 1, "racing callers must not each create a directory")
|
||||
}
|
||||
|
||||
func TestWorkDir_removeIsANoOpWhenNothingWasCreated(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
w := NewWorkDir(tmpdir.WithValue(context.Background(), tmpdir.FromPath(root)))
|
||||
|
||||
w.Remove()
|
||||
w.Remove()
|
||||
|
||||
entries, err := os.ReadDir(root)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries)
|
||||
}
|
||||
|
||||
func TestWorkDir_removeDeletesWhatWasCreated(t *testing.T) {
|
||||
w := NewWorkDir(tmpdir.WithValue(context.Background(), tmpdir.FromPath(t.TempDir())))
|
||||
|
||||
dir, err := w.Path()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "blob"), []byte("x"), 0o600))
|
||||
|
||||
w.Remove()
|
||||
assert.NoDirExists(t, dir)
|
||||
|
||||
// safe to call more than once, as the extraction's cleanup is
|
||||
w.Remove()
|
||||
}
|
||||
|
||||
func TestWorkDir_nilHasNowhereToWrite(t *testing.T) {
|
||||
var w *WorkDir
|
||||
|
||||
_, err := w.Path()
|
||||
assert.Error(t, err, "a caller with nowhere to put content must fail rather than pick a directory")
|
||||
assert.False(t, w.created())
|
||||
w.Remove()
|
||||
}
|
||||
Reference in New Issue
Block a user