fix: link nested java packages to their enclosing jar when nested cataloging is on

the legacy recursion links a nested jar to the jar that holds it: a `DEPENDENCY_OF` edge from inner to outer, and `JavaArchive.Parent` on the inner package. Inside an extracted archive java called `parse(ctx, nil)`, so both went away as soon as the feature was on. What replaced them was `file CONTAINS package`, which SPDX and CycloneDX drop, so those formats lost all war-to-jar structure.

- `archive.Traversal` gains `Parent` and `Packages` (the packages found describing that archive, filled in by the task after its sub-pipeline runs)
- java picks the enclosing jar's main package from the parent traversal (the one whose virtual path is the archive's own; pom-derived aux packages carry a suffixed one) and passes it to `parse`, then sets `Parent` the same way the legacy path does

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2026-09-24 13:36:52 -04:00
parent 23de18918e
commit 22edfe66ca
4 changed files with 88 additions and 2 deletions
+9
View File
@@ -5,6 +5,7 @@ import (
"strings" "strings"
"github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
) )
// Traversal describes the archive whose contents a cataloger is running against. The archive // Traversal describes the archive whose contents a cataloger is running against. The archive
@@ -20,6 +21,14 @@ type Traversal struct {
// Digests are of the archive file itself, taken during extraction; a cataloger running inside the // Digests are of the archive file itself, taken during extraction; a cataloger running inside the
// archive cannot compute them since the archive is not inside itself. // archive cannot compute them since the archive is not inside itself.
Digests []file.Digest Digests []file.Digest
// Parent is the traversal of the archive this one is nested in, nil for an archive at the scan root.
Parent *Traversal
// Packages are those the catalogers found describing this archive (located at it), filled in once
// they have run. Catalogers inside a nested archive read them through Parent, as java does to link a
// nested jar to the jar that holds it.
Packages []pkg.Package
} }
// ContentsArchivePath is the ArchivePath every file inside the archive carries: the chain to it, built // ContentsArchivePath is the ArchivePath every file inside the archive carries: the chain to it, built
+13 -1
View File
@@ -203,7 +203,7 @@ func (c *archiveCataloger) processArchive(ctx context.Context, parentResolver fi
// extracted files keep the filesystem ID of where the archive was found; the nesting chain is // extracted files keep the filesystem ID of where the archive was found; the nesting chain is
// carried separately as the archive path. The chain names real paths, not the link the archive was // carried separately as the archive path. The chain names real paths, not the link the archive was
// reached by, so it is stable and joins to the archive's package location by prefix. // reached by, so it is stable and joins to the archive's package location by prefix.
traversal := &archive.Traversal{Location: location} traversal := &archive.Traversal{Location: location, Parent: archive.TraversalFromContext(ctx)}
archivePath := traversal.ContentsArchivePath() archivePath := traversal.ContentsArchivePath()
extracted, err := archive.Extract(ctx, archiveContent, location.FileSystemID, archivePath, c.limiter, c.exclusions) extracted, err := archive.Extract(ctx, archiveContent, location.FileSystemID, archivePath, c.limiter, c.exclusions)
if errors.Is(err, archive.ErrDiskLimitReached) { if errors.Is(err, archive.ErrDiskLimitReached) {
@@ -240,6 +240,7 @@ func (c *archiveCataloger) processArchive(ctx context.Context, parentResolver fi
c.progress.Increment() c.progress.Increment()
scratch := c.runSubPipeline(ctx, extracted, location.Coordinates) scratch := c.runSubPipeline(ctx, extracted, location.Coordinates)
traversal.Packages = packagesAt(scratch, location.Coordinates)
children := c.discoverArchives(extracted) children := c.discoverArchives(extracted)
mergeArchiveResults(location.Coordinates, archivePath, children, scratch, builder) mergeArchiveResults(location.Coordinates, archivePath, children, scratch, builder)
c.progress.AtomicStage.Set(fmt.Sprintf("%s archives (%s)", humanize.Comma(c.progress.Current()), archivePath)) c.progress.AtomicStage.Set(fmt.Sprintf("%s archives (%s)", humanize.Comma(c.progress.Current()), archivePath))
@@ -277,6 +278,17 @@ func (c *archiveCataloger) isAncestor(digests []file.Digest) bool {
return false return false
} }
// packagesAt returns the packages located at the given coordinates, i.e. those describing the file there.
func packagesAt(s *sbom.SBOM, coordinates file.Coordinates) []pkg.Package {
var out []pkg.Package
for _, p := range s.Artifacts.Packages.Sorted() {
if p.Locations.CoordinateSet().Contains(coordinates) {
out = append(out, p)
}
}
return out
}
func recordArchiveUnknown(builder sbomsync.Builder, coordinates file.Coordinates, reason string) { func recordArchiveUnknown(builder sbomsync.Builder, coordinates file.Coordinates, reason string) {
appendUnknowns(builder, ArchiveCatalogerTaskName, []unknown.CoordinateError{{Coordinates: coordinates, Reason: errors.New(reason)}}) appendUnknowns(builder, ArchiveCatalogerTaskName, []unknown.CoordinateError{{Coordinates: coordinates, Reason: errors.New(reason)}})
} }
+38
View File
@@ -381,6 +381,44 @@ func TestArchiveCataloger_defaultFileSelectionCoversRootJars(t *testing.T) {
} }
} }
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 := buildZipBytesRaw(t, map[string][]byte{
"META-INF/MANIFEST.MF": []byte("Manifest-Version: 1.0\nImplementation-Title: app\nImplementation-Version: 1.0\n"),
"WEB-INF/lib/lib-2.0.jar": jarBytes(t, "lib", "2.0"),
})
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) { func TestArchiveCataloger_descriptorRecordsArchiveConfig(t *testing.T) {
// a consumer must be able to tell from the SBOM alone how deep archive contents were searched // a consumer must be able to tell from the SBOM alone how deep archive contents were searched
cfg := DefaultCreateSBOMConfig(). cfg := DefaultCreateSBOMConfig().
+28 -1
View File
@@ -82,7 +82,18 @@ func (c *archiveCataloger) catalogExtracted(ctx context.Context, trav *archive.T
return nil, nil, err return nil, nil, err
} }
pkgs, relationships, err := parser.parse(ctx, nil) // link to the jar this one is nested in, as the legacy recursion does: a DEPENDENCY_OF edge from
// parse, and Parent on whatever does not already have one
parent := enclosingJavaPackage(trav)
pkgs, relationships, err := parser.parse(ctx, parent)
if parent != nil {
for i := range pkgs {
if metadata, ok := pkgs[i].Metadata.(pkg.JavaArchive); ok && metadata.Parent == nil {
metadata.Parent = parent
pkgs[i].Metadata = metadata
}
}
}
if err != nil { if err != nil {
// attributed to the archive, as the generic cataloger attributes a parser failure to its file // attributed to the archive, as the generic cataloger attributes a parser failure to its file
return pkgs, relationships, unknown.New(trav.Location, err) return pkgs, relationships, unknown.New(trav.Location, err)
@@ -90,6 +101,22 @@ func (c *archiveCataloger) catalogExtracted(ctx context.Context, trav *archive.T
return pkgs, relationships, nil return pkgs, relationships, nil
} }
// enclosingJavaPackage returns the main java package of the archive the traversal's archive is nested
// in, if there is one. Of the java packages located at an archive, the main one carries the archive's
// own virtual path; those found from embedded pom files carry it with their group and artifact appended.
func enclosingJavaPackage(trav *archive.Traversal) *pkg.Package {
if trav.Parent == nil {
return nil
}
virtualPath := archive.VirtualPath(trav.Parent.Location)
for _, p := range trav.Parent.Packages {
if metadata, ok := p.Metadata.(pkg.JavaArchive); ok && metadata.VirtualPath == virtualPath {
return &p
}
}
return nil
}
// isJavaArchiveName reports whether the path matches the globs this cataloger opens files by. // isJavaArchiveName reports whether the path matches the globs this cataloger opens files by.
func isJavaArchiveName(path string) bool { func isJavaArchiveName(path string) bool {
for _, glob := range archiveFormatGlobs { for _, glob := range archiveFormatGlobs {