github.com/noqcks/syft@v0.0.0-20230920222752-a9e2c4e288e5/syft/pkg/cataloger/internal/pkgtest/test_generic_parser.go (about) 1 package pkgtest 2 3 import ( 4 "fmt" 5 "io" 6 "os" 7 "strings" 8 "testing" 9 10 "github.com/google/go-cmp/cmp" 11 "github.com/google/go-cmp/cmp/cmpopts" 12 "github.com/stretchr/testify/assert" 13 "github.com/stretchr/testify/require" 14 15 "github.com/anchore/stereoscope/pkg/imagetest" 16 "github.com/anchore/syft/syft/artifact" 17 "github.com/anchore/syft/syft/file" 18 "github.com/anchore/syft/syft/linux" 19 "github.com/anchore/syft/syft/pkg" 20 "github.com/anchore/syft/syft/pkg/cataloger/generic" 21 "github.com/anchore/syft/syft/source" 22 ) 23 24 type locationComparer func(x, y file.Location) bool 25 type licenseComparer func(x, y pkg.License) bool 26 27 type CatalogTester struct { 28 expectedPkgs []pkg.Package 29 expectedRelationships []artifact.Relationship 30 assertResultExpectations bool 31 expectedPathResponses []string // this is a minimum set, the resolver may return more that just this list 32 expectedContentQueries []string // this is a full set, any other queries are unexpected (and will fail the test) 33 ignoreUnfulfilledPathResponses map[string][]string 34 ignoreAnyUnfulfilledPaths []string 35 env *generic.Environment 36 reader file.LocationReadCloser 37 resolver file.Resolver 38 wantErr require.ErrorAssertionFunc 39 compareOptions []cmp.Option 40 locationComparer locationComparer 41 licenseComparer licenseComparer 42 } 43 44 func NewCatalogTester() *CatalogTester { 45 return &CatalogTester{ 46 wantErr: require.NoError, 47 locationComparer: DefaultLocationComparer, 48 licenseComparer: DefaultLicenseComparer, 49 ignoreUnfulfilledPathResponses: map[string][]string{ 50 "FilesByPath": { 51 // most catalogers search for a linux release, which will not be fulfilled in testing 52 "/etc/os-release", 53 "/usr/lib/os-release", 54 "/etc/system-release-cpe", 55 "/etc/redhat-release", 56 "/bin/busybox", 57 }, 58 }, 59 } 60 } 61 62 func DefaultLocationComparer(x, y file.Location) bool { 63 return cmp.Equal(x.Coordinates, y.Coordinates) && cmp.Equal(x.VirtualPath, y.VirtualPath) 64 } 65 66 func DefaultLicenseComparer(x, y pkg.License) bool { 67 return cmp.Equal(x, y, cmp.Comparer(DefaultLocationComparer), cmp.Comparer( 68 func(x, y file.LocationSet) bool { 69 xs := x.ToSlice() 70 ys := y.ToSlice() 71 if len(xs) != len(ys) { 72 return false 73 } 74 for i, xe := range xs { 75 ye := ys[i] 76 if !DefaultLocationComparer(xe, ye) { 77 return false 78 } 79 } 80 return true 81 }, 82 )) 83 } 84 85 func (p *CatalogTester) FromDirectory(t *testing.T, path string) *CatalogTester { 86 t.Helper() 87 88 s, err := source.NewFromDirectoryPath(path) 89 require.NoError(t, err) 90 91 resolver, err := s.FileResolver(source.AllLayersScope) 92 require.NoError(t, err) 93 94 p.resolver = resolver 95 return p 96 } 97 98 func (p *CatalogTester) FromFile(t *testing.T, path string) *CatalogTester { 99 t.Helper() 100 101 fixture, err := os.Open(path) 102 require.NoError(t, err) 103 104 p.reader = file.LocationReadCloser{ 105 Location: file.NewLocation(fixture.Name()), 106 ReadCloser: fixture, 107 } 108 return p 109 } 110 111 func (p *CatalogTester) FromString(location, data string) *CatalogTester { 112 p.reader = file.LocationReadCloser{ 113 Location: file.NewLocation(location), 114 ReadCloser: io.NopCloser(strings.NewReader(data)), 115 } 116 return p 117 } 118 119 func (p *CatalogTester) WithLinuxRelease(r linux.Release) *CatalogTester { 120 if p.env == nil { 121 p.env = &generic.Environment{} 122 } 123 p.env.LinuxRelease = &r 124 return p 125 } 126 127 func (p *CatalogTester) WithEnv(env *generic.Environment) *CatalogTester { 128 p.env = env 129 return p 130 } 131 132 func (p *CatalogTester) WithError() *CatalogTester { 133 p.assertResultExpectations = true 134 p.wantErr = require.Error 135 return p 136 } 137 138 func (p *CatalogTester) WithErrorAssertion(a require.ErrorAssertionFunc) *CatalogTester { 139 p.wantErr = a 140 return p 141 } 142 143 func (p *CatalogTester) WithResolver(r file.Resolver) *CatalogTester { 144 p.resolver = r 145 return p 146 } 147 148 func (p *CatalogTester) WithImageResolver(t *testing.T, fixtureName string) *CatalogTester { 149 t.Helper() 150 img := imagetest.GetFixtureImage(t, "docker-archive", fixtureName) 151 152 s, err := source.NewFromStereoscopeImageObject(img, fixtureName, nil) 153 require.NoError(t, err) 154 155 r, err := s.FileResolver(source.SquashedScope) 156 require.NoError(t, err) 157 p.resolver = r 158 return p 159 } 160 161 func (p *CatalogTester) IgnoreLocationLayer() *CatalogTester { 162 p.locationComparer = func(x, y file.Location) bool { 163 return cmp.Equal(x.Coordinates.RealPath, y.Coordinates.RealPath) && cmp.Equal(x.VirtualPath, y.VirtualPath) 164 } 165 166 // we need to update the license comparer to use the ignored location layer 167 p.licenseComparer = func(x, y pkg.License) bool { 168 return cmp.Equal(x, y, cmp.Comparer(p.locationComparer), cmp.Comparer( 169 func(x, y file.LocationSet) bool { 170 xs := x.ToSlice() 171 ys := y.ToSlice() 172 if len(xs) != len(ys) { 173 return false 174 } 175 for i, xe := range xs { 176 ye := ys[i] 177 if !p.locationComparer(xe, ye) { 178 return false 179 } 180 } 181 182 return true 183 })) 184 } 185 return p 186 } 187 188 func (p *CatalogTester) IgnorePackageFields(fields ...string) *CatalogTester { 189 p.compareOptions = append(p.compareOptions, cmpopts.IgnoreFields(pkg.Package{}, fields...)) 190 return p 191 } 192 193 func (p *CatalogTester) WithCompareOptions(opts ...cmp.Option) *CatalogTester { 194 p.compareOptions = append(p.compareOptions, opts...) 195 return p 196 } 197 198 func (p *CatalogTester) Expects(pkgs []pkg.Package, relationships []artifact.Relationship) *CatalogTester { 199 p.assertResultExpectations = true 200 p.expectedPkgs = pkgs 201 p.expectedRelationships = relationships 202 return p 203 } 204 205 func (p *CatalogTester) ExpectsResolverPathResponses(locations []string) *CatalogTester { 206 p.expectedPathResponses = locations 207 return p 208 } 209 210 func (p *CatalogTester) ExpectsResolverContentQueries(locations []string) *CatalogTester { 211 p.expectedContentQueries = locations 212 return p 213 } 214 215 func (p *CatalogTester) IgnoreUnfulfilledPathResponses(paths ...string) *CatalogTester { 216 p.ignoreAnyUnfulfilledPaths = append(p.ignoreAnyUnfulfilledPaths, paths...) 217 return p 218 } 219 220 func (p *CatalogTester) TestParser(t *testing.T, parser generic.Parser) { 221 t.Helper() 222 pkgs, relationships, err := parser(p.resolver, p.env, p.reader) 223 p.wantErr(t, err) 224 p.assertPkgs(t, pkgs, relationships) 225 } 226 227 func (p *CatalogTester) TestGroupedCataloger(t *testing.T, cataloger pkg.Cataloger) { 228 t.Helper() 229 230 resolver := NewObservingResolver(p.resolver) 231 232 pkgs, relationships, err := cataloger.Catalog(resolver) 233 234 // this is a minimum set, the resolver may return more than just this list 235 for _, path := range p.expectedPathResponses { 236 assert.Truef(t, resolver.ObservedPathResponses(path), "expected path query for %q was not observed", path) 237 } 238 239 // this is a full set, any other queries are unexpected (and will fail the test) 240 if len(p.expectedContentQueries) > 0 { 241 assert.ElementsMatchf(t, p.expectedContentQueries, resolver.AllContentQueries(), "unexpected content queries observed: diff %s", cmp.Diff(p.expectedContentQueries, resolver.AllContentQueries())) 242 } 243 244 if p.assertResultExpectations { 245 p.wantErr(t, err) 246 p.assertPkgs(t, pkgs, relationships) 247 } else { 248 resolver.PruneUnfulfilledPathResponses(p.ignoreUnfulfilledPathResponses, p.ignoreAnyUnfulfilledPaths...) 249 250 // if we aren't testing the results, we should focus on what was searched for (for glob-centric tests) 251 assert.Falsef(t, resolver.HasUnfulfilledPathRequests(), "unfulfilled path requests: \n%v", resolver.PrettyUnfulfilledPathRequests()) 252 } 253 } 254 255 func (p *CatalogTester) TestCataloger(t *testing.T, cataloger pkg.Cataloger) { 256 t.Helper() 257 258 resolver := NewObservingResolver(p.resolver) 259 260 pkgs, relationships, err := cataloger.Catalog(resolver) 261 262 // this is a minimum set, the resolver may return more that just this list 263 for _, path := range p.expectedPathResponses { 264 assert.Truef(t, resolver.ObservedPathResponses(path), "expected path query for %q was not observed", path) 265 } 266 267 // this is a full set, any other queries are unexpected (and will fail the test) 268 if len(p.expectedContentQueries) > 0 { 269 assert.ElementsMatchf(t, p.expectedContentQueries, resolver.AllContentQueries(), "unexpected content queries observed: diff %s", cmp.Diff(p.expectedContentQueries, resolver.AllContentQueries())) 270 } 271 272 if p.assertResultExpectations { 273 p.wantErr(t, err) 274 p.assertPkgs(t, pkgs, relationships) 275 } else { 276 resolver.PruneUnfulfilledPathResponses(p.ignoreUnfulfilledPathResponses, p.ignoreAnyUnfulfilledPaths...) 277 278 // if we aren't testing the results, we should focus on what was searched for (for glob-centric tests) 279 assert.Falsef(t, resolver.HasUnfulfilledPathRequests(), "unfulfilled path requests: \n%v", resolver.PrettyUnfulfilledPathRequests()) 280 } 281 } 282 283 // nolint:funlen 284 func (p *CatalogTester) assertPkgs(t *testing.T, pkgs []pkg.Package, relationships []artifact.Relationship) { 285 t.Helper() 286 287 p.compareOptions = append(p.compareOptions, 288 cmpopts.IgnoreFields(pkg.Package{}, "id"), // note: ID is not deterministic for test purposes 289 cmpopts.IgnoreFields(pkg.Package{}, "FoundBy"), 290 cmpopts.SortSlices(pkg.Less), 291 cmp.Comparer( 292 func(x, y file.LocationSet) bool { 293 xs := x.ToSlice() 294 ys := y.ToSlice() 295 296 if len(xs) != len(ys) { 297 return false 298 } 299 for i, xe := range xs { 300 ye := ys[i] 301 if !p.locationComparer(xe, ye) { 302 return false 303 } 304 } 305 306 return true 307 }, 308 ), 309 cmp.Comparer( 310 func(x, y pkg.LicenseSet) bool { 311 xs := x.ToSlice() 312 ys := y.ToSlice() 313 314 if len(xs) != len(ys) { 315 return false 316 } 317 for i, xe := range xs { 318 ye := ys[i] 319 if !p.licenseComparer(xe, ye) { 320 return false 321 } 322 } 323 324 return true 325 }, 326 ), 327 cmp.Comparer( 328 p.locationComparer, 329 ), 330 cmp.Comparer( 331 p.licenseComparer, 332 ), 333 ) 334 335 { 336 var r diffReporter 337 var opts []cmp.Option 338 339 opts = append(opts, p.compareOptions...) 340 opts = append(opts, cmp.Reporter(&r)) 341 342 for _, pkg := range pkgs { 343 t.Logf("pkg: %+v", pkg) 344 } 345 for _, expectedPkg := range p.expectedPkgs { 346 t.Logf("expectedPkg: %+v", expectedPkg) 347 } 348 349 if diff := cmp.Diff(p.expectedPkgs, pkgs, opts...); diff != "" { 350 t.Log("Specific Differences:\n" + r.String()) 351 t.Errorf("unexpected packages from parsing (-expected +actual)\n%s", diff) 352 } 353 } 354 { 355 var r diffReporter 356 var opts []cmp.Option 357 358 opts = append(opts, p.compareOptions...) 359 opts = append(opts, cmp.Reporter(&r)) 360 361 if diff := cmp.Diff(p.expectedRelationships, relationships, opts...); diff != "" { 362 t.Log("Specific Differences:\n" + r.String()) 363 364 t.Errorf("unexpected relationships from parsing (-expected +actual)\n%s", diff) 365 } 366 } 367 } 368 369 func TestFileParser(t *testing.T, fixturePath string, parser generic.Parser, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) { 370 t.Helper() 371 NewCatalogTester().FromFile(t, fixturePath).Expects(expectedPkgs, expectedRelationships).TestParser(t, parser) 372 } 373 374 func TestFileParserWithEnv(t *testing.T, fixturePath string, parser generic.Parser, env *generic.Environment, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) { 375 t.Helper() 376 377 NewCatalogTester().FromFile(t, fixturePath).WithEnv(env).Expects(expectedPkgs, expectedRelationships).TestParser(t, parser) 378 } 379 380 func AssertPackagesEqual(t *testing.T, a, b pkg.Package) { 381 t.Helper() 382 opts := []cmp.Option{ 383 cmpopts.IgnoreFields(pkg.Package{}, "id"), // note: ID is not deterministic for test purposes 384 cmp.Comparer( 385 func(x, y file.LocationSet) bool { 386 xs := x.ToSlice() 387 ys := y.ToSlice() 388 389 if len(xs) != len(ys) { 390 return false 391 } 392 for i, xe := range xs { 393 ye := ys[i] 394 if !DefaultLocationComparer(xe, ye) { 395 return false 396 } 397 } 398 399 return true 400 }, 401 ), 402 cmp.Comparer( 403 func(x, y pkg.LicenseSet) bool { 404 xs := x.ToSlice() 405 ys := y.ToSlice() 406 407 if len(xs) != len(ys) { 408 return false 409 } 410 for i, xe := range xs { 411 ye := ys[i] 412 if !DefaultLicenseComparer(xe, ye) { 413 return false 414 } 415 } 416 417 return true 418 }, 419 ), 420 cmp.Comparer( 421 DefaultLocationComparer, 422 ), 423 cmp.Comparer( 424 DefaultLicenseComparer, 425 ), 426 } 427 428 if diff := cmp.Diff(a, b, opts...); diff != "" { 429 t.Errorf("unexpected packages from parsing (-expected +actual)\n%s", diff) 430 } 431 } 432 433 // diffReporter is a simple custom reporter that only records differences detected during comparison. 434 type diffReporter struct { 435 path cmp.Path 436 diffs []string 437 } 438 439 func (r *diffReporter) PushStep(ps cmp.PathStep) { 440 r.path = append(r.path, ps) 441 } 442 443 func (r *diffReporter) Report(rs cmp.Result) { 444 if !rs.Equal() { 445 vx, vy := r.path.Last().Values() 446 r.diffs = append(r.diffs, fmt.Sprintf("%#v:\n\t-: %+v\n\t+: %+v\n", r.path, vx, vy)) 447 } 448 } 449 450 func (r *diffReporter) PopStep() { 451 r.path = r.path[:len(r.path)-1] 452 } 453 454 func (r *diffReporter) String() string { 455 return strings.Join(r.diffs, "\n") 456 }