chore: revert custom index in favor of key_Split

Signed-off-by: Keith Zantow <keith.zantow@anchore.com>
This commit is contained in:
Keith Zantow
2026-09-20 11:22:10 -04:00
parent 99615b7dfa
commit 53cd4254e4
81 changed files with 7461 additions and 6624 deletions
+30 -53
View File
@@ -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
View File
@@ -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)
}
+47
View File
@@ -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
View File
@@ -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()
+45 -45
View File
@@ -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
View File
@@ -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)) {
File diff suppressed because it is too large Load Diff
+59
View File
@@ -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
View File
@@ -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
+14 -20
View File
@@ -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
View File
@@ -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:
+14 -36
View File
@@ -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++
}
-352
View File
@@ -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
}
-221
View File
@@ -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
}
+34 -108
View File
@@ -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
View File
@@ -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})
}
+130 -62
View File
@@ -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
+215
View File
@@ -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
}
+14 -32
View File
@@ -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
+19 -33
View File
@@ -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"}
+90
View File
@@ -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
}
+101
View File
@@ -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()
}