syft/internal/capabilities/generate/completeness_test.go
Alex Goodman 1510db7c4e add info command from generated capabilities
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
2025-10-13 17:14:40 -04:00

967 lines
32 KiB
Go

package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"os/exec"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"github.com/scylladb/go-set/strset"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/internal/capabilities"
"github.com/anchore/syft/internal/packagemetadata"
"github.com/anchore/syft/syft/pkg"
)
// requireParserObservations controls whether TestAllCatalogersHaveObservations enforces that all parsers have observations
// - true: fail test if any parser is missing observations (strict mode)
// - false: only check custom catalogers, skip parser checks (lenient mode, not all parsers are observable)
const requireParserObservations = false
// metadataTypeCoverageExceptions lists metadata types that are allowed to not be represented in any cataloger
var metadataTypeCoverageExceptions = strset.New(
reflect.TypeOf(pkg.MicrosoftKbPatch{}).Name(),
)
// packageTypeCoverageExceptions lists package types that are allowed to not be represented in any cataloger
var packageTypeCoverageExceptions = strset.New(
string(pkg.JenkinsPluginPkg), // TODO: this should probably be covered by a cataloger test one day
string(pkg.KbPkg),
)
// observationExceptions maps cataloger/parser names to observation types that should be ignored during validation
//
// TestAllCatalogersHaveObservations:
// - always checks custom catalogers
// - checks parsers only if requireParserObservations=true
// - nil or non-nil value: skip existence check for this cataloger/parser
//
// examples:
//
// "graalvm-native-image-cataloger": nil, // custom cataloger: skip existence check
// "linux-kernel-cataloger": strset.New("relationships"), // custom cataloger: skip only relationships validation
// "conan-cataloger/parseConanLock": nil, // parser: skip all observation validation
// "cataloger-name/parser-function": strset.New("file_digests"), // parser: skip only file_digests validation
var observationExceptions = map[string]*strset.Set{
// for the graalvm-native-image-cataloger, we don't have a really reliable test fixture yet
"graalvm-native-image-cataloger": nil,
// the linux-kernel-cataloger produces relationships but aren't really indicative of dependency information in the way the user might expect
"linux-kernel-cataloger": strset.New("relationships"),
}
func TestCatalogersInSync(t *testing.T) {
// get canonical list from syft binary
catalogersInBinary := getCatalogerNamesFromBinary(t)
// load catalogers from embedded YAML
catalogerEntries, err := capabilities.Packages()
require.NoError(t, err)
yamlCatalogers := strset.New()
for _, c := range catalogerEntries {
yamlCatalogers.Add(c.Name)
}
// test 1: All catalogers in binary must be in YAML
var missingFromYAML []string
for _, name := range catalogersInBinary {
if !yamlCatalogers.Has(name) {
missingFromYAML = append(missingFromYAML, name)
}
}
require.Empty(t, missingFromYAML,
"The following catalogers are in 'syft cataloger list' but missing from capabilities YAML: %v\n"+
"Run 'go generate ./internal/capabilities' to auto-add generic catalogers, or manually add custom catalogers.",
missingFromYAML)
// test 2: All catalogers in YAML must exist in binary
var orphanedInYAML []string
binarySet := strset.New()
for _, name := range catalogersInBinary {
binarySet.Add(name)
}
for _, name := range yamlCatalogers.List() {
if !binarySet.Has(name) {
orphanedInYAML = append(orphanedInYAML, name)
}
}
require.Empty(t, orphanedInYAML,
"The following catalogers are in capabilities YAML but not found in binary: %v\n"+
"These catalogers may have been removed. Delete them from the YAML.",
orphanedInYAML)
// test 3: All capabilities must be filled (no TODOs/nulls)
validateCapabilitiesFilled(t, catalogerEntries)
}
func getCatalogerNamesFromBinary(t *testing.T) []string {
// get cataloger names from task factories
infos, err := allPackageCatalogerInfo()
require.NoError(t, err)
var names []string
for _, info := range infos {
names = append(names, info.Name)
}
sort.Strings(names)
return names
}
func validateCapabilitiesFilled(t *testing.T, catalogers []capabilities.CatalogerEntry) {
for _, cataloger := range catalogers {
cataloger := cataloger // capture loop variable for subtest
t.Run(cataloger.Name, func(t *testing.T) {
if cataloger.Type == "generic" {
// generic catalogers have parsers with capabilities
require.NotEmpty(t, cataloger.Parsers, "generic cataloger must have at least one parser")
for _, parser := range cataloger.Parsers {
parser := parser // capture loop variable for subtest
t.Run(parser.ParserFunction, func(t *testing.T) {
require.NotEmpty(t, parser.Capabilities, "parser must have at least one capability field defined")
})
}
} else if cataloger.Type == "custom" {
// custom catalogers have cataloger-level capabilities
require.NotEmpty(t, cataloger.Capabilities, "custom cataloger must have at least one capability field defined")
}
})
}
}
func TestPackageTypeCoverage(t *testing.T) {
// load catalogers from embedded YAML
catalogerEntries, err := capabilities.Packages()
require.NoError(t, err)
// collect all package types mentioned in catalogers
foundPkgTypes := strset.New()
for _, cataloger := range catalogerEntries {
if cataloger.Type == "generic" {
for _, parser := range cataloger.Parsers {
for _, pkgType := range parser.PackageTypes {
foundPkgTypes.Add(pkgType)
}
}
} else if cataloger.Type == "custom" {
for _, pkgType := range cataloger.PackageTypes {
foundPkgTypes.Add(pkgType)
}
}
}
// check that all known package types are represented
var missingTypes []pkg.Type
for _, pkgType := range pkg.AllPkgs {
if !foundPkgTypes.Has(string(pkgType)) {
missingTypes = append(missingTypes, pkgType)
}
}
// filter out exceptions
var missingTypesWithoutExceptions []pkg.Type
for _, pkgType := range missingTypes {
if !packageTypeCoverageExceptions.Has(string(pkgType)) {
missingTypesWithoutExceptions = append(missingTypesWithoutExceptions, pkgType)
}
}
require.Empty(t, missingTypesWithoutExceptions,
"The following package types are not represented in any cataloger: %v\n"+
"Either add catalogers for these types or update pkg.AllPkgs if they're no longer supported.",
missingTypesWithoutExceptions)
}
func TestMetadataTypeCoverage(t *testing.T) {
// load catalogers from embedded YAML
catalogerEntries, err := capabilities.Packages()
require.NoError(t, err)
// collect all metadata types mentioned in catalogers
foundMetadataTypes := strset.New()
for _, cataloger := range catalogerEntries {
if cataloger.Type == "generic" {
for _, parser := range cataloger.Parsers {
for _, metadataType := range parser.MetadataTypes {
foundMetadataTypes.Add(strings.TrimPrefix(metadataType, "pkg."))
}
}
} else if cataloger.Type == "custom" {
for _, metadataType := range cataloger.MetadataTypes {
foundMetadataTypes.Add(strings.TrimPrefix(metadataType, "pkg."))
}
}
}
// get all known metadata types
allMetadataTypes := packagemetadata.AllTypes()
// check that all known metadata types are represented
var missingTypes []string
for _, metadataType := range allMetadataTypes {
typeName := reflect.TypeOf(metadataType).Name()
if !foundMetadataTypes.Has(typeName) {
missingTypes = append(missingTypes, typeName)
}
}
// filter out exceptions
var missingTypesWithoutExceptions []string
for _, metadataType := range missingTypes {
if !metadataTypeCoverageExceptions.Has(metadataType) {
missingTypesWithoutExceptions = append(missingTypesWithoutExceptions, metadataType)
}
}
require.Empty(t, missingTypesWithoutExceptions,
"The following metadata types are not represented in any cataloger: %v\n"+
"Either add catalogers for these types or update packagemetadata.AllTypes() if they're no longer supported.",
missingTypesWithoutExceptions)
}
func TestCatalogerStructure(t *testing.T) {
// load catalogers from embedded YAML
catalogerEntries, err := capabilities.Packages()
require.NoError(t, err)
for _, cataloger := range catalogerEntries {
cataloger := cataloger // capture loop variable for subtest
t.Run(cataloger.Name, func(t *testing.T) {
// ecosystem must always be set (it's MANUAL)
require.NotEmpty(t, cataloger.Ecosystem, "ecosystem must be set for all catalogers")
if cataloger.Type == "generic" {
// generic catalogers must have parsers
require.NotEmpty(t, cataloger.Parsers, "generic cataloger must have at least one parser")
// generic catalogers should not have cataloger-level capabilities
require.Empty(t, cataloger.Capabilities, "generic cataloger should not have cataloger-level capabilities (use parser-level instead)")
// generic catalogers should not have cataloger-level metadata/package types
require.Empty(t, cataloger.MetadataTypes, "generic cataloger should not have cataloger-level metadata types")
require.Empty(t, cataloger.PackageTypes, "generic cataloger should not have cataloger-level package types")
} else if cataloger.Type == "custom" {
// custom catalogers must have detectors
require.NotEmpty(t, cataloger.Detectors, "custom cataloger must have at least one detector")
// custom catalogers must have cataloger-level capabilities
require.NotEmpty(t, cataloger.Capabilities, "custom cataloger must have cataloger-level capabilities")
// custom catalogers should not have parsers
require.Empty(t, cataloger.Parsers, "custom cataloger should not have parsers (those are for generic catalogers)")
} else {
t.Errorf("unknown cataloger type: %q (must be 'generic' or 'custom')", cataloger.Type)
}
})
}
}
func TestCatalogerDataQuality(t *testing.T) {
// load catalogers from embedded YAML
catalogerEntries, err := capabilities.Packages()
require.NoError(t, err)
t.Run("no duplicate cataloger names", func(t *testing.T) {
nameCount := make(map[string]int)
for _, cataloger := range catalogerEntries {
nameCount[cataloger.Name]++
}
var duplicates []string
for name, count := range nameCount {
if count > 1 {
duplicates = append(duplicates, fmt.Sprintf("%s (appears %d times)", name, count))
}
}
require.Empty(t, duplicates, "Found duplicate cataloger names: %v", duplicates)
})
t.Run("detector validation for custom catalogers", func(t *testing.T) {
for _, cataloger := range catalogerEntries {
if cataloger.Type != "custom" {
continue
}
cataloger := cataloger // capture loop variable
t.Run(cataloger.Name, func(t *testing.T) {
require.NotEmpty(t, cataloger.Detectors, "custom cataloger must have at least one detector")
for i, detector := range cataloger.Detectors {
t.Run(fmt.Sprintf("detector-%d", i), func(t *testing.T) {
// detector criteria must not be empty
require.NotEmpty(t, detector.Criteria, "detector criteria must not be empty")
// detector method must be valid
validMethods := map[capabilities.ArtifactDetectionMethod]bool{
capabilities.GlobDetection: true,
capabilities.PathDetection: true,
capabilities.MIMETypeDetection: true,
}
require.True(t, validMethods[detector.Method],
"detector method must be one of: glob, path, mimetype (got %q)", detector.Method)
})
}
})
}
})
t.Run("no duplicate parser functions within cataloger", func(t *testing.T) {
for _, cataloger := range catalogerEntries {
if cataloger.Type != "generic" {
continue
}
cataloger := cataloger // capture loop variable
t.Run(cataloger.Name, func(t *testing.T) {
parserFuncs := strset.New()
var duplicates []string
for _, parser := range cataloger.Parsers {
if parserFuncs.Has(parser.ParserFunction) {
duplicates = append(duplicates, parser.ParserFunction)
}
parserFuncs.Add(parser.ParserFunction)
}
require.Empty(t, duplicates, "Found duplicate parser functions: %v", duplicates)
})
}
})
}
// TestRegenerateCapabilitiesDoesNotFail verifies that regeneration runs successfully
func TestRegenerateCapabilitiesDoesNotFail(t *testing.T) {
if os.Getenv("CI") == "" {
t.Skip("skipping regeneration test in local environment")
}
repoRoot, err := RepoRoot()
require.NoError(t, err)
yamlPath := filepath.Join(repoRoot, "internal/capabilities/packages.yaml")
// regenerate should not fail
_, err = RegenerateCapabilities(yamlPath, repoRoot)
require.NoError(t, err)
// verify file hasn't changed (i.e., it was already up to date)
cmd := exec.Command("git", "diff", "--exit-code", yamlPath)
cmd.Dir = repoRoot
err = cmd.Run()
require.NoError(t, err, "packages.yaml has uncommitted changes after regeneration. Run 'go generate ./internal/capabilities' locally and commit the changes.")
}
// TestAllCatalogersHaveObservations verifies that all catalogers have test observations,
// ensuring they are using the pkgtest helpers
func TestAllCatalogersHaveObservations(t *testing.T) {
repoRoot, err := RepoRoot()
require.NoError(t, err)
// load catalogers from YAML
catalogerEntries, err := capabilities.Packages()
require.NoError(t, err)
// collect all catalogers and parsers from observations
observedCatalogers := strset.New()
observedParsers := make(map[string]*strset.Set) // package -> parser set
// walk test-fixtures directories to find test-observations.json files
testFixtureDirs, err := findTestFixtureDirs(repoRoot)
require.NoError(t, err)
for _, dir := range testFixtureDirs {
observationsFile := filepath.Join(dir, "test-observations.json")
if _, err := os.Stat(observationsFile); os.IsNotExist(err) {
continue
}
observations, err := readTestObservations(observationsFile)
if err != nil {
t.Logf("Warning: failed to read %s: %v", observationsFile, err)
continue
}
// track observed catalogers
for catalogerName := range observations.Catalogers {
observedCatalogers.Add(catalogerName)
}
// track observed parsers
pkg := observations.Package
if observedParsers[pkg] == nil {
observedParsers[pkg] = strset.New()
}
for parserName := range observations.Parsers {
observedParsers[pkg].Add(parserName)
}
}
// infer parser observations for single-parser catalogers
// if a cataloger has only one parser and the cataloger was observed, assume the parser was evaluated
for _, cataloger := range catalogerEntries {
if cataloger.Type == "generic" && len(cataloger.Parsers) == 1 && observedCatalogers.Has(cataloger.Name) {
packageName := extractPackageName(cataloger.Name)
if observedParsers[packageName] == nil {
observedParsers[packageName] = strset.New()
}
observedParsers[packageName].Add(cataloger.Parsers[0].ParserFunction)
}
}
// verify catalogers have observations
var missingCatalogers []string
var missingParsers []string
for _, cataloger := range catalogerEntries {
if cataloger.Type == "custom" {
// custom catalogers should always have cataloger-level observations
// skip if this cataloger has an exception (nil or non-nil)
if _, hasException := observationExceptions[cataloger.Name]; hasException {
continue
}
if !observedCatalogers.Has(cataloger.Name) {
missingCatalogers = append(missingCatalogers, cataloger.Name)
}
} else if cataloger.Type == "generic" && requireParserObservations {
// generic catalogers have parser-level observations (only checked if requireParserObservations=true)
// skip if the cataloger itself has an exception (applies to all its parsers)
if _, hasException := observationExceptions[cataloger.Name]; hasException {
continue
}
// extract package name from cataloger name
packageName := extractPackageName(cataloger.Name)
for _, parser := range cataloger.Parsers {
parserKey := fmt.Sprintf("%s/%s", cataloger.Name, parser.ParserFunction)
// skip if this specific parser has an exception (nil or non-nil)
if _, hasException := observationExceptions[parserKey]; hasException {
continue
}
if observedParsers[packageName] == nil || !observedParsers[packageName].Has(parser.ParserFunction) {
missingParsers = append(missingParsers, parserKey)
}
}
}
}
require.Empty(t, missingCatalogers,
"The following custom catalogers have no test observations (not using pkgtest helpers): %v\n"+
"Update tests to use CatalogTester.TestCataloger() from syft/pkg/cataloger/internal/pkgtest",
missingCatalogers)
if requireParserObservations {
require.Empty(t, missingParsers,
"The following parsers have no test observations (not using pkgtest helpers): %v\n"+
"Update tests to use CatalogTester.TestParser() from syft/pkg/cataloger/internal/pkgtest",
missingParsers)
}
}
// extractPackageName extracts the package name from a cataloger name
// e.g., "javascript-lock-cataloger" -> "javascript"
func extractPackageName(catalogerName string) string {
// package name is the first segment before the first dash
for i, ch := range catalogerName {
if ch == '-' {
return catalogerName[:i]
}
}
return catalogerName
}
func TestConfigCompleteness(t *testing.T) {
repoRoot, err := RepoRoot()
require.NoError(t, err)
// load the packages.yaml
doc, _, err := loadCapabilities(filepath.Join(repoRoot, "internal/capabilities/packages.yaml"))
require.NoError(t, err)
// collect all validation errors before failing
var errors []string
// validation 1: all entries in configs section are referenced by at least one cataloger
configsReferenced := make(map[string]bool)
for _, cataloger := range doc.Catalogers {
if cataloger.Config != "" {
configsReferenced[cataloger.Config] = true
}
}
for configKey := range doc.Configs {
if !configsReferenced[configKey] {
errors = append(errors, fmt.Sprintf("Config %q is not referenced by any cataloger", configKey))
}
}
// validation 2: all catalogers with non-empty config field have entry in configs
for _, cataloger := range doc.Catalogers {
if cataloger.Config != "" {
if _, exists := doc.Configs[cataloger.Config]; !exists {
errors = append(errors, fmt.Sprintf("Cataloger %q references config %q which doesn't exist in configs section", cataloger.Name, cataloger.Config))
}
}
}
// validation 3: all app-key references in configs exist in app-config section
// build a set of all app-config keys for quick lookup
appConfigKeys := make(map[string]bool)
for _, appConfig := range doc.ApplicationConfig {
appConfigKeys[appConfig.Key] = true
}
for configName, configEntry := range doc.Configs {
for _, field := range configEntry.Fields {
if field.AppKey != "" {
if !appConfigKeys[field.AppKey] {
errors = append(errors, fmt.Sprintf("Config field %q.%s references app-key %q which doesn't exist in app-config section", configName, field.Key, field.AppKey))
}
}
}
}
// report all errors at once
if len(errors) > 0 {
require.Fail(t, "Config completeness validation failed", strings.Join(errors, "\n"))
}
}
func TestAppConfigFieldsHaveDescriptions(t *testing.T) {
repoRoot, err := RepoRoot()
require.NoError(t, err)
configs, err := DiscoverAppConfigs(repoRoot)
require.NoError(t, err)
// verify that all configs have descriptions
var missingDescriptions []string
for _, cfg := range configs {
if cfg.Description == "" {
missingDescriptions = append(missingDescriptions, cfg.Key)
}
}
require.Empty(t, missingDescriptions, "the following configs are missing descriptions: %v", missingDescriptions)
}
func TestAppConfigKeyFormat(t *testing.T) {
repoRoot, err := RepoRoot()
require.NoError(t, err)
configs, err := DiscoverAppConfigs(repoRoot)
require.NoError(t, err)
// verify that all config keys follow the expected format
for _, cfg := range configs {
// keys should be in format "ecosystem.field-name" or "ecosystem.nested.field-name"
require.Contains(t, cfg.Key, ".", "config key should contain at least one dot: %s", cfg.Key)
// keys should use kebab-case (all lowercase with hyphens)
require.NotContains(t, cfg.Key, "_", "config key should not contain underscores: %s", cfg.Key)
require.NotContains(t, cfg.Key, " ", "config key should not contain spaces: %s", cfg.Key)
}
}
// TestCapabilityConfigFieldReferences validates that config field names referenced in CapabilitiesV2
// conditions actually exist in the cataloger's config struct
func TestCapabilityConfigFieldReferences(t *testing.T) {
repoRoot, err := RepoRoot()
require.NoError(t, err)
// load the packages.yaml
doc, _, err := loadCapabilities(filepath.Join(repoRoot, "internal/capabilities/packages.yaml"))
require.NoError(t, err)
// collect all validation errors before failing
var errors []string
// for each cataloger with CapabilitiesV2
for _, cataloger := range doc.Catalogers {
// check cataloger-level CapabilitiesV2 (for custom catalogers)
if cataloger.Type == "custom" && len(cataloger.Capabilities) > 0 {
// load the cataloger's config struct if it has one
if cataloger.Config != "" {
configEntry, exists := doc.Configs[cataloger.Config]
if !exists {
errors = append(errors, fmt.Sprintf("Cataloger %q references config %q which doesn't exist", cataloger.Name, cataloger.Config))
continue
}
// build a set of valid config field names
validFields := make(map[string]bool)
for _, field := range configEntry.Fields {
validFields[field.Key] = true
}
// validate each capability field
for _, capField := range cataloger.Capabilities {
// check conditions for config field references
for _, condition := range capField.Conditions {
for fieldName := range condition.When {
if !validFields[fieldName] {
errors = append(errors,
fmt.Sprintf("Cataloger %q capability field %q references config field %q which doesn't exist in config struct %q",
cataloger.Name, capField.Name, fieldName, cataloger.Config))
}
}
}
}
} else if len(cataloger.Capabilities) > 0 {
// cataloger has CapabilitiesV2 with conditions but no config - check if any conditions reference fields
for _, capField := range cataloger.Capabilities {
if len(capField.Conditions) > 0 {
for _, condition := range capField.Conditions {
if len(condition.When) > 0 {
errors = append(errors,
fmt.Sprintf("Cataloger %q capability field %q has conditions but cataloger has no config struct",
cataloger.Name, capField.Name))
break
}
}
}
}
}
}
// check parser-level CapabilitiesV2 (for generic catalogers)
if cataloger.Type == "generic" {
for _, parser := range cataloger.Parsers {
if len(parser.Capabilities) > 0 {
// load the cataloger's config struct if it has one
if cataloger.Config != "" {
configEntry, exists := doc.Configs[cataloger.Config]
if !exists {
errors = append(errors, fmt.Sprintf("Cataloger %q references config %q which doesn't exist", cataloger.Name, cataloger.Config))
continue
}
// build a set of valid config field names
validFields := make(map[string]bool)
for _, field := range configEntry.Fields {
validFields[field.Key] = true
}
// validate each capability field
for _, capField := range parser.Capabilities {
// check conditions for config field references
for _, condition := range capField.Conditions {
for fieldName := range condition.When {
if !validFields[fieldName] {
errors = append(errors,
fmt.Sprintf("Parser %q/%s capability field %q references config field %q which doesn't exist in config struct %q",
cataloger.Name, parser.ParserFunction, capField.Name, fieldName, cataloger.Config))
}
}
}
}
} else {
// parser has CapabilitiesV2 with conditions but cataloger has no config
for _, capField := range parser.Capabilities {
if len(capField.Conditions) > 0 {
for _, condition := range capField.Conditions {
if len(condition.When) > 0 {
errors = append(errors,
fmt.Sprintf("Parser %q/%s capability field %q has conditions but cataloger has no config struct",
cataloger.Name, parser.ParserFunction, capField.Name))
break
}
}
}
}
}
}
}
}
}
// report all errors at once
if len(errors) > 0 {
require.Fail(t, "CapabilityV2 config field reference validation failed", strings.Join(errors, "\n"))
}
}
// TestCapabilityFieldNaming validates that capability field names follow known patterns
func TestCapabilityFieldNaming(t *testing.T) {
repoRoot, err := RepoRoot()
require.NoError(t, err)
// load the packages.yaml
doc, _, err := loadCapabilities(filepath.Join(repoRoot, "internal/capabilities/packages.yaml"))
require.NoError(t, err)
// define known capability field paths
knownFields := strset.New(
"license",
"dependency.depth",
"dependency.edges",
"dependency.kinds",
"package_manager.files.listing",
"package_manager.files.digests",
"package_manager.package_integrity_hash",
)
// collect all validation errors/warnings
var errors []string
// check cataloger-level CapabilitiesV2
for _, cataloger := range doc.Catalogers {
if cataloger.Type == "custom" && len(cataloger.Capabilities) > 0 {
for _, capField := range cataloger.Capabilities {
if !knownFields.Has(capField.Name) {
errors = append(errors,
fmt.Sprintf("Cataloger %q uses unknown capability field %q - may be a typo or new field not in known list",
cataloger.Name, capField.Name))
}
}
}
// check parser-level CapabilitiesV2
if cataloger.Type == "generic" {
for _, parser := range cataloger.Parsers {
if len(parser.Capabilities) > 0 {
for _, capField := range parser.Capabilities {
if !knownFields.Has(capField.Name) {
errors = append(errors,
fmt.Sprintf("Parser %q/%s uses unknown capability field %q - may be a typo or new field not in known list",
cataloger.Name, parser.ParserFunction, capField.Name))
}
}
}
}
}
}
// report all errors at once
if len(errors) > 0 {
require.Fail(t, "Capability field naming validation failed", strings.Join(errors, "\n"))
}
}
// TestCapabilityValueTypes validates that capability field values match expected types
func TestCapabilityValueTypes(t *testing.T) {
repoRoot, err := RepoRoot()
require.NoError(t, err)
// load the packages.yaml
doc, _, err := loadCapabilities(filepath.Join(repoRoot, "internal/capabilities/packages.yaml"))
require.NoError(t, err)
// collect all validation errors
var errors []string
// validate cataloger-level CapabilitiesV2
for _, cataloger := range doc.Catalogers {
if cataloger.Type == "custom" && len(cataloger.Capabilities) > 0 {
for _, capField := range cataloger.Capabilities {
// validate default value type
err := validateCapabilityValueType(capField.Name, capField.Default)
if err != nil {
errors = append(errors,
fmt.Sprintf("Cataloger %q capability field %q default value: %v",
cataloger.Name, capField.Name, err))
}
// validate condition value types
for i, condition := range capField.Conditions {
err := validateCapabilityValueType(capField.Name, condition.Value)
if err != nil {
errors = append(errors,
fmt.Sprintf("Cataloger %q capability field %q condition %d value: %v",
cataloger.Name, capField.Name, i, err))
}
}
}
}
// validate parser-level CapabilitiesV2
if cataloger.Type == "generic" {
for _, parser := range cataloger.Parsers {
if len(parser.Capabilities) > 0 {
for _, capField := range parser.Capabilities {
// validate default value type
err := validateCapabilityValueType(capField.Name, capField.Default)
if err != nil {
errors = append(errors,
fmt.Sprintf("Parser %q/%s capability field %q default value: %v",
cataloger.Name, parser.ParserFunction, capField.Name, err))
}
// validate condition value types
for i, condition := range capField.Conditions {
err := validateCapabilityValueType(capField.Name, condition.Value)
if err != nil {
errors = append(errors,
fmt.Sprintf("Parser %q/%s capability field %q condition %d value: %v",
cataloger.Name, parser.ParserFunction, capField.Name, i, err))
}
}
}
}
}
}
}
// report all errors at once
if len(errors) > 0 {
require.Fail(t, "Capability value type validation failed", strings.Join(errors, "\n"))
}
}
// validateCapabilityValueType checks if a value matches the expected type for a capability field
func validateCapabilityValueType(fieldPath string, value interface{}) error {
if value == nil {
return nil // nil is acceptable
}
switch fieldPath {
case "license",
"package_manager.files.listing",
"package_manager.files.digests",
"package_manager.package_integrity_hash":
// expect bool
if _, ok := value.(bool); !ok {
return fmt.Errorf("expected bool, got %T", value)
}
case "dependency.depth", "dependency.kinds":
// expect []string or []interface{} that can be converted to []string
switch v := value.(type) {
case []string:
// ok
case []interface{}:
// check each element is a string
for i, elem := range v {
if _, ok := elem.(string); !ok {
return fmt.Errorf("expected []string, but element %d is %T", i, elem)
}
}
default:
return fmt.Errorf("expected []string, got %T", value)
}
case "dependency.edges":
// expect string
if _, ok := value.(string); !ok {
return fmt.Errorf("expected string, got %T", value)
}
default:
// unknown field, skip validation
return nil
}
return nil
}
// loadConfigStructFields loads the config struct definition from source code using AST parsing
func loadConfigStructFields(repoRoot, configName string) (map[string]string, error) {
// configName format: "package.StructName" (e.g., "golang.CatalogerConfig")
parts := strings.Split(configName, ".")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid config name format: %q", configName)
}
packageName := parts[0]
structName := parts[1]
// find the package directory
packageDir := filepath.Join(repoRoot, "syft", "pkg", "cataloger", packageName)
if _, err := os.Stat(packageDir); os.IsNotExist(err) {
return nil, fmt.Errorf("package directory not found: %s", packageDir)
}
// parse all .go files in the package
files, err := filepath.Glob(filepath.Join(packageDir, "*.go"))
if err != nil {
return nil, err
}
for _, filePath := range files {
if strings.HasSuffix(filePath, "_test.go") {
continue
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, filePath, nil, 0)
if err != nil {
continue
}
// find the struct definition
fields := findStructFields(file, structName)
if len(fields) > 0 {
return fields, nil
}
}
return nil, fmt.Errorf("config struct %q not found in package %q", structName, packageName)
}
// findStructFields extracts field names and types from a struct definition
func findStructFields(file *ast.File, structName string) map[string]string {
fields := make(map[string]string)
ast.Inspect(file, func(n ast.Node) bool {
// look for type declarations
typeSpec, ok := n.(*ast.TypeSpec)
if !ok || typeSpec.Name.Name != structName {
return true
}
// check if it's a struct type
structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
return false
}
// extract field names and types
for _, field := range structType.Fields.List {
if len(field.Names) == 0 {
continue // embedded field
}
fieldName := field.Names[0].Name
fieldType := getTypeName(field.Type)
fields[fieldName] = fieldType
}
return false
})
return fields
}
// getTypeName extracts a string representation of a type
func getTypeName(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.Ident:
return t.Name
case *ast.SelectorExpr:
return fmt.Sprintf("%s.%s", getTypeName(t.X), t.Sel.Name)
case *ast.ArrayType:
return fmt.Sprintf("[]%s", getTypeName(t.Elt))
case *ast.MapType:
return fmt.Sprintf("map[%s]%s", getTypeName(t.Key), getTypeName(t.Value))
case *ast.StarExpr:
return fmt.Sprintf("*%s", getTypeName(t.X))
default:
return "unknown"
}
}