Files
syft/syft/archive_cataloger_test.go
T
2026-09-22 00:23:36 -04:00

787 lines
30 KiB
Go

package syft
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
"io"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"testing"
"unicode/utf8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wagoodman/go-partybus"
"github.com/wagoodman/go-progress"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/cataloging"
"github.com/anchore/syft/syft/cataloging/filecataloging"
"github.com/anchore/syft/syft/cataloging/pkgcataloging"
"github.com/anchore/syft/syft/event"
"github.com/anchore/syft/syft/event/monitor"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/java"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
"github.com/anchore/syft/syft/source/directorysource"
)
// markerCataloger is a stub package cataloger emitting one package per "marker.txt" it finds, so the
// end-to-end tests assert behavior without depending on any real cataloger's fixture format.
type markerCataloger struct{}
// TestArchiveCataloger_endToEnd scans a directory holding a zip with a marker file inside: the
// package found there must carry the archive's nesting-chain FileSystemID, and a file-level CONTAINS
// relationship must link the archive to it.
func TestArchiveCataloger_endToEnd(t *testing.T) {
scanDir := t.TempDir()
writeTestZip(t, filepath.Join(scanDir, "app.zip"), map[string]string{"nested/marker.txt": "hello"})
src, err := directorysource.New(directorysource.Config{Path: scanDir})
require.NoError(t, err)
t.Cleanup(func() { _ = src.Close() })
cfg := DefaultCreateSBOMConfig().
WithoutCatalogers().
WithCatalogers(pkgcataloging.NewCatalogerReference(markerCataloger{}, []string{"directory"}))
cfg.Archive.MaxDepth = 1
s, err := cfg.Create(context.Background(), src)
require.NoError(t, err)
pkgs := s.Artifacts.Packages.Sorted()
require.Len(t, pkgs, 1, "expected exactly one package, discovered inside the archive")
found := pkgs[0]
assert.Equal(t, "marker-pkg", found.Name)
locs := found.Locations.ToSlice()
require.NotEmpty(t, locs)
// the root directory's FSID is empty, so the chain starts with the relative archive path
assert.Equal(t, "app.zip", locs[0].ArchivePath, "package location should carry the archive's FileSystemID")
// a file-level CONTAINS relationship links the archive file to the discovered package
var hasContains bool
for _, rel := range s.Relationships {
if rel.Type != artifact.ContainsRelationship || rel.To.ID() != found.ID() {
continue
}
coord, ok := rel.From.(file.Coordinates)
if ok && strings.HasSuffix(coord.RealPath, "app.zip") {
hasContains = true
}
}
assert.True(t, hasContains, "expected a CONTAINS relationship from app.zip to the discovered package")
}
// TestArchiveCataloger_nestedRecursion covers depth-limited recursion and the FileSystemID chain: a
// marker inside inner.zip inside outer.zip carries the full chain.
func TestArchiveCataloger_nestedRecursion(t *testing.T) {
innerZip := buildZipBytes(t, map[string]string{"nested/marker.txt": "hello"})
scanDir := t.TempDir()
writeTestZipRaw(t, filepath.Join(scanDir, "outer.zip"), map[string][]byte{
"deeper/inner.zip": innerZip,
})
src, err := directorysource.New(directorysource.Config{Path: scanDir})
require.NoError(t, err)
t.Cleanup(func() { _ = src.Close() })
cfg := DefaultCreateSBOMConfig().
WithoutCatalogers().
WithCatalogers(pkgcataloging.NewCatalogerReference(markerCataloger{}, []string{"directory"}))
cfg.Archive.MaxDepth = 2
s, err := cfg.Create(context.Background(), src)
require.NoError(t, err)
pkgs := s.Artifacts.Packages.Sorted()
require.Len(t, pkgs, 1)
locs := pkgs[0].Locations.ToSlice()
require.NotEmpty(t, locs)
assert.Equal(t, "outer.zip:deeper/inner.zip", locs[0].ArchivePath,
"nested package location should carry the full archive nesting chain as its FileSystemID")
}
func TestArchiveCataloger_jarInsideArchiveCatalogedOnce(t *testing.T) {
// the failure mode is a duplicate, not an error: a jar inside a zip can be reached by the task's
// walk and by the java cataloger's own unarchiving, so count rather than assert presence
dep := jarBytes(t, "nested-lib", "2.0", nil)
tests := []struct {
name string
container string
entry string
}{
{name: "jar inside a zip", container: "bundle.zip", entry: "lib/nested-lib-2.0.jar"},
{name: "jar inside a war", container: "app.war", entry: "WEB-INF/lib/nested-lib-2.0.jar"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
scanDir := t.TempDir()
writeTestZipRaw(t, filepath.Join(scanDir, tt.container), map[string][]byte{tt.entry: dep})
s := scanDirWith(t, scanDir, javaScanConfig(3))
var count int
for _, p := range s.Artifacts.Packages.Sorted() {
if p.Name == "nested-lib" {
count++
}
}
assert.Equal(t, 1, count, "the nested jar's package must be cataloged exactly once")
})
}
}
func TestArchiveCataloger_javaVirtualPathUnchangedByMechanism(t *testing.T) {
// these colon-delimited strings appear in already-published SBOMs, so the package must be reported
// under the same virtual path whichever mechanism reached it
dep := jarBytes(t, "nested-lib", "2.0", nil)
scanDir := t.TempDir()
writeTestZipRaw(t, filepath.Join(scanDir, "app.war"), map[string][]byte{
"WEB-INF/lib/nested-lib-2.0.jar": dep,
})
virtualPaths := func(t *testing.T, depth int) map[string]string {
t.Helper()
s := scanDirWith(t, scanDir, javaScanConfig(depth))
out := map[string]string{}
for _, p := range s.Artifacts.Packages.Sorted() {
if metadata, ok := p.Metadata.(pkg.JavaArchive); ok {
out[p.Name] = metadata.VirtualPath
}
}
return out
}
off := virtualPaths(t, 0)
on := virtualPaths(t, 3)
require.Contains(t, off, "nested-lib", "the java cataloger's own recursion must find it when the feature is off")
require.Contains(t, on, "nested-lib", "the archive cataloger must find it when the feature is on")
// the compatibility guarantee: the string is in published SBOMs, so a package reached through the
// archive cataloger must carry the same virtual path java's own recursion gave it
assert.Equal(t, off["nested-lib"], on["nested-lib"],
"the nested jar's virtual path must be byte-identical between the two mechanisms")
// and in colon-delimited form; for a directory source the archive's access path is relative, so the
// chain has no leading separator
assert.Equal(t, "app.war:WEB-INF/lib/nested-lib-2.0.jar", on["nested-lib"])
}
func TestArchiveCataloger_archiveContentsDoNotLeakIntoParentTree(t *testing.T) {
scanDir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(scanDir, "app"), 0o755))
writeTestZip(t, filepath.Join(scanDir, "app", "data.zip"), map[string]string{"nested/marker.txt": "hello"})
s := scanDirWith(t, scanDir, archiveScanConfig(1, markerCataloger{}))
for _, p := range s.Artifacts.Packages.Sorted() {
for _, loc := range p.Locations.ToSlice() {
// the nested file's path is relative to the archive, and the FileSystemID says which archive.
// A joined or flattened path would smear the archive's contents into the parent's tree, which
// is what the rejected resolver-recursion approach did.
assert.NotContains(t, loc.RealPath, "data.zip/")
assert.NotEqual(t, "/app/nested/marker.txt", loc.RealPath)
assert.Equal(t, "app/data.zip", loc.ArchivePath)
}
}
}
// there is no archive-specific exclusion setting: a scan pattern's shape decides whether it reaches
// inside an archive
func TestArchiveCataloger_exclusionsAreTheScansOwn(t *testing.T) {
marker := map[string]string{"nested/marker.txt": "hello"}
t.Run("a recursive pattern reaches inside archives", func(t *testing.T) {
// the marker is inside the archive, so only an exclusion applied to the archive's own index
// removes it; a filter over the scan root never sees this path
scanDir := t.TempDir()
writeTestZip(t, filepath.Join(scanDir, "app.zip"), marker)
s := scanDirWithExclusions(t, scanDir, archiveScanConfig(1, markerCataloger{}), "**/marker.txt")
assert.Empty(t, s.Artifacts.Packages.Sorted(),
"a pattern matching at any depth must exclude a file inside an archive")
})
t.Run("a root-anchored pattern does not reach inside archives", func(t *testing.T) {
// the same relative path exists twice, at the scan root and inside the archive; an anchored
// pattern names the first and says nothing about the second
scanDir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(scanDir, "nested"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(scanDir, "nested", "marker.txt"), []byte("at the root"), 0o644))
writeTestZip(t, filepath.Join(scanDir, "app.zip"), marker)
s := scanDirWithExclusions(t, scanDir, archiveScanConfig(1, markerCataloger{}), "./nested/marker.txt")
var fsIDs []string
for _, p := range s.Artifacts.Packages.Sorted() {
for _, loc := range p.Locations.ToSlice() {
fsIDs = append(fsIDs, loc.ArchivePath)
}
}
assert.Equal(t, []string{"app.zip"}, fsIDs,
"the file at the scan root must be excluded and the one inside the archive kept")
})
t.Run("excluding an archive's own extension stops it being descended into", func(t *testing.T) {
// the archive is not extracted; it is still an ordinary file, and nothing about it is an error
scanDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(scanDir, "pkg.rpm"), buildZipBytes(t, marker), 0o644))
s := scanDirWithExclusions(t, scanDir, archiveScanConfig(1, markerCataloger{}), "**/*.rpm")
assert.Empty(t, s.Artifacts.Packages.Sorted(),
"an archive whose own path is excluded must not be extracted")
})
t.Run("a nested archive is excluded at its own level", func(t *testing.T) {
// outer.zip is cataloged; inner.zip is excluded, so it is absent from outer's own filesystem
// and is neither extracted nor recordable as a file. Asserted through the resolver, since
// absence from the index is upstream of every consumer.
innerZip := buildZipBytes(t, marker)
scanDir := t.TempDir()
writeTestZipRaw(t, filepath.Join(scanDir, "outer.zip"), map[string][]byte{
"deeper/inner.zip": innerZip,
"outer/marker.txt": []byte("hello"),
})
var seen []string
probe := resolverProbeCataloger{
glob: "**/*.zip",
record: func(paths []string) {
seen = append(seen, paths...)
},
}
s := scanDirWithExclusions(t, scanDir, archiveScanConfig(2, markerCataloger{}, probe), "**/inner.zip")
var fsIDs []string
for _, p := range s.Artifacts.Packages.Sorted() {
for _, loc := range p.Locations.ToSlice() {
fsIDs = append(fsIDs, loc.ArchivePath)
}
}
assert.Equal(t, []string{"outer.zip"}, fsIDs,
"the outer archive must still be cataloged and the inner one must contribute nothing")
for _, path := range seen {
assert.NotContains(t, path, "inner.zip",
"the excluded inner archive must be absent from the outer archive's filesystem")
}
assert.NotEmpty(t, seen, "the probe must have seen the outer archive at the scan root")
})
t.Run("matching follows the scan's own case sensitivity", func(t *testing.T) {
// an exclusion behaves the same inside an archive as anywhere else; a caller wanting both
// cases says so in the pattern, as for any other exclusion
scanDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(scanDir, "PKG.RPM"), buildZipBytes(t, marker), 0o644))
kept := scanDirWithExclusions(t, scanDir, archiveScanConfig(1, markerCataloger{}), "**/*.rpm")
assert.NotEmpty(t, kept.Artifacts.Packages.Sorted(),
"a lower-case pattern must not match an upper-case name")
excluded := scanDirWithExclusions(t, scanDir, archiveScanConfig(1, markerCataloger{}), "**/*.[rR][pP][mM]")
assert.Empty(t, excluded.Artifacts.Packages.Sorted(),
"the case-insensitive intent is expressible as an ordinary pattern")
})
t.Run("no exclusions means nothing is excluded", func(t *testing.T) {
scanDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(scanDir, "pkg.rpm"), buildZipBytes(t, marker), 0o644))
s := scanDirWith(t, scanDir, archiveScanConfig(1, markerCataloger{}))
assert.NotEmpty(t, s.Artifacts.Packages.Sorted(),
"with no patterns configured every detected archive stays eligible")
})
t.Run("an excluded file is absent from the filesystem, not present and skipped", func(t *testing.T) {
// an excluded entry is not in the archive's index at all, so nothing downstream resolves it by
// path or by glob
scanDir := t.TempDir()
writeTestZip(t, filepath.Join(scanDir, "app.zip"), map[string]string{
"nested/marker.txt": "hello",
"nested/keep.txt": "hello",
})
var seen []string
probe := resolverProbeCataloger{
glob: "**/*.txt",
record: func(paths []string) {
seen = append(seen, paths...)
},
}
scanDirWithExclusions(t, scanDir, archiveScanConfig(1, probe), "**/marker.txt")
assert.Contains(t, seen, "nested/keep.txt")
assert.NotContains(t, seen, "nested/marker.txt",
"an excluded path must not be resolvable from the archive's filesystem at all")
})
}
// resolverProbeCataloger reports what the resolver it was handed can see, which is how "absent from
// the index" is told from "present and skipped" within a scan.
type resolverProbeCataloger struct {
glob string
record func([]string)
}
func TestArchiveCataloger_unicodeEntryNamesSurviveToOutput(t *testing.T) {
// entry names reach the FileSystemID and then the encoded SBOM; a mangled one is a corrupt
// identifier rather than a visible failure
innerZip := buildZipBytes(t, map[string]string{"nested/marker.txt": "hello"})
scanDir := t.TempDir()
writeTestZipRaw(t, filepath.Join(scanDir, "outer.zip"), map[string][]byte{
"日本語/café-🎉/inner.zip": innerZip,
})
s := scanDirWith(t, scanDir, archiveScanConfig(2, markerCataloger{}))
pkgs := s.Artifacts.Packages.Sorted()
require.Len(t, pkgs, 1)
locs := pkgs[0].Locations.ToSlice()
require.NotEmpty(t, locs)
assert.Equal(t, "outer.zip:日本語/café-🎉/inner.zip", locs[0].ArchivePath)
assert.True(t, utf8.ValidString(locs[0].ArchivePath))
}
func TestArchiveCataloger_libraryParity(t *testing.T) {
// the library entry point must behave as the CLI does, and must not silently replace anything the
// consumer set on the package cataloger config
dep := jarBytes(t, "nested-lib", "2.0", nil)
scanDir := t.TempDir()
writeTestZipRaw(t, filepath.Join(scanDir, "bundle.zip"), map[string][]byte{"lib/nested-lib-2.0.jar": dep})
countNestedLib := func(t *testing.T, s *sbom.SBOM) int {
t.Helper()
var n int
for _, p := range s.Artifacts.Packages.Sorted() {
if p.Name == "nested-lib" {
n++
}
}
return n
}
t.Run("WithArchiveConfig enables the feature and hands java its yield", func(t *testing.T) {
s := scanDirWith(t, scanDir, javaScanConfig(3))
assert.Equal(t, 1, countNestedLib(t, s), "reached exactly once, via the archive cataloger")
})
t.Run("setting only java's own depth neither enables the feature nor breaks java", func(t *testing.T) {
// java's squash-inlined MaxDepth is a config field a consumer can set: it must not affect
// recursion ownership, and must not be overwritten
supplied := java.DefaultArchiveCatalogerConfig()
supplied.ArchiveSearchConfig = supplied.ArchiveSearchConfig.WithMaxDepth(3)
cfg := javaScanConfig(0).WithPackagesConfig(pkgcataloging.Config{JavaArchive: supplied})
s := scanDirWith(t, scanDir, cfg)
// the archive task never ran, so java's own recursion found it - once
assert.Equal(t, 1, countNestedLib(t, s), "java must keep recursing itself")
assert.Equal(t, 3, cfg.Packages.JavaArchive.MaxDepth, "the consumer's value must survive")
})
}
func TestArchiveCataloger_noExtractionPathReachesTheSBOM(t *testing.T) {
// covers syft/archive-content-identity#nested-paths-are-archive-relative. AllLocations emitted the
// resolver's own root unrelativized, and filemetadata.Cataloger records every location it is handed,
// so a coordinate whose RealPath was the extraction temp dir reached FileMetadata, merged into the
// shared SBOM, and gained a CONTAINS edge. File cataloging must be on: it is the only path that
// enumerates all locations rather than searching for them.
innerZip := buildZipBytes(t, map[string]string{"nested/marker.txt": "hello"})
scanDir := t.TempDir()
writeTestZipRaw(t, filepath.Join(scanDir, "outer.zip"), map[string][]byte{"deeper/inner.zip": innerZip})
// the resolver normalizes its root with EvalSymlinks, so the scan root it reports is the resolved
// path. On macOS t.TempDir() sits under /var, a symlink to /private/var, so comparing against the
// unresolved value fails there while passing on linux.
resolvedScanDir, err := filepath.EvalSymlinks(scanDir)
require.NoError(t, err)
cfg := archiveScanConfig(2, markerCataloger{}).
WithFilesConfig(filecataloging.Config{Selection: file.AllFilesSelection})
s := scanDirWith(t, scanDir, cfg)
// every path syft can report about this scan, from every table and both ends of every edge
var paths []string
for coords := range s.Artifacts.FileMetadata {
paths = append(paths, coords.RealPath)
}
for _, p := range s.Artifacts.Packages.Sorted() {
for _, loc := range p.Locations.ToSlice() {
paths = append(paths, loc.RealPath, loc.AccessPath)
}
}
for _, rel := range s.Relationships {
for _, end := range []artifact.Identifiable{rel.From, rel.To} {
if coords, ok := end.(file.Coordinates); ok {
paths = append(paths, coords.RealPath)
}
}
}
require.NotEmpty(t, paths)
for _, p := range paths {
assert.NotContains(t, p, "syft-archive-",
"an extraction directory reached the SBOM at %q", p)
assert.NotEqual(t, "entries", filepath.Base(p),
"the file holding an archive's entries reached the SBOM at %q", p)
if filepath.IsAbs(p) {
// the directory resolver has always reported the scan root absolute: it is the path the
// user asked for. Anything else absolute is a filesystem this feature invented leaking its
// own root.
assert.Equal(t, resolvedScanDir, p, "only the scanned directory itself may be reported absolute")
}
}
}
func TestArchiveCataloger_repeatedScansAgreeOnNestedPaths(t *testing.T) {
// the reproducibility property the requirement exists for: extraction directories are created fresh
// per run, so anything of theirs reaching output makes two scans of identical input disagree
innerZip := buildZipBytes(t, map[string]string{"nested/marker.txt": "hello"})
scanDir := t.TempDir()
writeTestZipRaw(t, filepath.Join(scanDir, "outer.zip"), map[string][]byte{"deeper/inner.zip": innerZip})
scan := func(t *testing.T) []string {
t.Helper()
cfg := archiveScanConfig(2, markerCataloger{}).
WithFilesConfig(filecataloging.Config{Selection: file.AllFilesSelection})
s := scanDirWith(t, scanDir, cfg)
var out []string
for coords := range s.Artifacts.FileMetadata {
out = append(out, coords.ArchivePath+"|"+coords.RealPath)
}
sort.Strings(out)
return out
}
first := scan(t)
require.NotEmpty(t, first)
assert.Equal(t, first, scan(t))
}
func TestArchiveCataloger_legacySearchSettingsAreSuperseded(t *testing.T) {
// syft/nested-archive-cataloging#legacy-archive-search-settings-are-superseded. The row that matters
// is nested-on with both settings false: it looks like a bug and is not, so a future change that
// "fixes" it is changing a requirement.
dep := jarBytes(t, "legacy-lib", "1.2", nil)
entry := map[string][]byte{"lib/legacy-lib-1.2.jar": dep}
containers := map[string][]byte{
"bundle.zip": buildZipBytesRaw(t, entry),
"bundle.tar.gz": buildTarGzBytes(t, entry),
}
// with the feature off, java's own wrapped-archive parsers reach the jar, gated by these two
// settings: indexed covers the zip, unindexed covers the tar family
found := func(t *testing.T, container string, depth int, searchSettings *bool) bool {
t.Helper()
scanDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(scanDir, container), containers[container], 0o644))
cfg := javaScanConfig(depth)
if searchSettings != nil {
// these two settings only ever reach the java cataloger's wrapped-archive parser
// registrations, where the CLI's search-indexed-archives and search-unindexed-archives
// land as well
packages := cfg.Packages
packages.JavaArchive.IncludeIndexedArchives = *searchSettings
packages.JavaArchive.IncludeUnindexedArchives = *searchSettings
cfg = cfg.WithPackagesConfig(packages)
}
for _, p := range scanDirWith(t, scanDir, cfg).Artifacts.Packages.Sorted() {
if p.Name == "legacy-lib" {
return true
}
}
return false
}
yes, no := true, false
for _, tt := range []struct {
name string
depth int
searchSettings *bool
wantZip bool
wantTarGz bool
}{
{name: "off, both settings true", depth: 0, searchSettings: &yes, wantZip: true, wantTarGz: true},
{name: "off, both settings false", depth: 0, searchSettings: &no, wantZip: false, wantTarGz: false},
{name: "on, both settings true", depth: 2, searchSettings: &yes, wantZip: true, wantTarGz: true},
// the load-bearing cell
{name: "on, both settings false", depth: 2, searchSettings: &no, wantZip: true, wantTarGz: true},
// and what real users hit: unindexed defaults to false, so the tar.gz is cataloged with a
// setting whose documented meaning says it should not be
{name: "on, settings untouched", depth: 2, searchSettings: nil, wantZip: true, wantTarGz: true},
{name: "off, settings untouched", depth: 0, searchSettings: nil, wantZip: true, wantTarGz: false},
} {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.wantZip, found(t, "bundle.zip", tt.depth, tt.searchSettings), "zip")
assert.Equal(t, tt.wantTarGz, found(t, "bundle.tar.gz", tt.depth, tt.searchSettings), "tar.gz")
})
}
}
func (markerCataloger) Name() string { return "marker-cataloger" }
func (markerCataloger) Catalog(_ context.Context, resolver file.Resolver) ([]pkg.Package, []artifact.Relationship, error) {
locations, err := resolver.FilesByGlob("**/marker.txt")
if err != nil {
return nil, nil, err
}
var pkgs []pkg.Package
for _, loc := range locations {
p := pkg.Package{
Name: "marker-pkg",
Version: "1.0.0",
Type: pkg.BinaryPkg,
Locations: file.NewLocationSet(loc),
}
p.SetID()
pkgs = append(pkgs, p)
}
return pkgs, nil, nil
}
func writeTestZip(t *testing.T, path string, files map[string]string) {
t.Helper()
raw := make(map[string][]byte, len(files))
for k, v := range files {
raw[k] = []byte(v)
}
writeTestZipRaw(t, path, raw)
}
func writeTestZipRaw(t *testing.T, path string, files map[string][]byte) {
t.Helper()
require.NoError(t, os.WriteFile(path, buildZipBytesRaw(t, files), 0o644))
}
func buildZipBytes(t *testing.T, files map[string]string) []byte {
t.Helper()
raw := make(map[string][]byte, len(files))
for k, v := range files {
raw[k] = []byte(v)
}
return buildZipBytesRaw(t, raw)
}
// buildZipBytesRaw writes entries in sorted name order rather than map order: limits are enforced as
// the walk proceeds, so entry order decides which entries land before a truncation.
func buildZipBytesRaw(t *testing.T, files map[string][]byte) []byte {
t.Helper()
names := make([]string, 0, len(files))
for name := range files {
names = append(names, name)
}
sort.Strings(names)
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, name := range names {
w, err := zw.Create(name)
require.NoError(t, err)
_, err = w.Write(files[name])
require.NoError(t, err)
}
require.NoError(t, zw.Close())
return buf.Bytes()
}
// archiveScanConfig builds a CreateSBOMConfig limited to the given catalogers, with nested archive
// cataloging at the given depth (0 disables it).
func archiveScanConfig(depth int, catalogers ...pkg.Cataloger) *CreateSBOMConfig {
refs := make([]pkgcataloging.CatalogerReference, 0, len(catalogers))
for _, c := range catalogers {
refs = append(refs, pkgcataloging.NewCatalogerReference(c, []string{"directory"}))
}
cfg := DefaultCreateSBOMConfig().WithoutCatalogers().WithCatalogers(refs...)
cfg.Archive.MaxDepth = depth
return cfg
}
// javaScanConfig builds a config whose java cataloger comes from the cataloging factory rather than
// pre-built: the flag handing recursion to the archive task is derived onto the factory's
// PackagesConfig, so a cataloger passed via WithCatalogers keeps its own config and never yields.
//
// It selects only the java-tagged catalogers, not the whole default set, which includes the RPM
// cataloger and its sqlite driver this test binary does not register.
func javaScanConfig(depth int) *CreateSBOMConfig {
cfg := DefaultCreateSBOMConfig().
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java"))
if depth > 0 {
cfg = cfg.WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(depth))
}
return cfg
}
func scanDirWith(t *testing.T, dir string, cfg *CreateSBOMConfig) *sbom.SBOM {
t.Helper()
src, err := directorysource.New(directorysource.Config{Path: dir})
require.NoError(t, err)
t.Cleanup(func() { _ = src.Close() })
s, err := cfg.Create(context.Background(), src)
require.NoError(t, err)
return s
}
// scanDirWithExclusions scans with the given exclusion patterns configured on the source, where
// exclusions live; archive cataloging has no exclusion setting of its own.
func scanDirWithExclusions(t *testing.T, dir string, cfg *CreateSBOMConfig, exclude ...string) *sbom.SBOM {
t.Helper()
src, err := directorysource.New(directorysource.Config{
Path: dir,
Exclude: source.ExcludeConfig{Paths: exclude},
})
require.NoError(t, err)
t.Cleanup(func() { _ = src.Close() })
s, err := cfg.Create(context.Background(), src)
require.NoError(t, err)
return s
}
func jarBytes(t *testing.T, title, version string, extra map[string][]byte) []byte {
t.Helper()
entries := map[string][]byte{
"META-INF/MANIFEST.MF": []byte(
"Manifest-Version: 1.0\nImplementation-Title: " + title + "\nImplementation-Version: " + version + "\n",
),
}
for k, v := range extra {
entries[k] = v
}
return buildZipBytesRaw(t, entries)
}
func (resolverProbeCataloger) Name() string { return "resolver-probe-cataloger" }
func (c resolverProbeCataloger) Catalog(_ context.Context, resolver file.Resolver) ([]pkg.Package, []artifact.Relationship, error) {
locations, err := resolver.FilesByGlob(c.glob)
if err != nil {
return nil, nil, err
}
var paths []string
for _, loc := range locations {
paths = append(paths, loc.RealPath)
}
c.record(paths)
return nil, nil, nil
}
func buildTarGzBytes(t *testing.T, files map[string][]byte) []byte {
t.Helper()
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
writeTarEntries(t, gw, files)
require.NoError(t, gw.Close())
return buf.Bytes()
}
func writeTarEntries(t *testing.T, w io.Writer, files map[string][]byte) {
t.Helper()
names := make([]string, 0, len(files))
for name := range files {
names = append(names, name)
}
sort.Strings(names)
tw := tar.NewWriter(w)
for _, name := range names {
body := files[name]
require.NoError(t, tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))}))
_, err := tw.Write(body)
require.NoError(t, err)
}
require.NoError(t, tw.Close())
}
// recordingBus captures the progress rows a scan publishes.
type recordingBus struct {
mu sync.Mutex
events []partybus.Event
}
func (p *recordingBus) Publish(e partybus.Event) {
p.mu.Lock()
defer p.mu.Unlock()
p.events = append(p.events, e)
}
// TestArchiveCataloger_fileCatalogerProgressCountsArchiveContents scans a directory holding one file
// and a zip holding two more. The file catalogers run once over the scan root and again over the
// archive, and the row each of them publishes must be one row carrying the total of both - a row that
// stops at the scan root reports a number that is wrong by everything inside every archive.
func TestArchiveCataloger_fileCatalogerProgressCountsArchiveContents(t *testing.T) {
scanDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(scanDir, "loose.txt"), []byte("outside"), 0o600))
writeTestZip(t, filepath.Join(scanDir, "app.zip"), map[string]string{
"nested/one.txt": "hello",
"nested/two.txt": "there",
})
recorder := &recordingBus{}
bus.Set(recorder)
t.Cleanup(func() { bus.Set(nil) })
src, err := directorysource.New(directorysource.Config{Path: scanDir})
require.NoError(t, err)
t.Cleanup(func() { _ = src.Close() })
cfg := DefaultCreateSBOMConfig().
WithoutCatalogers().
WithCatalogers(pkgcataloging.NewCatalogerReference(markerCataloger{}, []string{"directory"})).
WithFilesConfig(filecataloging.DefaultConfig().WithSelection(file.AllFilesSelection))
cfg.Archive.MaxDepth = 1
_, err = cfg.Create(context.Background(), src)
require.NoError(t, err)
rows := map[string][]*progress.Manual{}
for _, e := range recorder.events {
if e.Type != event.CatalogerTaskStarted {
continue
}
info, ok := e.Source.(monitor.GenericTask)
require.True(t, ok)
prog, ok := e.Value.(progress.StagedProgressable)
require.True(t, ok)
tp, ok := prog.(*monitor.TaskProgress)
require.True(t, ok)
rows[info.Title.Default] = append(rows[info.Title.Default], tp.Manual)
}
for _, title := range []string{"File digests", "File metadata"} {
published := rows[title]
require.Len(t, published, 1, "%s must publish exactly one row for the whole scan", title)
row := published[0]
assert.GreaterOrEqual(t, row.Current(), int64(4),
"%s must count the two files inside the archive as well as the two beside it, got %d",
title, row.Current())
// a re-run reports into the row it already owns, so the row's total has to grow with it. A total
// describing only the scan root leaves the count running past it, which renders as an overshot
// bar and reads as complete (progress.IsCompleted) while the walk is still going.
if row.Size() >= 0 {
assert.Equal(t, row.Current(), row.Size(),
"%s must carry a total covering every run that reported into it, got %d/%d",
title, row.Current(), row.Size())
}
}
}