Files
syft/internal/archive/orchestrator.go
T
Thomas Bechtold f7ea072900 feat(archive): add Orchestrator for iterative archive extraction
Implement the Orchestrator that manages the iterative
extract-catalog loop for recursive archive discovery:

- Scans the composite resolver for candidate archive locations,
  detecting format by peeking 512 bytes via FileContentsByLocation
  rather than copying every non-archive file to a probe-temp file
- Extracts identified archives to per-archive temp directories
- Builds child resolvers via an injectable ResolverFactory (kept
  pluggable so tests can substitute a mock resolver without
  importing syft/internal/fileresolver)
- Tracks extraction state to avoid reprocessing
- Enforces safety limits (depth, per-archive size, file count,
  total extraction budget)
- Generates ContainsRelationship entries linking each archive to
  its extracted-contents filesystem
- Manages temp directory lifecycle with Cleanup()

Signed-off-by: Thomas Bechtold <thomasbechtold@jpberlin.de>
2026-04-27 08:48:56 +02:00

363 lines
12 KiB
Go

package archive
import (
"bytes"
"context"
"fmt"
"io"
"os"
"sync"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/cataloging"
"github.com/anchore/syft/syft/file"
)
// ResolverFactory creates a file.Resolver for a directory path.
// This is injectable to avoid direct dependency on syft/internal/fileresolver.
type ResolverFactory func(root string) (file.Resolver, error)
// Orchestrator manages iterative archive extraction and cataloging. It discovers
// archives in the file resolver, extracts them, builds child resolvers for their
// contents, and tracks the relationships between archives and their contents.
type Orchestrator struct {
composite *CompositeResolver
extractors []Extractor
config cataloging.ArchiveSearchConfig
tmpDir string
resolverFactory ResolverFactory
// tracking state
processed map[processedKey]bool // archives we've already considered for extraction
totalBytesWritten int64
archiveNodes []*archiveNode // for relationship tracking
cleanups []func()
mu sync.Mutex
}
// archiveNode tracks an extracted archive for relationship generation.
type archiveNode struct {
location file.Location // where the archive is in the resolver
fsID string // the filesystem ID assigned to this archive's contents
depth int
}
// processedKey identifies an archive location uniquely. We key the processed
// map on a struct (not a "<fsID>:<realPath>" string) because both fields can
// legally contain ':' — fsIDs are emitted as "archive:<hex>" and POSIX paths
// permit colons — so a string key would have an ambiguous boundary.
type processedKey struct {
fsID string
realPath string
}
// NewOrchestrator creates an Orchestrator that manages archive extraction.
// The resolverFactory is used to build file.Resolvers for extracted archive directories.
func NewOrchestrator(baseResolver file.Resolver, cfg cataloging.ArchiveSearchConfig, tmpDir string, resolverFactory ResolverFactory) *Orchestrator {
return &Orchestrator{
composite: NewCompositeResolver(baseResolver),
extractors: DefaultExtractors(),
config: cfg,
tmpDir: tmpDir,
resolverFactory: resolverFactory,
processed: make(map[processedKey]bool),
}
}
// Resolver returns the composite resolver that includes all extracted archive contents.
func (o *Orchestrator) Resolver() *CompositeResolver {
return o.composite
}
// DiscoverAndExtract finds archives in the current resolver view, extracts them,
// and adds their contents as child resolvers. Returns the number of new archives extracted.
// The depth parameter indicates the current nesting depth (0 = scanning base filesystem).
func (o *Orchestrator) DiscoverAndExtract(ctx context.Context, depth int) int {
if depth >= o.config.MaxDepth {
return 0
}
o.mu.Lock()
defer o.mu.Unlock()
if o.config.MaxTotalExtractionBytes > 0 && o.totalBytesWritten >= o.config.MaxTotalExtractionBytes {
log.Debug("archive orchestrator: total extraction size limit reached, stopping")
return 0
}
// build the list of extractors actually permitted by config — every
// non-archive location in the resolver is going to pay a peek read against
// these, so excluding disabled types up-front avoids reading files we
// would only end up rejecting after detection.
enabled := o.enabledExtractors()
if len(enabled) == 0 {
return 0
}
newArchives := 0
// scan all locations in the current resolver for archives
for loc := range o.composite.AllLocations(ctx) {
if ctx.Err() != nil {
break
}
// skip already-processed archives
key := processedKey{fsID: loc.FileSystemID, realPath: loc.RealPath}
if o.processed[key] {
continue
}
// skip excluded extensions
if IsExcludedExtension(loc.RealPath, o.config.ExcludeExtensions) {
continue
}
// check if this location is an archive we can extract — peek a small
// buffer rather than materializing the full file content, which would
// be a full disk-to-disk copy of every non-archive file in the resolver.
ext := o.detectFormat(ctx, loc, enabled)
if ext == nil {
continue
}
// mark as processed before extraction to avoid re-processing
o.processed[key] = true
// extract the archive
extracted, err := o.extractArchive(ctx, ext, loc, depth+1)
if err != nil {
log.WithFields("archive", loc.Path(), "error", err).Debug("failed to extract archive")
continue
}
if extracted {
newArchives++
}
}
return newArchives
}
// enabledExtractors returns the subset of o.extractors whose corresponding
// config gate is on (IncludeIndexedArchives for ZipExtractor,
// IncludeUnindexedArchives for TarExtractor).
func (o *Orchestrator) enabledExtractors() []Extractor {
var out []Extractor
for _, ext := range o.extractors {
switch ext.(type) {
case *ZipExtractor:
if o.config.IncludeIndexedArchives {
out = append(out, ext)
}
case *TarExtractor:
if o.config.IncludeUnindexedArchives {
out = append(out, ext)
}
default:
// unknown extractor types: include by default so plugin extractors
// added in the future aren't silently disabled
out = append(out, ext)
}
}
return out
}
// extractArchive extracts a single archive and registers its contents as a child resolver.
func (o *Orchestrator) extractArchive(ctx context.Context, ext Extractor, loc file.Location, depth int) (bool, error) {
// create a temp dir for this archive's contents
extractDir, err := os.MkdirTemp(o.tmpDir, fmt.Sprintf("archive-depth%d-*", depth))
if err != nil {
return false, fmt.Errorf("unable to create temp dir: %w", err)
}
o.cleanups = append(o.cleanups, func() {
os.RemoveAll(extractDir)
})
// extractors need a file path, so materialize the archive contents to a temp file
tmpPath, err := o.copyArchiveToTempFile(loc)
if err != nil {
return false, err
}
defer os.Remove(tmpPath)
// calculate remaining budget
limits := ExtractionLimits{
MaxExtractionSizeBytes: o.config.MaxExtractionSizeBytes,
MaxFileCount: o.config.MaxFileCount,
}
if o.config.MaxTotalExtractionBytes > 0 {
remaining := o.config.MaxTotalExtractionBytes - o.totalBytesWritten
if remaining <= 0 {
return false, fmt.Errorf("total extraction budget exhausted")
}
if limits.MaxExtractionSizeBytes == 0 || remaining < limits.MaxExtractionSizeBytes {
limits.MaxExtractionSizeBytes = remaining
}
}
result, err := ext.Extract(ctx, tmpPath, extractDir, limits)
if err != nil {
log.WithFields("archive", loc.Path(), "error", err).Debug("archive extraction stopped")
// partial extraction is still useful, continue if we got some files
if result.FilesExtracted == 0 {
return false, err
}
}
o.totalBytesWritten += result.BytesWritten
if result.FilesExtracted == 0 {
return false, nil
}
// build a directory resolver for the extracted contents
childResolver, resolverErr := o.resolverFactory(extractDir)
if resolverErr != nil {
return false, fmt.Errorf("unable to build resolver for extracted archive: %w", resolverErr)
}
fsID := o.composite.AddChild(childResolver, loc, depth)
// track for relationship generation
o.archiveNodes = append(o.archiveNodes, &archiveNode{
location: loc,
fsID: fsID,
depth: depth,
})
log.WithFields(
"archive", loc.Path(),
"depth", depth,
"files", result.FilesExtracted,
"bytes", result.BytesWritten,
).Debug("extracted archive contents")
return true, nil
}
// formatProbeBytes is the size of the buffer peeked from each candidate file
// to decide whether it's an extractable archive.
//
// Single-step formats only need their magic bytes (ZIP: 4, gzip/bzip2/xz/zstd:
// 2-6, tar: a 512-byte first-block header). The size that matters is for
// compressed-tar without a recognized extension: there mholt/archives must
// gzip-decode our peek buffer and feed the result to tar.NewReader.Next(),
// which needs a full 512-byte tar header out the back. 4 KiB of compressed
// input gives gzip a comfortable margin and is still a single filesystem
// block read.
const formatProbeBytes = 4096
// detectFormat returns the first enabled extractor that can handle loc by
// peeking a small buffer of its contents (no full materialization). Returns
// nil when no extractor matches or when the file can't be opened. The caller
// is responsible for narrowing extractors to those permitted by config so we
// don't pay peek I/O for archive types that would only be rejected later.
func (o *Orchestrator) detectFormat(ctx context.Context, loc file.Location, extractors []Extractor) Extractor {
reader, err := o.composite.FileContentsByLocation(loc)
if err != nil {
return nil
}
defer internal.CloseAndLogError(reader, loc.RealPath)
var peek [formatProbeBytes]byte
n, _ := io.ReadFull(reader, peek[:])
if n == 0 {
return nil
}
for _, ext := range extractors {
if ext.CanExtract(ctx, loc.RealPath, bytes.NewReader(peek[:n])) {
return ext
}
}
return nil
}
// copyArchiveToTempFile reads the archive contents from the resolver and writes them
// to a freshly-created temp file under o.tmpDir. The returned path must be removed
// by the caller (typically via defer).
func (o *Orchestrator) copyArchiveToTempFile(loc file.Location) (string, error) {
reader, err := o.composite.FileContentsByLocation(loc)
if err != nil {
return "", fmt.Errorf("unable to get archive contents: %w", err)
}
defer internal.CloseAndLogError(reader, loc.RealPath)
tmpFile, err := os.CreateTemp(o.tmpDir, "archive-src-*")
if err != nil {
return "", fmt.Errorf("unable to create temp file: %w", err)
}
_, copyErr := safeIOCopy(tmpFile, reader)
tmpFile.Close()
if copyErr != nil {
os.Remove(tmpFile.Name())
return "", fmt.Errorf("unable to copy archive to temp: %w", copyErr)
}
return tmpFile.Name(), nil
}
// Relationships returns the archive containment relationships discovered during extraction.
func (o *Orchestrator) Relationships() []artifact.Relationship {
o.mu.Lock()
defer o.mu.Unlock()
var rels []artifact.Relationship
for _, node := range o.archiveNodes {
// Create a "contains" relationship: the archive contains its extracted content
// The archive file (identified by its coordinates) contains the child filesystem
rels = append(rels, artifact.Relationship{
From: node.location.Coordinates,
To: file.Coordinates{RealPath: "/", FileSystemID: node.fsID},
Type: artifact.ContainsRelationship,
})
}
return rels
}
// Cleanup removes all temporary directories created during extraction.
func (o *Orchestrator) Cleanup() {
o.mu.Lock()
defer o.mu.Unlock()
for _, cleanup := range o.cleanups {
cleanup()
}
o.cleanups = nil
}
// safeIOCopy copies with a size limit to prevent decompression bombs.
func safeIOCopy(dst *os.File, src interface{ Read([]byte) (int, error) }) (int64, error) {
const maxCopySize = 2 * 1024 * 1024 * 1024 // 2GB
return limitedCopy(dst, src, maxCopySize)
}
func limitedCopy(dst *os.File, src interface{ Read([]byte) (int, error) }, limit int64) (int64, error) {
n, err := copyN(dst, src, limit)
if n >= limit {
return n, fmt.Errorf("copy size limit reached (%d bytes)", limit)
}
return n, err
}
func copyN(dst *os.File, src interface{ Read([]byte) (int, error) }, limit int64) (int64, error) {
buf := make([]byte, 32*1024)
var written int64
for written < limit {
nr, readErr := src.Read(buf)
if nr > 0 {
nw, writeErr := dst.Write(buf[:nr])
written += int64(nw)
if writeErr != nil {
return written, writeErr
}
}
if readErr != nil {
if readErr == io.EOF {
return written, nil
}
return written, readErr
}
}
return written, nil
}