github.com/anchore/syft@v1.38.2/syft/pkg/cataloger/internal/pkgtest/test_generic_parser.go (about) 1 package pkgtest 2 3 import ( 4 "context" 5 "fmt" 6 "io" 7 "os" 8 "path/filepath" 9 "reflect" 10 "runtime" 11 "sort" 12 "strings" 13 "sync" 14 "testing" 15 16 "github.com/google/go-cmp/cmp" 17 "github.com/google/go-cmp/cmp/cmpopts" 18 "github.com/google/licensecheck" 19 "github.com/stretchr/testify/assert" 20 "github.com/stretchr/testify/require" 21 22 stereofile "github.com/anchore/stereoscope/pkg/file" 23 "github.com/anchore/stereoscope/pkg/imagetest" 24 "github.com/anchore/syft/internal/cmptest" 25 "github.com/anchore/syft/internal/licenses" 26 "github.com/anchore/syft/internal/relationship" 27 "github.com/anchore/syft/syft/artifact" 28 "github.com/anchore/syft/syft/file" 29 "github.com/anchore/syft/syft/internal/fileresolver" 30 "github.com/anchore/syft/syft/linux" 31 "github.com/anchore/syft/syft/pkg" 32 "github.com/anchore/syft/syft/pkg/cataloger/generic" 33 "github.com/anchore/syft/syft/source" 34 "github.com/anchore/syft/syft/source/directorysource" 35 "github.com/anchore/syft/syft/source/filesource" 36 "github.com/anchore/syft/syft/source/stereoscopesource" 37 ) 38 39 var ( 40 once sync.Once 41 licenseScanner *licenses.Scanner 42 ) 43 44 type CatalogTester struct { 45 expectedPkgs []pkg.Package 46 expectedRelationships []artifact.Relationship 47 assertResultExpectations bool 48 expectedPathResponses []string // this is a minimum set, the resolver may return more that just this list 49 expectedContentQueries []string // this is a full set, any other queries are unexpected (and will fail the test) 50 ignoreUnfulfilledPathResponses map[string][]string 51 ignoreAnyUnfulfilledPaths []string 52 env *generic.Environment 53 reader file.LocationReadCloser 54 resolver file.Resolver 55 wantErr require.ErrorAssertionFunc 56 compareOptions []cmp.Option 57 locationComparer cmptest.LocationComparer 58 licenseComparer cmptest.LicenseComparer 59 packageStringer func(pkg.Package) string 60 customAssertions []func(t *testing.T, pkgs []pkg.Package, relationships []artifact.Relationship) 61 context context.Context 62 skipTestObservations bool 63 } 64 65 func Context() context.Context { 66 once.Do(func() { 67 // most of the time in testing is initializing the scanner. Let's do that just once 68 sc := &licenses.ScannerConfig{Scanner: licensecheck.Scan, CoverageThreshold: 75} 69 scanner, err := licenses.NewScanner(sc) 70 if err != nil { 71 panic("unable to setup licences scanner for testing") 72 } 73 licenseScanner = &scanner 74 }) 75 76 return licenses.SetContextLicenseScanner(context.Background(), *licenseScanner) 77 } 78 79 func NewCatalogTester() *CatalogTester { 80 return &CatalogTester{ 81 context: Context(), 82 locationComparer: cmptest.DefaultLocationComparer, 83 licenseComparer: cmptest.DefaultLicenseComparer, 84 packageStringer: stringPackage, 85 resolver: fileresolver.Empty{}, 86 ignoreUnfulfilledPathResponses: map[string][]string{ 87 "FilesByPath": { 88 // most catalogers search for a linux release, which will not be fulfilled in testing 89 "/etc/os-release", 90 "/usr/lib/os-release", 91 "/etc/system-release-cpe", 92 "/etc/redhat-release", 93 "/bin/busybox", 94 }, 95 }, 96 } 97 } 98 99 func (p *CatalogTester) WithContext(ctx context.Context) *CatalogTester { 100 p.context = ctx 101 return p 102 } 103 104 func (p *CatalogTester) FromDirectory(t *testing.T, path string) *CatalogTester { 105 t.Helper() 106 107 if path == "" { 108 return p 109 } 110 111 s, err := directorysource.NewFromPath(path) 112 require.NoError(t, err) 113 114 resolver, err := s.FileResolver(source.AllLayersScope) 115 require.NoError(t, err) 116 117 p.resolver = resolver 118 return p 119 } 120 121 func (p *CatalogTester) FromFileSource(t *testing.T, path string) *CatalogTester { 122 t.Helper() 123 124 s, err := filesource.NewFromPath(path) 125 require.NoError(t, err) 126 resolver, err := s.FileResolver(source.AllLayersScope) 127 require.NoError(t, err) 128 129 p.resolver = resolver 130 return p 131 } 132 133 func (p *CatalogTester) FromFile(t *testing.T, path string) *CatalogTester { 134 t.Helper() 135 136 if path == "" { 137 return p 138 } 139 140 absPath, err := filepath.Abs(path) 141 require.NoError(t, err) 142 143 fixture, err := os.Open(path) 144 require.NoError(t, err) 145 146 p.reader = file.LocationReadCloser{ 147 Location: file.NewVirtualLocationFromDirectory(fixture.Name(), fixture.Name(), *stereofile.NewFileReference(stereofile.Path(absPath))), 148 ReadCloser: fixture, 149 } 150 return p 151 } 152 153 func (p *CatalogTester) FromString(location, data string) *CatalogTester { 154 p.reader = file.LocationReadCloser{ 155 Location: file.NewLocation(location), 156 ReadCloser: io.NopCloser(strings.NewReader(data)), 157 } 158 return p 159 } 160 161 func (p *CatalogTester) WithLinuxRelease(r linux.Release) *CatalogTester { 162 if p.env == nil { 163 p.env = &generic.Environment{} 164 } 165 p.env.LinuxRelease = &r 166 return p 167 } 168 169 func (p *CatalogTester) WithEnv(env *generic.Environment) *CatalogTester { 170 p.env = env 171 return p 172 } 173 174 func (p *CatalogTester) WithError() *CatalogTester { 175 p.wantErr = require.Error 176 return p 177 } 178 179 func (p *CatalogTester) WithErrorAssertion(a require.ErrorAssertionFunc) *CatalogTester { 180 p.wantErr = a 181 return p 182 } 183 184 func (p *CatalogTester) WithResolver(r file.Resolver) *CatalogTester { 185 p.resolver = r 186 return p 187 } 188 189 func (p *CatalogTester) WithImageResolver(t *testing.T, fixtureName string) *CatalogTester { 190 t.Helper() 191 192 if fixtureName == "" { 193 return p 194 } 195 196 img := imagetest.GetFixtureImage(t, "docker-archive", fixtureName) 197 198 s := stereoscopesource.New(img, stereoscopesource.ImageConfig{ 199 Reference: fixtureName, 200 }) 201 202 r, err := s.FileResolver(source.SquashedScope) 203 require.NoError(t, err) 204 p.resolver = r 205 return p 206 } 207 208 func (p *CatalogTester) ExpectsAssertion(a func(t *testing.T, pkgs []pkg.Package, relationships []artifact.Relationship)) *CatalogTester { 209 p.customAssertions = append(p.customAssertions, a) 210 return p 211 } 212 213 func (p *CatalogTester) IgnoreLocationLayer() *CatalogTester { 214 p.locationComparer = cmptest.LocationComparerWithoutLayer 215 p.licenseComparer = cmptest.LicenseComparerWithoutLocationLayer 216 return p 217 } 218 219 func (p *CatalogTester) IgnorePackageFields(fields ...string) *CatalogTester { 220 p.compareOptions = append(p.compareOptions, cmpopts.IgnoreFields(pkg.Package{}, fields...)) 221 return p 222 } 223 224 func (p *CatalogTester) WithCompareOptions(opts ...cmp.Option) *CatalogTester { 225 p.compareOptions = append(p.compareOptions, opts...) 226 return p 227 } 228 229 func (p *CatalogTester) Expects(pkgs []pkg.Package, relationships []artifact.Relationship) *CatalogTester { 230 p.assertResultExpectations = true 231 p.expectedPkgs = pkgs 232 p.expectedRelationships = relationships 233 return p 234 } 235 236 func (p *CatalogTester) WithPackageStringer(fn func(pkg.Package) string) *CatalogTester { 237 p.packageStringer = fn 238 return p 239 } 240 241 func (p *CatalogTester) ExpectsPackageStrings(expected []string) *CatalogTester { 242 return p.ExpectsAssertion(func(t *testing.T, pkgs []pkg.Package, _ []artifact.Relationship) { 243 diffPackages(t, expected, pkgs, p.packageStringer) 244 }) 245 } 246 247 func (p *CatalogTester) ExpectsRelationshipStrings(expected []string) *CatalogTester { 248 return p.ExpectsAssertion(func(t *testing.T, pkgs []pkg.Package, relationships []artifact.Relationship) { 249 diffRelationships(t, expected, relationships, pkgs, p.packageStringer) 250 }) 251 } 252 253 func (p *CatalogTester) ExpectsResolverPathResponses(locations []string) *CatalogTester { 254 p.expectedPathResponses = locations 255 return p 256 } 257 258 func (p *CatalogTester) ExpectsResolverContentQueries(locations []string) *CatalogTester { 259 p.expectedContentQueries = locations 260 return p 261 } 262 263 func (p *CatalogTester) IgnoreUnfulfilledPathResponses(paths ...string) *CatalogTester { 264 p.ignoreAnyUnfulfilledPaths = append(p.ignoreAnyUnfulfilledPaths, paths...) 265 return p 266 } 267 268 func (p *CatalogTester) WithoutTestObserver() *CatalogTester { 269 p.skipTestObservations = true 270 return p 271 } 272 273 func (p *CatalogTester) TestParser(t *testing.T, parser generic.Parser) { 274 t.Helper() 275 pkgs, relationships, err := parser(p.context, p.resolver, p.env, p.reader) 276 277 // only test for errors if explicitly requested 278 if p.wantErr != nil { 279 p.wantErr(t, err) 280 } 281 282 // track metadata types for cataloger discovery 283 p.trackParserMetadata(t, parser, pkgs, relationships) 284 285 p.assertPkgs(t, pkgs, relationships) 286 } 287 288 func (p *CatalogTester) TestCataloger(t *testing.T, cataloger pkg.Cataloger) { 289 t.Helper() 290 291 resolver := NewObservingResolver(p.resolver) 292 293 pkgs, relationships, err := cataloger.Catalog(p.context, resolver) 294 295 // this is a minimum set, the resolver may return more that just this list 296 for _, path := range p.expectedPathResponses { 297 assert.Truef(t, resolver.ObservedPathResponses(path), "expected path query for %q was not observed", path) 298 } 299 300 // this is a full set, any other queries are unexpected (and will fail the test) 301 if len(p.expectedContentQueries) > 0 { 302 assert.ElementsMatchf(t, p.expectedContentQueries, resolver.AllContentQueries(), "unexpected content queries observed: diff %s", cmp.Diff(p.expectedContentQueries, resolver.AllContentQueries())) 303 } 304 305 // only test for errors if explicitly requested 306 if p.wantErr != nil { 307 p.wantErr(t, err) 308 } 309 310 // track metadata types for cataloger discovery 311 p.trackCatalogerMetadata(t, cataloger, pkgs, relationships) 312 313 if p.assertResultExpectations { 314 p.assertPkgs(t, pkgs, relationships) 315 } 316 317 for _, a := range p.customAssertions { 318 a(t, pkgs, relationships) 319 } 320 321 if !p.assertResultExpectations && len(p.customAssertions) == 0 && p.wantErr == nil { 322 resolver.PruneUnfulfilledPathResponses(p.ignoreUnfulfilledPathResponses, p.ignoreAnyUnfulfilledPaths...) 323 324 // if we aren't testing the results, we should focus on what was searched for (for glob-centric tests) 325 assert.Falsef(t, resolver.HasUnfulfilledPathRequests(), "unfulfilled path requests: \n%v", resolver.PrettyUnfulfilledPathRequests()) 326 } 327 } 328 329 func (p *CatalogTester) assertPkgs(t *testing.T, pkgs []pkg.Package, relationships []artifact.Relationship) { 330 t.Helper() 331 332 p.compareOptions = append(p.compareOptions, cmptest.BuildOptions(p.licenseComparer, p.locationComparer)...) 333 334 { 335 r := cmptest.NewDiffReporter() 336 var opts []cmp.Option 337 338 opts = append(opts, p.compareOptions...) 339 opts = append(opts, cmp.Reporter(&r)) 340 341 // order should not matter 342 pkg.Sort(p.expectedPkgs) 343 pkg.Sort(pkgs) 344 345 if diff := cmp.Diff(p.expectedPkgs, pkgs, opts...); diff != "" { 346 t.Log("Specific Differences:\n" + r.String()) 347 t.Errorf("unexpected packages from parsing (-expected +actual)\n%s", diff) 348 } 349 } 350 { 351 r := cmptest.NewDiffReporter() 352 var opts []cmp.Option 353 354 opts = append(opts, p.compareOptions...) 355 opts = append(opts, cmp.Reporter(&r)) 356 357 // ignore the "FoundBy" field on relationships as it is set in the generic cataloger before it's presence on the relationship 358 opts = append(opts, cmpopts.IgnoreFields(pkg.Package{}, "FoundBy")) 359 360 // order should not matter 361 relationship.Sort(p.expectedRelationships) 362 relationship.Sort(relationships) 363 364 if diff := cmp.Diff(p.expectedRelationships, relationships, opts...); diff != "" { 365 t.Log("Specific Differences:\n" + r.String()) 366 367 t.Errorf("unexpected relationships from parsing (-expected +actual)\n%s", diff) 368 } 369 } 370 } 371 372 func TestFileParser(t *testing.T, fixturePath string, parser generic.Parser, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) { 373 t.Helper() 374 NewCatalogTester().FromFile(t, fixturePath).Expects(expectedPkgs, expectedRelationships).TestParser(t, parser) 375 } 376 377 func TestCataloger(t *testing.T, fixtureDir string, cataloger pkg.Cataloger, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) { 378 t.Helper() 379 NewCatalogTester().FromDirectory(t, fixtureDir).Expects(expectedPkgs, expectedRelationships).TestCataloger(t, cataloger) 380 } 381 382 func TestCatalogerFromFileSource(t *testing.T, fixturePath string, cataloger pkg.Cataloger, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) { 383 t.Helper() 384 NewCatalogTester().FromFileSource(t, fixturePath).Expects(expectedPkgs, expectedRelationships).TestCataloger(t, cataloger) 385 } 386 387 func TestFileParserWithEnv(t *testing.T, fixturePath string, parser generic.Parser, env *generic.Environment, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) { 388 t.Helper() 389 390 NewCatalogTester().FromFile(t, fixturePath).WithEnv(env).Expects(expectedPkgs, expectedRelationships).TestParser(t, parser) 391 } 392 393 func AssertPackagesEqual(t *testing.T, a, b pkg.Package, userOpts ...cmp.Option) { 394 t.Helper() 395 opts := cmptest.DefaultOptions() 396 opts = append(opts, userOpts...) 397 398 if diff := cmp.Diff(a, b, opts...); diff != "" { 399 t.Errorf("unexpected packages from parsing (-expected +actual)\n%s", diff) 400 } 401 } 402 403 func AssertPackagesEqualIgnoreLayers(t *testing.T, a, b pkg.Package, userOpts ...cmp.Option) { 404 t.Helper() 405 opts := cmptest.DefaultIgnoreLocationLayerOptions() 406 opts = append(opts, userOpts...) 407 408 if diff := cmp.Diff(a, b, opts...); diff != "" { 409 t.Errorf("unexpected packages from parsing (-expected +actual)\n%s", diff) 410 } 411 } 412 413 func diffPackages(t *testing.T, expected []string, actual []pkg.Package, pkgStringer func(pkg.Package) string) { 414 t.Helper() 415 sort.Strings(expected) 416 if d := cmp.Diff(expected, stringPackages(actual, pkgStringer)); d != "" { 417 t.Errorf("unexpected package strings (-want, +got): %s", d) 418 } 419 } 420 421 func diffRelationships(t *testing.T, expected []string, actual []artifact.Relationship, pkgs []pkg.Package, pkgStringer func(pkg.Package) string) { 422 t.Helper() 423 pkgsByID := make(map[artifact.ID]pkg.Package) 424 for _, p := range pkgs { 425 pkgsByID[p.ID()] = p 426 } 427 sort.Strings(expected) 428 if d := cmp.Diff(expected, stringRelationships(actual, pkgsByID, pkgStringer)); d != "" { 429 t.Errorf("unexpected relationship strings (-want, +got): %s", d) 430 } 431 } 432 433 func stringRelationships(relationships []artifact.Relationship, nameLookup map[artifact.ID]pkg.Package, pkgStringer func(pkg.Package) string) []string { 434 var result []string 435 for _, r := range relationships { 436 var fromName, toName string 437 { 438 fromPkg, ok := nameLookup[r.From.ID()] 439 if !ok { 440 fromName = string(r.From.ID()) 441 } else { 442 fromName = pkgStringer(fromPkg) 443 } 444 } 445 446 { 447 toPkg, ok := nameLookup[r.To.ID()] 448 if !ok { 449 toName = string(r.To.ID()) 450 } else { 451 toName = pkgStringer(toPkg) 452 } 453 } 454 455 result = append(result, fromName+" ["+string(r.Type)+"] "+toName) 456 } 457 sort.Strings(result) 458 return result 459 } 460 461 func stringPackages(pkgs []pkg.Package, pkgStringer func(pkg.Package) string) []string { 462 var result []string 463 for _, p := range pkgs { 464 result = append(result, pkgStringer(p)) 465 } 466 sort.Strings(result) 467 return result 468 } 469 470 func stringPackage(p pkg.Package) string { 471 locs := p.Locations.ToSlice() 472 var loc string 473 if len(locs) > 0 { 474 loc = p.Locations.ToSlice()[0].RealPath 475 } 476 477 return fmt.Sprintf("%s @ %s (%s)", p.Name, p.Version, loc) 478 } 479 480 // getFunctionName extracts the function name from a function pointer using reflection 481 func getFunctionName(fn interface{}) string { 482 // get the function pointer 483 ptr := reflect.ValueOf(fn).Pointer() 484 485 // get the function details 486 funcForPC := runtime.FuncForPC(ptr) 487 if funcForPC == nil { 488 return "" 489 } 490 491 fullName := funcForPC.Name() 492 493 // extract just the function name from the full path 494 // e.g., "github.com/anchore/syft/syft/pkg/cataloger/python.parseRequirementsTxt" 495 // -> "parseRequirementsTxt" 496 parts := strings.Split(fullName, ".") 497 if len(parts) > 0 { 498 name := parts[len(parts)-1] 499 // strip the -fm suffix that Go's reflection adds for methods 500 // e.g., "parsePackageLock-fm" -> "parsePackageLock" 501 return strings.TrimSuffix(name, "-fm") 502 } 503 504 return fullName 505 } 506 507 // getCatalogerName extracts the cataloger name from the test context or cataloger name 508 func getCatalogerName(_ *testing.T, cataloger pkg.Cataloger) string { 509 // use the cataloger's name method if available 510 return cataloger.Name() 511 } 512 513 // getPackagePath extracts the package path from a function name 514 // e.g., "github.com/anchore/syft/syft/pkg/cataloger/python.parseRequirementsTxt" -> "python" 515 func getPackagePath(fn interface{}) string { 516 ptr := reflect.ValueOf(fn).Pointer() 517 funcForPC := runtime.FuncForPC(ptr) 518 if funcForPC == nil { 519 return "" 520 } 521 522 fullName := funcForPC.Name() 523 524 // extract package name from path 525 // e.g., "github.com/anchore/syft/syft/pkg/cataloger/python.parseRequirementsTxt" 526 // -> "python" 527 if strings.Contains(fullName, "/cataloger/") { 528 parts := strings.Split(fullName, "/cataloger/") 529 if len(parts) > 1 { 530 // get the next segment after "/cataloger/" 531 remaining := parts[1] 532 // split by "." to get package name 533 pkgParts := strings.Split(remaining, ".") 534 if len(pkgParts) > 0 { 535 return pkgParts[0] 536 } 537 } 538 } 539 540 return "" 541 } 542 543 // getPackagePathFromCataloger extracts the package path from the caller's file path 544 // For generic catalogers, the cataloger type is from the generic package, but we need 545 // the package where the test is defined (e.g., rust, python, etc.) 546 func getPackagePathFromCataloger(_ pkg.Cataloger) string { 547 // walk up the call stack to find the test file 548 // we're looking for a file in the cataloger directory structure 549 for i := 0; i < 10; i++ { 550 _, file, _, ok := runtime.Caller(i) 551 if !ok { 552 break 553 } 554 555 // extract package name from file path 556 // e.g., "/Users/.../syft/pkg/cataloger/rust/cataloger_test.go" -> "rust" 557 if strings.Contains(file, "/cataloger/") { 558 parts := strings.Split(file, "/cataloger/") 559 if len(parts) > 1 { 560 // get the next segment after "/cataloger/" 561 remaining := parts[1] 562 // split by "/" to get package name 563 pkgParts := strings.Split(remaining, "/") 564 if len(pkgParts) > 0 && pkgParts[0] != "internal" { 565 return pkgParts[0] 566 } 567 } 568 } 569 } 570 571 return "" 572 } 573 574 // trackParserMetadata records metadata types for a parser function 575 func (p *CatalogTester) trackParserMetadata(t *testing.T, parser generic.Parser, pkgs []pkg.Package, relationships []artifact.Relationship) { 576 if p.skipTestObservations { 577 return 578 } 579 580 parserName := getFunctionName(parser) 581 if parserName == "" { 582 return 583 } 584 585 // try to infer package name from function path 586 packageName := getPackagePath(parser) 587 if packageName == "" { 588 return 589 } 590 591 tracker := getTracker() 592 593 // old tracking (still used by metadata discovery) 594 for _, pkg := range pkgs { 595 tracker.RecordParserPackageMetadata(packageName, parserName, pkg) 596 } 597 598 // new unified observations with capability tracking 599 tracker.RecordParserObservations(packageName, parserName, pkgs, relationships) 600 601 // ensure results are written when tests complete 602 t.Cleanup(func() { 603 _ = WriteResultsIfEnabled() 604 }) 605 } 606 607 // trackCatalogerMetadata records metadata types for a cataloger 608 func (p *CatalogTester) trackCatalogerMetadata(t *testing.T, cataloger pkg.Cataloger, pkgs []pkg.Package, relationships []artifact.Relationship) { 609 if p.skipTestObservations { 610 return 611 } 612 613 catalogerName := getCatalogerName(t, cataloger) 614 if catalogerName == "" { 615 return 616 } 617 618 // try to infer package name from cataloger type 619 packageName := getPackagePathFromCataloger(cataloger) 620 if packageName == "" { 621 return 622 } 623 624 tracker := getTracker() 625 626 // old tracking (still used by metadata discovery) 627 for _, pkg := range pkgs { 628 tracker.RecordCatalogerPackageMetadata(catalogerName, pkg) 629 } 630 631 // new unified observations with capability tracking 632 tracker.RecordCatalogerObservations(packageName, catalogerName, pkgs, relationships) 633 634 // ensure results are written when tests complete 635 t.Cleanup(func() { 636 _ = WriteResultsIfEnabled() 637 }) 638 }