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