Files
syft/internal/archive/content.go
T
2026-09-22 00:23:36 -04:00

159 lines
5.0 KiB
Go

package archive
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/anchore/syft/internal/log"
)
// copyChunkSize is how many bytes are read and charged at a time, bounding how far a charge runs
// ahead of what is actually held.
const copyChunkSize = 32 * 1024
// ReaderAtSeeker is the random access an archive format needs: a zip is read from its central
// directory at the end of the stream.
type ReaderAtSeeker interface {
io.Reader
io.ReaderAt
io.Seeker
}
// Content is one archive's bytes, ready for random access.
type Content struct {
ReaderAtSeeker
// release gives back what holding the bytes cost; nil when they are read in place
release func()
}
// Release drops the held bytes and refunds their charge. Safe to call more than once.
func (c *Content) Release() {
if c.release != nil {
c.release()
c.release = nil
}
}
// acquireContent makes an archive's bytes random-access. Bytes that already are, such as a file on
// disk or an entry of a parent archive, are read in place and cost nothing. Anything else is held
// in memory or written to disk within the limits.
func acquireContent(r io.Reader, name string, workDir *WorkDir, charge *Charge) (Content, error) {
if ras, ok := r.(ReaderAtSeeker); ok {
return Content{ReaderAtSeeker: ras}, nil
}
return holdContent(r, name, workDir, charge)
}
// holdContent holds an archive's bytes in memory while the memory limit admits them and writes them
// to disk once it does not. Reaching the disk limit yields ErrDiskLimitReached.
func holdContent(r io.Reader, name string, workDir *WorkDir, charge *Charge) (Content, error) {
held, rest, err := readWhileMemoryAdmits(r, charge)
if err != nil {
return Content{}, fmt.Errorf("unable to read archive content: %w", err)
}
if rest == nil {
return Content{
ReaderAtSeeker: bytes.NewReader(held),
release: func() { charge.RefundMemory(int64(len(held))) },
}, nil
}
log.WithFields("archive", name).Debug("archive content does not fit in memory; writing it to disk")
charge.RefundMemory(int64(len(held)))
return writeContentToDisk(io.MultiReader(bytes.NewReader(held), rest), workDir, charge)
}
// readWhileMemoryAdmits reads r into memory, charging each chunk before holding it so a stream larger
// than the memory limit is never held in full. When a chunk is refused it returns what is held so
// far, still charged, and a reader over the refused chunk followed by the rest of r. rest is nil when
// all of r fit.
func readWhileMemoryAdmits(r io.Reader, charge *Charge) (held []byte, rest io.Reader, err error) {
chunk := make([]byte, copyChunkSize)
for {
n, readErr := r.Read(chunk)
if n > 0 {
if !charge.Memory(int64(n)) {
return held, io.MultiReader(bytes.NewReader(chunk[:n]), r), nil
}
held = append(held, chunk[:n]...)
}
if errors.Is(readErr, io.EOF) {
return held, nil, nil
}
if readErr != nil {
return nil, nil, readErr
}
}
}
// contentFileName is the file an archive's own bytes are written to when they do not fit in memory.
const contentFileName = "archive"
// writeContentToDisk writes r into the work directory, charging the disk limit as bytes land, and
// returns a reader over the file. Reaching the disk limit removes the file and yields
// ErrDiskLimitReached.
func writeContentToDisk(r io.Reader, workDir *WorkDir, charge *Charge) (Content, error) {
dir, err := workDir.Path()
if err != nil {
return Content{}, err
}
path := filepath.Join(dir, contentFileName)
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return Content{}, fmt.Errorf("unable to create archive content file: %w", err)
}
written, err := copyCharged(f, r, charge)
release := func() {
charge.RefundDisk(written)
if err := f.Close(); err != nil {
log.WithFields("path", path, "error", err).Trace("unable to close archive content file")
}
if err := os.Remove(path); err != nil {
log.WithFields("path", path, "error", err).Trace("unable to remove archive content file")
}
}
if err == nil {
_, err = f.Seek(0, io.SeekStart)
}
if err != nil {
release()
if errors.Is(err, ErrDiskLimitReached) {
return Content{}, err
}
return Content{}, fmt.Errorf("unable to write archive content file: %w", err)
}
return Content{ReaderAtSeeker: f, release: release}, nil
}
// copyCharged copies src to dst, charging each chunk to the disk limit before writing it. It stops
// with ErrDiskLimitReached at the first refused chunk; bytes written before that stay charged.
func copyCharged(dst io.Writer, src io.Reader, charge *Charge) (written int64, err error) {
chunk := make([]byte, copyChunkSize)
for {
n, readErr := src.Read(chunk)
if n > 0 {
if !charge.Disk(int64(n)) {
return written, ErrDiskLimitReached
}
w, writeErr := dst.Write(chunk[:n])
written += int64(w)
charge.RefundDisk(int64(n - w))
if writeErr != nil {
return written, writeErr
}
}
if errors.Is(readErr, io.EOF) {
return written, nil
}
if readErr != nil {
return written, readErr
}
}
}