From 5fc384dddbfc6746cfd27c90c4c8feacce1ceb32 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Thu, 24 Sep 2026 11:25:40 -0400 Subject: [PATCH] fix: keep the access path of archives discovered through a link `discoverArchives` collects MIME hits first, and those are bare real paths. When the glob pass then found the same file through a symlink, it was dropped as a duplicate along with its access path. Java decides whether to describe an archive by its name, so a content-addressed jar (`store/3f9a1c`, linked from `lib/json-simple-1.1.1.jar`) was extracted but produced no package. This is the pnpm / nix / bazel layout. When deduping, the candidate now takes the first location that carries an access path. Signed-off-by: Alex Goodman --- internal/task/archive_tasks.go | 13 +++++++++---- syft/archive_cataloger_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/internal/task/archive_tasks.go b/internal/task/archive_tasks.go index c2d7e7f31..d3c29808b 100644 --- a/internal/task/archive_tasks.go +++ b/internal/task/archive_tasks.go @@ -263,9 +263,9 @@ func (c *archiveCataloger) discoverArchives(resolver file.Resolver) []archiveCan } var candidates []archiveCandidate - seen := map[file.Coordinates]struct{}{} + seen := map[file.Coordinates]int{} for _, loc := range archives { - seen[loc.Coordinates] = struct{}{} + seen[loc.Coordinates] = len(candidates) candidates = append(candidates, archiveCandidate{location: loc, sniffedAsArchive: true}) } @@ -275,10 +275,15 @@ func (c *archiveCataloger) discoverArchives(resolver file.Resolver) []archiveCan return candidates } for _, loc := range namedLikeZips { - if _, dup := seen[loc.Coordinates]; dup { + if i, dup := seen[loc.Coordinates]; dup { + // a MIME hit is the bare real path; the name it is reached by (a link such as + // lib/dep.jar -> ../store/3f9a1c) is what name-based catalogers like java key off + if prev := candidates[i].location; prev.AccessPath == "" || prev.AccessPath == prev.RealPath { + candidates[i].location = loc + } continue } - seen[loc.Coordinates] = struct{}{} + seen[loc.Coordinates] = len(candidates) candidates = append(candidates, archiveCandidate{location: loc}) } return candidates diff --git a/syft/archive_cataloger_test.go b/syft/archive_cataloger_test.go index efb222d27..634b86491 100644 --- a/syft/archive_cataloger_test.go +++ b/syft/archive_cataloger_test.go @@ -330,6 +330,31 @@ func TestArchiveCataloger_featureOff(t *testing.T) { }) } +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 := jarBytes(t, "json-simple", "1.1.1") + 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")). + WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(2)) + s := scanDirWithExclusions(t, dir, cfg) + + assert.Equal(t, []string{"bundle.tar.gz|store/3f9a1c"}, packageLocations(s, "json-simple")) +} + 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().