Files
syft/syft/archive_cataloger_test.go
T
2026-10-20 11:15:17 -04:00

647 lines
26 KiB
Go

package syft
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"os"
"path/filepath"
"sort"
"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/archive"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/task"
"github.com/anchore/syft/internal/testutils"
"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"
)
// Every CreateSBOM call pays to compile the license scanner, so the end-to-end behavior is asserted
// over one scan of one fixture directory rather than a scan per claim. The fixture holds every shape
// the claims need; the subtests read the one SBOM.
//
// scan root
//
// loose.txt an ordinary file, counted by the file catalogers
// nested/marker.txt excluded by the root-anchored pattern ./nested/marker.txt
// app.zip nested/marker.txt, nested/keep.txt, nested/excluded.txt (**/excluded.txt)
// app/data.zip nested/marker.txt, under a directory
// outer.zip outer/marker.txt
// deeper/inner.zip nested/marker.txt, two levels down
// skip/inner-excluded.zip nested/marker.txt, excluded by **/inner-excluded.zip
// 日本語/café-🎉/unicode.zip nested/marker.txt, under a non-ASCII path
// pkg.rpm a zip holding a marker, excluded by **/*.rpm
// KEPT.RPM the same zip, kept: the pattern is lower-case (a distinct base
// name, so the two coexist on a case-insensitive filesystem)
// app.war WEB-INF/lib/war-lib-2.0.jar
// bundle.zip lib/zip-lib-1.2.jar
// bundle.tar.gz lib/tgz-lib-1.2.jar
var fixtureExclusions = []string{"./nested/marker.txt", "**/excluded.txt", "**/inner-excluded.zip", "**/*.rpm"}
func writeFixture(t *testing.T) string {
t.Helper()
dir := t.TempDir()
write := func(name string, data []byte) {
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, name), data, 0o644))
}
markerZip := testutils.Zip(t, map[string]string{"nested/marker.txt": "hello"})
write("loose.txt", []byte("outside"))
write("nested/marker.txt", []byte("at the root"))
write("app.zip", testutils.Zip(t, map[string]string{
"nested/marker.txt": "hello",
"nested/keep.txt": "hello",
"nested/excluded.txt": "hello",
}))
write("app/data.zip", markerZip)
write("outer.zip", testutils.Zip(t, map[string][]byte{
"outer/marker.txt": []byte("hello"),
"deeper/inner.zip": markerZip,
"skip/inner-excluded.zip": markerZip,
"日本語/café-🎉/unicode.zip": markerZip,
}))
write("pkg.rpm", markerZip)
write("KEPT.RPM", markerZip)
write("app.war", testutils.Zip(t, map[string][]byte{
"WEB-INF/lib/war-lib-2.0.jar": testutils.Jar(t, "war-lib", "2.0", nil),
}))
write("bundle.zip", testutils.Zip(t, map[string][]byte{
"lib/zip-lib-1.2.jar": testutils.Jar(t, "zip-lib", "1.2", nil),
}))
write("bundle.tar.gz", testutils.TarGz(t, map[string][]byte{
"lib/tgz-lib-1.2.jar": testutils.Jar(t, "tgz-lib", "1.2", nil),
}))
return dir
}
func TestArchiveCataloger(t *testing.T) {
scanDir := writeFixture(t)
resolvedScanDir, err := filepath.EvalSymlinks(scanDir)
require.NoError(t, err)
resolvedScanDir = filepath.ToSlash(resolvedScanDir)
recorder := &recordingBus{}
bus.Set(recorder)
t.Cleanup(func() { bus.Set(nil) })
var (
probeMu sync.Mutex
seen []string // every .txt and .zip path any resolver handed a cataloger could see
)
probe := resolverProbeCataloger{globs: []string{"**/*.txt", "**/*.zip"}, record: func(paths []string) {
probeMu.Lock()
defer probeMu.Unlock()
seen = append(seen, paths...)
}}
// the java catalogers come from the factory, as the CLI builds them: the flag handing recursion
// to the archive task is derived onto the factory's config. Both legacy search settings are off,
// the cell of the matrix that looks like a bug: with the feature on they no longer gate anything
packages := pkgcataloging.DefaultConfig()
packages.JavaArchive.IncludeIndexedArchives = false
packages.JavaArchive.IncludeUnindexedArchives = false
cfg := DefaultCreateSBOMConfig().
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
WithCatalogers(
pkgcataloging.NewAlwaysEnabledCatalogerReference(markerCataloger{}),
pkgcataloging.NewAlwaysEnabledCatalogerReference(probe),
).
WithPackagesConfig(packages).
WithFilesConfig(filecataloging.DefaultConfig().WithSelection(file.AllFilesSelection)).
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(3))
s := scanDirWithExclusions(t, scanDir, cfg, fixtureExclusions...)
t.Run("markers are found at every depth under archive-relative paths", func(t *testing.T) {
// the location's RealPath is the outermost archive; FullPath adds the chain of entries within it
assert.Equal(t, []string{
"KEPT.RPM!/nested/marker.txt",
"app.zip!/nested/marker.txt",
"app/data.zip!/nested/marker.txt",
"outer.zip!/deeper/inner.zip!/nested/marker.txt",
"outer.zip!/outer/marker.txt",
"outer.zip!/日本語/café-🎉/unicode.zip!/nested/marker.txt",
}, packageLocations(s, "marker-pkg"))
})
t.Run("archive contents do not leak into the parent tree", func(t *testing.T) {
for _, loc := range packageLocations(s, "marker-pkg") {
assert.NotContains(t, loc, ".zip/", "a joined path smears the archive's contents into the parent's tree")
assert.NotContains(t, loc, "!//", "a nested path must not be rooted")
}
})
t.Run("unicode entry names survive to output", func(t *testing.T) {
for _, loc := range packageLocations(s, "marker-pkg") {
assert.True(t, utf8.ValidString(loc))
}
})
t.Run("a CONTAINS relationship links each archive to what was found inside", func(t *testing.T) {
var containers []string
for _, p := range s.Artifacts.Packages.Sorted() {
if p.Name != "marker-pkg" {
continue
}
for _, rel := range s.Relationships {
if rel.Type != artifact.ContainsRelationship || rel.To.ID() != p.ID() {
continue
}
if coords, ok := rel.From.(file.Coordinates); ok {
containers = append(containers, coords.FullNestedPath())
}
}
}
sort.Strings(containers)
assert.Equal(t, []string{
"KEPT.RPM",
"app.zip",
"app/data.zip",
"outer.zip",
"outer.zip!/deeper/inner.zip",
"outer.zip!/日本語/café-🎉/unicode.zip",
}, containers, "the archive file, named in its own parent, contains the package")
})
t.Run("exclusions are the scan's own", func(t *testing.T) {
// there is no archive-specific exclusion setting: a scan pattern's shape decides whether it
// reaches inside an archive, and an excluded entry is absent from the archive's filesystem
// rather than present and skipped
markers := packageLocations(s, "marker-pkg")
assert.NotContains(t, markers, "nested/marker.txt", "a root-anchored pattern excludes the file at the scan root")
assert.Contains(t, markers, "app.zip!/nested/marker.txt", "and says nothing about the same path inside an archive")
assert.NotContains(t, markers, "pkg.rpm!/nested/marker.txt", "an archive whose own path is excluded is not extracted")
assert.Contains(t, markers, "KEPT.RPM!/nested/marker.txt", "a lower-case pattern does not match an upper-case name")
assert.NotContains(t, markers, "outer.zip!/skip/inner-excluded.zip!/nested/marker.txt", "a nested archive is excluded at its own level")
assert.Contains(t, seen, "app.zip!/nested/keep.txt")
assert.NotContains(t, seen, "app.zip!/nested/excluded.txt", "a **/ pattern excludes inside an archive, from the index itself")
assert.Contains(t, seen, "outer.zip!/deeper/inner.zip", "the probe ran inside outer.zip")
assert.NotContains(t, seen, "outer.zip!/skip/inner-excluded.zip", "the excluded inner archive is absent from outer's filesystem")
})
t.Run("a jar inside an archive is cataloged exactly once, under the virtual path java gives it", func(t *testing.T) {
// the failure mode is a duplicate: a jar inside a zip can be reached by the task's walk and by
// the java cataloger's own unarchiving. The colon-delimited virtual path appears in published
// SBOMs, so it must not change with the mechanism; for a directory source the archive's path
// is relative, so the chain has no leading separator
assert.Equal(t, map[string][]string{
"war-lib": {"app.war:WEB-INF/lib/war-lib-2.0.jar"},
"zip-lib": {"bundle.zip:lib/zip-lib-1.2.jar"},
"tgz-lib": {"bundle.tar.gz:lib/tgz-lib-1.2.jar"},
}, javaVirtualPaths(s), "with both legacy search settings off, every container is still descended")
})
t.Run("no extraction path reaches the SBOM", func(t *testing.T) {
// covers syft/archive-content-identity#nested-paths-are-archive-relative: file cataloging is
// the one path that enumerates every location rather than searching for them, so a resolver
// root that leaked would land in FileMetadata and gain a CONTAINS 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, "archive-spill-", "the spill file 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
assert.Equal(t, resolvedScanDir, p, "only the scanned directory itself may be reported absolute")
}
}
})
t.Run("file metadata covers every entry of every archive, and nothing else", func(t *testing.T) {
// an exact set is also the reproducibility check: anything of a per-run temp directory reaching
// output would fail it
var got []string
for coords := range s.Artifacts.FileMetadata {
if archive.ContainerPath(coords) == "" {
continue
}
got = append(got, coords.FullNestedPath())
}
sort.Strings(got)
assert.Equal(t, []string{
"KEPT.RPM!/nested/marker.txt",
"app.war!/WEB-INF/lib/war-lib-2.0.jar",
"app.war!/WEB-INF/lib/war-lib-2.0.jar!/META-INF/MANIFEST.MF",
"app.zip!/nested/keep.txt",
"app.zip!/nested/marker.txt",
"app/data.zip!/nested/marker.txt",
"bundle.tar.gz!/lib/tgz-lib-1.2.jar",
"bundle.tar.gz!/lib/tgz-lib-1.2.jar!/META-INF/MANIFEST.MF",
"bundle.zip!/lib/zip-lib-1.2.jar",
"bundle.zip!/lib/zip-lib-1.2.jar!/META-INF/MANIFEST.MF",
"outer.zip!/deeper/inner.zip",
"outer.zip!/deeper/inner.zip!/nested/marker.txt",
"outer.zip!/outer/marker.txt",
"outer.zip!/日本語/café-🎉/unicode.zip",
"outer.zip!/日本語/café-🎉/unicode.zip!/nested/marker.txt",
}, got)
})
t.Run("file catalogers publish one progress row counting archive contents", func(t *testing.T) {
// the file catalogers run once over the scan root and again over every archive; each must
// report into one row carrying the total of every run, or the count runs past the total and
// renders as an overshot bar that reads as complete mid-walk
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)
tp, ok := e.Value.(*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.Greater(t, row.Current(), int64(len(s.Artifacts.FileMetadata))/2,
"%s must count the files inside the archives, got %d", title, row.Current())
if row.Size() >= 0 {
assert.Equal(t, row.Current(), row.Size(), "%s total must cover every run that reported into it", title)
}
}
})
}
// TestArchiveCataloger_featureOff is the one comparison that needs a second scan: with the task
// disabled, java's own unarchiving reaches the jars its legacy settings admit, under the same virtual
// path the task gives them.
func TestArchiveCataloger_featureOff(t *testing.T) {
scanDir := writeFixture(t)
cfg := DefaultCreateSBOMConfig().
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java"))
s := scanDirWithExclusions(t, scanDir, cfg, fixtureExclusions...)
// indexed archives (zip, war) are searched by default and unindexed ones (tar.gz) are not, so
// what a user gets without the feature depends on settings the feature makes irrelevant
assert.Equal(t, map[string][]string{
"war-lib": {"app.war:WEB-INF/lib/war-lib-2.0.jar"},
"zip-lib": {"bundle.zip:lib/zip-lib-1.2.jar"},
}, javaVirtualPaths(s), "java's own recursion finds each once, under the virtual path the task also reports")
t.Run("the top-level archive search booleans reach java", func(t *testing.T) {
cfg := DefaultCreateSBOMConfig().
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithIncludeUnindexedArchives(true))
s := scanDirWithExclusions(t, scanDir, cfg, fixtureExclusions...)
assert.Contains(t, javaVirtualPaths(s), "tgz-lib")
assert.False(t, cfg.Packages.JavaArchive.IncludeUnindexedArchives, "the caller's config is not written to")
})
}
func TestArchiveCataloger_jarReachedThroughALinkIsIdentified(t *testing.T) {
// a content-addressed layout (pnpm, nix, bazel): the jar is stored under a hash and only the link
// to it is named like a jar
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
jar := testutils.Jar(t, "json-simple", "1.1.1", nil)
require.NoError(t, tw.WriteHeader(&tar.Header{Name: "store/3f9a1c", Mode: 0o644, Size: int64(len(jar))}))
_, err := tw.Write(jar)
require.NoError(t, err)
require.NoError(t, tw.WriteHeader(&tar.Header{Name: "app/lib/json-simple-1.1.1.jar", Typeflag: tar.TypeSymlink, Linkname: "../../store/3f9a1c"}))
require.NoError(t, tw.Close())
require.NoError(t, gw.Close())
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "bundle.tar.gz"), buf.Bytes(), 0o644))
cfg := DefaultCreateSBOMConfig().
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
WithFilesConfig(filecataloging.DefaultConfig().WithSelection(file.AllFilesSelection)).
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(2))
s := scanDirWithExclusions(t, dir, cfg)
assert.Equal(t, []string{"bundle.tar.gz!/store/3f9a1c"}, packageLocations(s, "json-simple"))
// the jar's own files are addressed by its real path, the same one its package location names
manifest := file.Coordinates{RealPath: "bundle.tar.gz", FullPath: "bundle.tar.gz!/store/3f9a1c!/META-INF/MANIFEST.MF"}
assert.Contains(t, s.Artifacts.FileMetadata, manifest)
}
func TestArchiveCataloger_defaultFileSelectionCoversRootJars(t *testing.T) {
// with the default owned-by-package selection, a jar at the root is described by the archive task,
// so the root file catalogers must run after it or the jar gets no file entries at all
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "app.jar"), testutils.Jar(t, "app", "1.0", nil), 0o644))
for name, depth := range map[string]int{"feature off": 0, "feature on": 1} {
t.Run(name, func(t *testing.T) {
cfg := DefaultCreateSBOMConfig().
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(depth))
s := scanDirWithExclusions(t, dir, cfg)
require.Equal(t, []string{"app.jar"}, packageLocations(s, "app"))
jar := file.NewCoordinates("app.jar", "")
assert.Contains(t, s.Artifacts.FileMetadata, jar)
assert.Contains(t, s.Artifacts.FileDigests, jar)
})
}
}
func TestArchiveCataloger_defaultFileSelectionCoversNestedJars(t *testing.T) {
// a nested jar's package is found inside the jar but located in the archive holding it, so that
// archive's file catalogers must run after it, as the root's do
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "outer.zip"), testutils.Zip(t, map[string][]byte{
"lib/inner.jar": testutils.Jar(t, "inner", "1.0", nil),
}), 0o644))
cfg := DefaultCreateSBOMConfig().
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(2))
s := scanDirWithExclusions(t, dir, cfg)
require.Equal(t, []string{"outer.zip!/lib/inner.jar"}, packageLocations(s, "inner"))
jar := file.Coordinates{RealPath: "outer.zip", FullPath: "outer.zip!/lib/inner.jar"}
assert.Contains(t, s.Artifacts.FileMetadata, jar)
assert.Contains(t, s.Artifacts.FileDigests, jar)
}
func TestArchiveCataloger_archiveUnknowns(t *testing.T) {
dir := t.TempDir()
plain := testutils.Zip(t, map[string]string{"readme.txt": "hi"})
require.NoError(t, os.WriteFile(filepath.Join(dir, "outer.zip"), testutils.Zip(t, map[string][]byte{
"lib/inner.jar": testutils.Jar(t, "inner", "1.0", nil),
"lib/plain.zip": plain,
}), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "plain.zip"), plain, 0o644))
scan := func(depth int) map[string][]string {
cfg := DefaultCreateSBOMConfig().
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
WithUnknownsConfig(cataloging.DefaultUnknownsConfig()).
WithFilesConfig(filecataloging.DefaultConfig().WithSelection(file.AllFilesSelection)).
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(depth))
out := map[string][]string{}
for c, reasons := range scanDirWithExclusions(t, dir, cfg).Artifacts.Unknowns {
out[c.FullNestedPath()] = reasons
}
return out
}
t.Run("an expanded archive is not reported as not cataloged", func(t *testing.T) {
assert.Empty(t, scan(3))
})
t.Run("an archive past the depth limit is reported once, by the archive cataloger", func(t *testing.T) {
unknowns := scan(1)
assert.NotContains(t, unknowns, "outer.zip")
for _, nested := range []string{"outer.zip!/lib/inner.jar", "outer.zip!/lib/plain.zip"} {
require.Len(t, unknowns[nested], 1, nested)
assert.Contains(t, unknowns[nested][0], "depth limit was reached")
}
})
t.Run("an archive is reported as not cataloged when nesting is off", func(t *testing.T) {
assert.Equal(t, []string{"archive not cataloged"}, scan(0)["plain.zip"])
})
}
func TestArchiveCataloger_unsupportedArchiveFormatsAreReported(t *testing.T) {
dir := t.TempDir()
var page bytes.Buffer
gw := gzip.NewWriter(&page)
_, err := gw.Write([]byte("a man page"))
require.NoError(t, err)
require.NoError(t, gw.Close())
require.NoError(t, os.WriteFile(filepath.Join(dir, "outer.zip"), testutils.Zip(t, map[string][]byte{
"legacy.7z": []byte("7z\xbc\xaf\x27\x1c\x00\x04"),
"page.1.gz": page.Bytes(),
}), 0o644))
cfg := DefaultCreateSBOMConfig().
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
WithUnknownsConfig(cataloging.DefaultUnknownsConfig()).
WithFilesConfig(filecataloging.DefaultConfig().WithSelection(file.AllFilesSelection)).
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(3))
unknowns := map[string][]string{}
for c, reasons := range scanDirWithExclusions(t, dir, cfg).Artifacts.Unknowns {
unknowns[c.FullNestedPath()] = reasons
}
assert.Equal(t, map[string][]string{
"outer.zip!/legacy.7z": {"archive-cataloger: archive not cataloged: archive format is not supported: application/x-7z-compressed"},
}, unknowns, "a bare compressed file holds no files, so it is not an archive left uncataloged")
}
func TestArchiveCataloger_javaParentLinkageMatchesTheLegacyPath(t *testing.T) {
// a consumer walking java DEPENDENCY_OF edges (the only structure SPDX and CycloneDX keep) must see
// the same war-to-jar linkage whether or not the archive task does the recursion
dir := t.TempDir()
war := testutils.Jar(t, "app", "1.0", map[string][]byte{
"WEB-INF/lib/lib-2.0.jar": testutils.Jar(t, "lib", "2.0", nil),
})
require.NoError(t, os.WriteFile(filepath.Join(dir, "app.war"), war, 0o644))
for name, depth := range map[string]int{"feature off": 0, "feature on": 2} {
t.Run(name, func(t *testing.T) {
cfg := DefaultCreateSBOMConfig().
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(depth))
s := scanDirWithExclusions(t, dir, cfg)
var edges []string
for _, rel := range s.Relationships {
from, fromOK := rel.From.(pkg.Package)
to, toOK := rel.To.(pkg.Package)
if fromOK && toOK && rel.Type == artifact.DependencyOfRelationship {
edges = append(edges, from.Name+" -> "+to.Name)
}
}
assert.Equal(t, []string{"lib -> app"}, edges)
var parent string
for _, p := range s.Artifacts.Packages.Sorted() {
if m, ok := p.Metadata.(pkg.JavaArchive); ok && p.Name == "lib" && m.Parent != nil {
parent = m.Parent.Name
}
}
assert.Equal(t, "app", parent)
})
}
}
func TestArchiveCataloger_descriptorRecordsArchiveConfig(t *testing.T) {
// a consumer must be able to tell from the SBOM alone how deep archive contents were searched
cfg := DefaultCreateSBOMConfig().
WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")).
WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(3))
s := scanDirWithExclusions(t, t.TempDir(), cfg)
trail, ok := s.Descriptor.Configuration.(configurationAuditTrail)
require.True(t, ok)
assert.Equal(t, 3, trail.Archive.MaxDepth)
assert.Contains(t, trail.Catalogers.Used, task.ArchiveCatalogerTaskName)
}
func TestArchiveCataloger_javaArchiveDepthIsAConfigError(t *testing.T) {
// depth and limits are not java settings; setting them on java's deprecated copy must fail fast
// rather than be silently ignored
for name, set := range map[string]func(*cataloging.ArchiveSearchConfig){
"max depth": func(c *cataloging.ArchiveSearchConfig) { c.MaxDepth = 3 },
"max memory bytes": func(c *cataloging.ArchiveSearchConfig) { c.MaxMemoryBytes = 1 },
"max disk bytes": func(c *cataloging.ArchiveSearchConfig) { c.MaxDiskBytes = 1 },
} {
t.Run(name, func(t *testing.T) {
supplied := java.DefaultArchiveCatalogerConfig()
supplied.ArchiveSearchConfig = cataloging.ArchiveSearchConfig{}
set(&supplied.ArchiveSearchConfig)
cfg := DefaultCreateSBOMConfig().WithPackagesConfig(pkgcataloging.Config{JavaArchive: supplied})
src, err := directorysource.New(directorysource.Config{Path: t.TempDir()})
require.NoError(t, err)
t.Cleanup(func() { _ = src.Close() })
_, err = cfg.Create(context.Background(), src)
require.ErrorContains(t, err, "set them with CreateSBOMConfig.Archive")
})
}
}
// packageLocations lists the full path of every location of every package with the name.
func packageLocations(s *sbom.SBOM, name string) []string {
var out []string
for _, p := range s.Artifacts.Packages.Sorted() {
if p.Name != name {
continue
}
for _, loc := range p.Locations.ToSlice() {
out = append(out, loc.FullNestedPath())
}
}
sort.Strings(out)
return out
}
// javaVirtualPaths maps each java package name to the virtual paths it was reported under; a name
// with two entries was cataloged twice.
func javaVirtualPaths(s *sbom.SBOM) map[string][]string {
out := map[string][]string{}
for _, p := range s.Artifacts.Packages.Sorted() {
if metadata, ok := p.Metadata.(pkg.JavaArchive); ok {
out[p.Name] = append(out[p.Name], metadata.VirtualPath)
}
}
return out
}
// 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{}
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
}
// 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 {
globs []string
record func([]string)
}
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.globs...)
if err != nil {
return nil, nil, err
}
var paths []string
for _, loc := range locations {
paths = append(paths, loc.FullNestedPath())
}
c.record(paths)
return nil, nil, nil
}
// 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)
}
// 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
}