Files
syft/internal/archive/overflow.go
T
2026-09-10 08:45:36 -04:00

353 lines
14 KiB
Go

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
}