mirror of
https://github.com/anchore/syft.git
synced 2025-11-17 16:33:21 +01:00
663 lines
17 KiB
Go
663 lines
17 KiB
Go
// this file discovers application-level configuration from cmd/syft/internal/options/ by parsing ecosystem config structs, their DescribeFields() methods, and default value functions.
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// AppConfigField represents an application-level configuration field for catalogers
|
|
type AppConfigField struct {
|
|
Key string // e.g., "golang.search-local-mod-cache-licenses"
|
|
Description string // extracted from DescribeFields() method
|
|
DefaultValue interface{} // extracted from Default*() functions
|
|
}
|
|
|
|
// extractEcosystemConfigFieldsFromCatalog parses catalog.go and extracts the ecosystem-specific
|
|
// config fields from the Catalog struct, returning a map of struct type name to YAML tag
|
|
func extractEcosystemConfigFieldsFromCatalog(catalogFilePath string) (map[string]string, error) {
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, catalogFilePath, nil, parser.ParseComments)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse catalog.go: %w", err)
|
|
}
|
|
|
|
// find the Catalog struct
|
|
catalogStruct := findConfigStruct(f, "Catalog")
|
|
if catalogStruct == nil {
|
|
return nil, fmt.Errorf("catalog struct not found in %s", catalogFilePath)
|
|
}
|
|
|
|
// extract ecosystem config fields from the Catalog struct
|
|
// these are between the "ecosystem-specific cataloger configuration" comment and the next section
|
|
ecosystemConfigs := make(map[string]string)
|
|
inEcosystemSection := false
|
|
|
|
for _, field := range catalogStruct.Fields.List {
|
|
// check for ecosystem section marker comment
|
|
if field.Doc != nil {
|
|
for _, comment := range field.Doc.List {
|
|
if strings.Contains(comment.Text, "ecosystem-specific cataloger configuration") {
|
|
inEcosystemSection = true
|
|
break
|
|
}
|
|
// check if we've hit the next section (any comment marking a new section)
|
|
if inEcosystemSection && strings.HasPrefix(comment.Text, "// configuration for") {
|
|
inEcosystemSection = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if !inEcosystemSection {
|
|
continue
|
|
}
|
|
|
|
// extract field type and yaml tag
|
|
if len(field.Names) == 0 {
|
|
continue
|
|
}
|
|
|
|
// get the type name (e.g., "golangConfig")
|
|
var typeName string
|
|
if ident, ok := field.Type.(*ast.Ident); ok {
|
|
typeName = ident.Name
|
|
} else {
|
|
continue
|
|
}
|
|
|
|
// get the yaml tag
|
|
yamlTag := extractYAMLTag(field)
|
|
if yamlTag == "" || yamlTag == "-" {
|
|
continue
|
|
}
|
|
|
|
ecosystemConfigs[typeName] = yamlTag
|
|
}
|
|
|
|
return ecosystemConfigs, nil
|
|
}
|
|
|
|
// findFilesWithCatalogerImports scans the options directory for .go files that import
|
|
// from "github.com/anchore/syft/syft/pkg/cataloger/*" packages
|
|
func findFilesWithCatalogerImports(optionsDir string) ([]string, error) {
|
|
entries, err := os.ReadDir(optionsDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read options directory: %w", err)
|
|
}
|
|
|
|
var candidateFiles []string
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
|
|
continue
|
|
}
|
|
|
|
filePath := filepath.Join(optionsDir, entry.Name())
|
|
|
|
// parse the file to check imports
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, filePath, nil, parser.ImportsOnly)
|
|
if err != nil {
|
|
continue // skip files that can't be parsed
|
|
}
|
|
|
|
// check if file imports from cataloger packages
|
|
for _, imp := range f.Imports {
|
|
importPath := strings.Trim(imp.Path.Value, `"`)
|
|
if strings.HasPrefix(importPath, "github.com/anchore/syft/syft/pkg/cataloger/") {
|
|
candidateFiles = append(candidateFiles, filePath)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return candidateFiles, nil
|
|
}
|
|
|
|
// extractConfigStructTypes parses a Go file and returns all struct type names defined in it
|
|
func extractConfigStructTypes(filePath string) ([]string, error) {
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, filePath, nil, 0)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse %s: %w", filePath, err)
|
|
}
|
|
|
|
var structTypes []string
|
|
for _, decl := range f.Decls {
|
|
genDecl, ok := decl.(*ast.GenDecl)
|
|
if !ok || genDecl.Tok != token.TYPE {
|
|
continue
|
|
}
|
|
|
|
for _, spec := range genDecl.Specs {
|
|
typeSpec, ok := spec.(*ast.TypeSpec)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// check if it's a struct type
|
|
if _, ok := typeSpec.Type.(*ast.StructType); ok {
|
|
structTypes = append(structTypes, typeSpec.Name.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
return structTypes, nil
|
|
}
|
|
|
|
// discoverCatalogerConfigs discovers cataloger config files by:
|
|
// 1. Finding files with cataloger imports in options directory
|
|
// 2. Extracting ecosystem config fields from Catalog struct
|
|
// 3. Matching file structs against Catalog fields
|
|
// Returns a map of file path to top-level YAML key
|
|
func discoverCatalogerConfigs(repoRoot string) (map[string]string, error) {
|
|
optionsDir := filepath.Join(repoRoot, "cmd", "syft", "internal", "options")
|
|
catalogFilePath := filepath.Join(optionsDir, "catalog.go")
|
|
|
|
// get ecosystem config fields from Catalog struct
|
|
ecosystemConfigs, err := extractEcosystemConfigFieldsFromCatalog(catalogFilePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(ecosystemConfigs) == 0 {
|
|
return nil, fmt.Errorf("no ecosystem config fields found in Catalog struct")
|
|
}
|
|
|
|
// find files with cataloger imports
|
|
candidateFiles, err := findFilesWithCatalogerImports(optionsDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// match candidate files against Catalog ecosystem fields
|
|
fileToKey := make(map[string]string)
|
|
foundStructs := make(map[string]bool)
|
|
|
|
for _, filePath := range candidateFiles {
|
|
structTypes, err := extractConfigStructTypes(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// check if any struct type matches an ecosystem config
|
|
for _, structType := range structTypes {
|
|
if yamlKey, exists := ecosystemConfigs[structType]; exists {
|
|
fileToKey[filePath] = yamlKey
|
|
foundStructs[structType] = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// validate that all ecosystem configs were found
|
|
var missingConfigs []string
|
|
for structType := range ecosystemConfigs {
|
|
if !foundStructs[structType] {
|
|
missingConfigs = append(missingConfigs, structType)
|
|
}
|
|
}
|
|
|
|
if len(missingConfigs) > 0 {
|
|
sort.Strings(missingConfigs)
|
|
return nil, fmt.Errorf("could not find files for ecosystem configs: %s", strings.Join(missingConfigs, ", "))
|
|
}
|
|
|
|
return fileToKey, nil
|
|
}
|
|
|
|
// DiscoverAppConfigs discovers all application-level cataloger configuration fields
|
|
// from the options package
|
|
func DiscoverAppConfigs(repoRoot string) ([]AppConfigField, error) {
|
|
// discover cataloger config files dynamically
|
|
configFiles, err := discoverCatalogerConfigs(repoRoot)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to discover cataloger configs: %w", err)
|
|
}
|
|
|
|
// extract configuration fields from each discovered file
|
|
var configs []AppConfigField
|
|
for filePath, topLevelKey := range configFiles {
|
|
fields, err := extractAppConfigFields(filePath, topLevelKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to extract config from %s: %w", filePath, err)
|
|
}
|
|
configs = append(configs, fields...)
|
|
}
|
|
|
|
// sort by key for consistent output
|
|
sort.Slice(configs, func(i, j int) bool {
|
|
return configs[i].Key < configs[j].Key
|
|
})
|
|
|
|
return configs, nil
|
|
}
|
|
|
|
// extractAppConfigFields extracts config fields from an options file
|
|
func extractAppConfigFields(filePath, topLevelKey string) ([]AppConfigField, error) {
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var configs []AppConfigField
|
|
|
|
// find the main config struct (not nested ones)
|
|
configStruct, descriptions := findAppConfigStructAndDescriptions(f, topLevelKey)
|
|
if configStruct == nil {
|
|
return nil, fmt.Errorf("no config struct found in %s", filePath)
|
|
}
|
|
|
|
// extract default values from the default function
|
|
defaults := extractAppDefaultValues(f)
|
|
|
|
// build config fields from struct fields
|
|
for _, field := range configStruct.Fields.List {
|
|
// extract yaml tag to get the field key
|
|
yamlKey := extractYAMLTag(field)
|
|
if yamlKey == "" || yamlKey == "-" {
|
|
continue
|
|
}
|
|
|
|
var fieldName string
|
|
if len(field.Names) > 0 {
|
|
fieldName = field.Names[0].Name
|
|
} else {
|
|
continue
|
|
}
|
|
|
|
// build full key path
|
|
fullKey := topLevelKey + "." + yamlKey
|
|
|
|
// handle nested structs (e.g., golang.MainModuleVersion)
|
|
if isNestedStruct(field.Type) {
|
|
nestedConfigs := extractNestedAppConfigs(f, fullKey, fieldName, field.Type, descriptions, defaults)
|
|
configs = append(configs, nestedConfigs...)
|
|
continue
|
|
}
|
|
|
|
// get description from DescribeFields
|
|
description := descriptions[fieldName]
|
|
|
|
// get default value
|
|
defaultValue := defaults[fieldName]
|
|
|
|
configs = append(configs, AppConfigField{
|
|
Key: fullKey,
|
|
Description: description,
|
|
DefaultValue: defaultValue,
|
|
})
|
|
}
|
|
|
|
return configs, nil
|
|
}
|
|
|
|
// findAppConfigStructAndDescriptions finds the main config struct and extracts field descriptions
|
|
// from the DescribeFields method
|
|
func findAppConfigStructAndDescriptions(f *ast.File, topLevelKey string) (*ast.StructType, map[string]string) {
|
|
expectedName := determineExpectedConfigName(topLevelKey)
|
|
configStruct := findConfigStruct(f, expectedName)
|
|
descriptions := extractDescriptionsFromDescribeFields(f)
|
|
return configStruct, descriptions
|
|
}
|
|
|
|
// determineExpectedConfigName maps the top-level key to the expected config struct name
|
|
func determineExpectedConfigName(topLevelKey string) string {
|
|
// handle special cases first
|
|
switch topLevelKey {
|
|
case "linux-kernel":
|
|
return "linuxKernelConfig"
|
|
case "javascript":
|
|
return "javaScriptConfig"
|
|
default:
|
|
return topLevelKey + "Config"
|
|
}
|
|
}
|
|
|
|
// findConfigStruct searches for the config struct with the expected name in the AST
|
|
func findConfigStruct(f *ast.File, expectedName string) *ast.StructType {
|
|
for _, decl := range f.Decls {
|
|
genDecl, ok := decl.(*ast.GenDecl)
|
|
if !ok || genDecl.Tok != token.TYPE {
|
|
continue
|
|
}
|
|
|
|
for _, spec := range genDecl.Specs {
|
|
typeSpec, ok := spec.(*ast.TypeSpec)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
structType, ok := typeSpec.Type.(*ast.StructType)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
if typeSpec.Name.Name == expectedName {
|
|
return structType
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extractDescriptionsFromDescribeFields extracts field descriptions from the DescribeFields method
|
|
func extractDescriptionsFromDescribeFields(f *ast.File) map[string]string {
|
|
descriptions := make(map[string]string)
|
|
|
|
for _, decl := range f.Decls {
|
|
funcDecl, ok := decl.(*ast.FuncDecl)
|
|
if !ok || funcDecl.Name.Name != "DescribeFields" {
|
|
continue
|
|
}
|
|
|
|
// extract descriptions from descriptions.Add calls
|
|
ast.Inspect(funcDecl.Body, func(n ast.Node) bool {
|
|
callExpr, ok := n.(*ast.CallExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
|
|
// check if this is a descriptions.Add call
|
|
selector, ok := callExpr.Fun.(*ast.SelectorExpr)
|
|
if !ok || selector.Sel.Name != "Add" {
|
|
return true
|
|
}
|
|
|
|
// first argument should be a field reference (&o.FieldName or &o.Parent.FieldName)
|
|
if len(callExpr.Args) < 2 {
|
|
return true
|
|
}
|
|
|
|
fieldPath := extractFieldPathFromRef(callExpr.Args[0])
|
|
if fieldPath == "" {
|
|
return true
|
|
}
|
|
|
|
// second argument is the description string
|
|
description := extractStringLiteral(callExpr.Args[1])
|
|
if description != "" {
|
|
description = cleanDescription(description)
|
|
descriptions[fieldPath] = description
|
|
}
|
|
|
|
return true
|
|
})
|
|
}
|
|
|
|
return descriptions
|
|
}
|
|
|
|
// extractNestedAppConfigs handles nested config structs like golang.MainModuleVersion
|
|
func extractNestedAppConfigs(f *ast.File, parentKey, parentFieldName string, fieldType ast.Expr, descriptions map[string]string, defaults map[string]interface{}) []AppConfigField {
|
|
var configs []AppConfigField
|
|
|
|
// find the nested struct type
|
|
var nestedStructName string
|
|
switch t := fieldType.(type) {
|
|
case *ast.Ident:
|
|
nestedStructName = t.Name
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
// find the struct definition
|
|
var nestedStruct *ast.StructType
|
|
for _, decl := range f.Decls {
|
|
genDecl, ok := decl.(*ast.GenDecl)
|
|
if !ok || genDecl.Tok != token.TYPE {
|
|
continue
|
|
}
|
|
|
|
for _, spec := range genDecl.Specs {
|
|
typeSpec, ok := spec.(*ast.TypeSpec)
|
|
if !ok || typeSpec.Name.Name != nestedStructName {
|
|
continue
|
|
}
|
|
|
|
var structOk bool
|
|
nestedStruct, structOk = typeSpec.Type.(*ast.StructType)
|
|
if structOk {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if nestedStruct == nil {
|
|
return nil
|
|
}
|
|
|
|
// extract fields from nested struct
|
|
for _, field := range nestedStruct.Fields.List {
|
|
yamlKey := extractYAMLTag(field)
|
|
if yamlKey == "" || yamlKey == "-" {
|
|
continue
|
|
}
|
|
|
|
var fieldName string
|
|
if len(field.Names) > 0 {
|
|
fieldName = field.Names[0].Name
|
|
} else {
|
|
continue
|
|
}
|
|
|
|
fullKey := parentKey + "." + yamlKey
|
|
|
|
// get description using the nested path (e.g., "MainModuleVersion.FromLDFlags")
|
|
nestedPath := parentFieldName + "." + fieldName
|
|
description := descriptions[nestedPath]
|
|
|
|
// try to get default value from nested defaults
|
|
var defaultValue interface{}
|
|
if nestedDefaults, ok := defaults[parentFieldName].(map[string]interface{}); ok {
|
|
defaultValue = nestedDefaults[fieldName]
|
|
}
|
|
|
|
configs = append(configs, AppConfigField{
|
|
Key: fullKey,
|
|
Description: description,
|
|
DefaultValue: defaultValue,
|
|
})
|
|
}
|
|
|
|
return configs
|
|
}
|
|
|
|
// extractAppDefaultValues extracts default values from the default*Config function
|
|
func extractAppDefaultValues(f *ast.File) map[string]interface{} {
|
|
defaults := make(map[string]interface{})
|
|
|
|
for _, decl := range f.Decls {
|
|
funcDecl, ok := decl.(*ast.FuncDecl)
|
|
if !ok || !strings.HasPrefix(funcDecl.Name.Name, "default") {
|
|
continue
|
|
}
|
|
|
|
// look for return statements that construct the config struct
|
|
ast.Inspect(funcDecl.Body, func(n ast.Node) bool {
|
|
// look for return statements
|
|
returnStmt, ok := n.(*ast.ReturnStmt)
|
|
if !ok || len(returnStmt.Results) == 0 {
|
|
return true
|
|
}
|
|
|
|
// check if returning a struct literal
|
|
compositeLit, ok := returnStmt.Results[0].(*ast.CompositeLit)
|
|
if !ok {
|
|
return true
|
|
}
|
|
|
|
// extract field values from the composite literal
|
|
for _, elt := range compositeLit.Elts {
|
|
kvExpr, ok := elt.(*ast.KeyValueExpr)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// get field name
|
|
ident, ok := kvExpr.Key.(*ast.Ident)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
fieldName := ident.Name
|
|
|
|
// extract the value
|
|
value := extractAppValue(kvExpr.Value)
|
|
if value != nil {
|
|
defaults[fieldName] = value
|
|
}
|
|
}
|
|
|
|
return true
|
|
})
|
|
}
|
|
|
|
return defaults
|
|
}
|
|
|
|
// extractAppValue extracts a Go value from an AST expression
|
|
func extractAppValue(expr ast.Expr) interface{} {
|
|
switch v := expr.(type) {
|
|
case *ast.BasicLit:
|
|
// string, int, bool literals
|
|
switch v.Kind {
|
|
case token.STRING:
|
|
return strings.Trim(v.Value, `"`)
|
|
case token.INT:
|
|
return v.Value
|
|
case token.FLOAT:
|
|
return v.Value
|
|
}
|
|
case *ast.Ident:
|
|
// boolean values
|
|
if v.Name == "true" {
|
|
return true
|
|
}
|
|
if v.Name == "false" {
|
|
return false
|
|
}
|
|
if v.Name == "nil" {
|
|
return nil
|
|
}
|
|
case *ast.CompositeLit:
|
|
// nested struct literal
|
|
nested := make(map[string]interface{})
|
|
for _, elt := range v.Elts {
|
|
kvExpr, ok := elt.(*ast.KeyValueExpr)
|
|
if !ok {
|
|
continue
|
|
}
|
|
ident, ok := kvExpr.Key.(*ast.Ident)
|
|
if !ok {
|
|
continue
|
|
}
|
|
nested[ident.Name] = extractAppValue(kvExpr.Value)
|
|
}
|
|
if len(nested) > 0 {
|
|
return nested
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extractYAMLTag extracts the yaml tag value from a struct field
|
|
func extractYAMLTag(field *ast.Field) string {
|
|
if field.Tag == nil {
|
|
return ""
|
|
}
|
|
|
|
tag := strings.Trim(field.Tag.Value, "`")
|
|
tags := reflect.StructTag(tag)
|
|
yamlTag := tags.Get("yaml")
|
|
|
|
// handle tags like "field-name,omitempty"
|
|
if idx := strings.Index(yamlTag, ","); idx != -1 {
|
|
yamlTag = yamlTag[:idx]
|
|
}
|
|
|
|
return yamlTag
|
|
}
|
|
|
|
// extractFieldPathFromRef extracts field path from & o.FieldName or &o.Parent.FieldName expression
|
|
func extractFieldPathFromRef(expr ast.Expr) string {
|
|
unaryExpr, ok := expr.(*ast.UnaryExpr)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
// handle nested field references like &o.MainModuleVersion.FromLDFlags
|
|
var parts []string
|
|
current := unaryExpr.X
|
|
|
|
for {
|
|
selectorExpr, ok := current.(*ast.SelectorExpr)
|
|
if !ok {
|
|
break
|
|
}
|
|
|
|
// add this selector to the path
|
|
parts = append([]string{selectorExpr.Sel.Name}, parts...)
|
|
|
|
// move to the next level
|
|
current = selectorExpr.X
|
|
}
|
|
|
|
// join the parts with dots to create the full path
|
|
// e.g., ["MainModuleVersion", "FromLDFlags"] -> "MainModuleVersion.FromLDFlags"
|
|
if len(parts) > 0 {
|
|
return strings.Join(parts, ".")
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// extractStringLiteral extracts a string value from a BasicLit node
|
|
func extractStringLiteral(expr ast.Expr) string {
|
|
lit, ok := expr.(*ast.BasicLit)
|
|
if !ok || lit.Kind != token.STRING {
|
|
return ""
|
|
}
|
|
|
|
// remove backticks or quotes
|
|
value := strings.Trim(lit.Value, "`\"")
|
|
return value
|
|
}
|
|
|
|
// cleanDescription cleans up multi-line descriptions
|
|
func cleanDescription(desc string) string {
|
|
// replace multiple whitespace with single space
|
|
desc = strings.Join(strings.Fields(desc), " ")
|
|
return strings.TrimSpace(desc)
|
|
}
|
|
|
|
// isNestedStruct checks if a field type is a nested struct (not a pointer or basic type)
|
|
func isNestedStruct(expr ast.Expr) bool {
|
|
switch t := expr.(type) {
|
|
case *ast.Ident:
|
|
// check if it's a struct type (not a basic type)
|
|
// basic types would be: string, int, bool, etc.
|
|
basicTypes := map[string]bool{
|
|
"string": true, "int": true, "int8": true, "int16": true, "int32": true, "int64": true,
|
|
"uint": true, "uint8": true, "uint16": true, "uint32": true, "uint64": true,
|
|
"float32": true, "float64": true, "bool": true, "byte": true, "rune": true,
|
|
}
|
|
return !basicTypes[t.Name]
|
|
case *ast.StarExpr:
|
|
// pointer types are not nested structs for our purposes
|
|
return false
|
|
case *ast.ArrayType, *ast.MapType:
|
|
return false
|
|
default:
|
|
return false
|
|
}
|
|
}
|