Add nested archive cataloging (#5260)

Signed-off-by: Kudryavcev Nikolay <kydry.nikolau@gmail.com>
Signed-off-by: Thomas Bechtold <thomasbechtold@jpberlin.de>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Signed-off-by: Keith Zantow <kzantow@gmail.com>
Co-authored-by: Kudryavcev Nikolay <kydry.nikolau@gmail.com>
Co-authored-by: Thomas Bechtold <thomasbechtold@jpberlin.de>
Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Keith Zantow
2026-10-29 17:33:07 -04:00
committed by GitHub
co-authored by Kudryavcev Nikolay Thomas Bechtold Alex Goodman
parent 14a0beb93c
commit 4b8a3db5f3
224 changed files with 15918 additions and 1281 deletions
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"github.com/anchore/syft/internal/capabilities/internal"
@@ -31,6 +32,10 @@ type ConfigInfo struct {
var appConfigAnnotationPattern = regexp.MustCompile(`^//\s*app-config:\s*(.+)$`)
// embeddedFieldsAnnotationPattern matches "// capabilities-fields: A, B" on an embedded struct, which limits
// the embedded fields listed to the ones named (for a shared type the cataloger reads only part of).
var embeddedFieldsAnnotationPattern = regexp.MustCompile(`^//\s*capabilities-fields:\s*(.+)$`)
// DiscoverConfigs walks the cataloger directory and discovers all configuration structs
// Returns map where key is "packageName.StructName" (e.g., "golang.CatalogerConfig")
func DiscoverConfigs(repoRoot string) (map[string]ConfigInfo, error) {
@@ -160,6 +165,9 @@ func extractCatalogerConfigFieldsRecursive(structType *ast.StructType, visitedTy
if len(field.Names) == 0 {
// this is an embedded field - resolve and extract its fields
embeddedFields := resolveEmbeddedStructFields(field.Type, visitedTypes)
if only := embeddedFieldAllowlist(field.Doc); only != nil {
embeddedFields = slices.DeleteFunc(embeddedFields, func(f ConfigField) bool { return !only[f.Name] })
}
fields = append(fields, embeddedFields...)
continue
}
@@ -195,6 +203,26 @@ func extractCatalogerConfigFieldsRecursive(structType *ast.StructType, visitedTy
return fields
}
// embeddedFieldAllowlist returns the field names from a "capabilities-fields:" annotation, or nil when there
// is none (every exported embedded field is listed).
func embeddedFieldAllowlist(commentGroup *ast.CommentGroup) map[string]bool {
if commentGroup == nil {
return nil
}
for _, comment := range commentGroup.List {
matches := embeddedFieldsAnnotationPattern.FindStringSubmatch(comment.Text)
if len(matches) < 2 {
continue
}
only := make(map[string]bool)
for _, name := range strings.Split(matches[1], ",") {
only[strings.TrimSpace(name)] = true
}
return only
}
return nil
}
// resolveEmbeddedStructFields resolves an embedded struct type and extracts its fields recursively
func resolveEmbeddedStructFields(fieldType ast.Expr, visitedTypes map[string]bool) []ConfigField {
// extract the type name from the expression
@@ -457,3 +457,32 @@ func TestDiscoverAllowedConfigStructs(t *testing.T) {
t.Logf(" - %s", configType)
}
}
func TestEmbeddedFieldAllowlist(t *testing.T) {
comments := func(lines ...string) *ast.CommentGroup {
g := &ast.CommentGroup{}
for _, l := range lines {
g.List = append(g.List, &ast.Comment{Text: l})
}
return g
}
tests := []struct {
name string
doc *ast.CommentGroup
want map[string]bool
}{
{name: "no doc lists every field", doc: nil, want: nil},
{name: "no annotation lists every field", doc: comments("// Deprecated: use something else."), want: nil},
{
name: "annotation limits to the named fields",
doc: comments("// Deprecated: use something else.", "// capabilities-fields: IncludeIndexedArchives, IncludeUnindexedArchives"),
want: map[string]bool{"IncludeIndexedArchives": true, "IncludeUnindexedArchives": true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, embeddedFieldAllowlist(tt.doc))
})
}
}