package archive import ( "context" "crypto" "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. 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. 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. ArchivePath string } // IndexResult reports what indexing one archive's overflow tar 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 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. 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. 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") return nil } digests, err := intFile.NewDigestsFromFile(ctx, io.NopCloser(content.Reader), archiveDigestHashes) if err != nil { log.WithFields("archive", accessPath, "error", err).Trace("unable to take archive digests") return nil } return digests } // 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 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 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 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 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. 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. 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") } } 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 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. // // 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 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 } charge := limiter.Charge() limits.Charge = charge var held Content cleanup := func() { if closeErr := held.Close(); closeErr != nil { log.WithFields("archive", archiveAccessPath, "error", closeErr).Trace("unable to close archive content") } held = Content{} removeWorkDir() charge.Release() } 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 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) cleanup() return nil, nil } resolver, result, err := overflowAndIndex(ctx, extractor, held, workDir, fileSystemID, archivePath, archiveAccessPath, limits, newResolver, newStoreResolver, notify) if err != nil { cleanup() return nil, err } return &ExtractedArchive{ 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. // // 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. type inPlaceContent interface { io.Reader io.ReaderAt io.Seeker // OverflowArchiveEntry marks a reader over one entry of an archive's overflow tar. 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) { if entry, ok := r.(inPlaceContent); ok { return Content{Name: name, Reader: entry}, nil } return holdContent(r, workDir, name, charge, notify) } // 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. func archiveFileName(accessPath string) string { name := filepath.Base(accessPath) if name == "" || name == "." || name == ".." || name == string(filepath.Separator) { return "archive" } 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. // // 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) { 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) if err != nil { if closeErr := store.Close(); closeErr != nil { log.WithFields("archive", fsID, "error", closeErr).Trace("unable to close archive entry store") } 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. 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") } return nil, result, ErrDiskLimitReached } resolver, indexed, err := newStoreResolver(store, Overflow{ RootDir: contentsDir, FileSystemID: fsID, ArchivePath: archivePath, }) if err != nil { if closeErr := store.Close(); closeErr != nil { log.WithFields("archive", fsID, "error", closeErr).Trace("unable to close archive entry store") } return nil, result, fmt.Errorf("unable to index extracted archive %q: %w", fsID, err) } result.FilesExtracted = indexed.Records if notify != nil && result.Truncated() { notify(Truncated{Archive: accessPath, Reason: result.Truncation}) } return resolver, result, nil }