github.com/kastenhq/syft@v0.0.0-20230821225854-0710af25cdbe/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/kastenhq/syft/syft/artifact" 17 "github.com/kastenhq/syft/syft/file" 18 "github.com/kastenhq/syft/syft/linux" 19 "github.com/kastenhq/syft/syft/pkg" 20 "github.com/kastenhq/syft/syft/pkg/cataloger/generic" 21 "github.com/kastenhq/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) TestCataloger(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 that 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 // nolint:funlen 256 func (p *CatalogTester) assertPkgs(t *testing.T, pkgs []pkg.Package, relationships []artifact.Relationship) { 257 t.Helper() 258 259 p.compareOptions = append(p.compareOptions, 260 cmpopts.IgnoreFields(pkg.Package{}, "id"), // note: ID is not deterministic for test purposes 261 cmpopts.SortSlices(pkg.Less), 262 cmp.Comparer( 263 func(x, y file.LocationSet) bool { 264 xs := x.ToSlice() 265 ys := y.ToSlice() 266 267 if len(xs) != len(ys) { 268 return false 269 } 270 for i, xe := range xs { 271 ye := ys[i] 272 if !p.locationComparer(xe, ye) { 273 return false 274 } 275 } 276 277 return true 278 }, 279 ), 280 cmp.Comparer( 281 func(x, y pkg.LicenseSet) bool { 282 xs := x.ToSlice() 283 ys := y.ToSlice() 284 285 if len(xs) != len(ys) { 286 return false 287 } 288 for i, xe := range xs { 289 ye := ys[i] 290 if !p.licenseComparer(xe, ye) { 291 return false 292 } 293 } 294 295 return true 296 }, 297 ), 298 cmp.Comparer( 299 p.locationComparer, 300 ), 301 cmp.Comparer( 302 p.licenseComparer, 303 ), 304 ) 305 306 { 307 var r diffReporter 308 var opts []cmp.Option 309 310 opts = append(opts, p.compareOptions...) 311 opts = append(opts, cmp.Reporter(&r)) 312 313 if diff := cmp.Diff(p.expectedPkgs, pkgs, opts...); diff != "" { 314 t.Log("Specific Differences:\n" + r.String()) 315 t.Errorf("unexpected packages from parsing (-expected +actual)\n%s", diff) 316 } 317 } 318 { 319 var r diffReporter 320 var opts []cmp.Option 321 322 opts = append(opts, p.compareOptions...) 323 opts = append(opts, cmp.Reporter(&r)) 324 325 if diff := cmp.Diff(p.expectedRelationships, relationships, opts...); diff != "" { 326 t.Log("Specific Differences:\n" + r.String()) 327 328 t.Errorf("unexpected relationships from parsing (-expected +actual)\n%s", diff) 329 } 330 } 331 } 332 333 func TestFileParser(t *testing.T, fixturePath string, parser generic.Parser, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) { 334 t.Helper() 335 NewCatalogTester().FromFile(t, fixturePath).Expects(expectedPkgs, expectedRelationships).TestParser(t, parser) 336 } 337 338 func TestFileParserWithEnv(t *testing.T, fixturePath string, parser generic.Parser, env *generic.Environment, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) { 339 t.Helper() 340 341 NewCatalogTester().FromFile(t, fixturePath).WithEnv(env).Expects(expectedPkgs, expectedRelationships).TestParser(t, parser) 342 } 343 344 func AssertPackagesEqual(t *testing.T, a, b pkg.Package) { 345 t.Helper() 346 opts := []cmp.Option{ 347 cmpopts.IgnoreFields(pkg.Package{}, "id"), // note: ID is not deterministic for test purposes 348 cmp.Comparer( 349 func(x, y file.LocationSet) bool { 350 xs := x.ToSlice() 351 ys := y.ToSlice() 352 353 if len(xs) != len(ys) { 354 return false 355 } 356 for i, xe := range xs { 357 ye := ys[i] 358 if !DefaultLocationComparer(xe, ye) { 359 return false 360 } 361 } 362 363 return true 364 }, 365 ), 366 cmp.Comparer( 367 func(x, y pkg.LicenseSet) bool { 368 xs := x.ToSlice() 369 ys := y.ToSlice() 370 371 if len(xs) != len(ys) { 372 return false 373 } 374 for i, xe := range xs { 375 ye := ys[i] 376 if !DefaultLicenseComparer(xe, ye) { 377 return false 378 } 379 } 380 381 return true 382 }, 383 ), 384 cmp.Comparer( 385 DefaultLocationComparer, 386 ), 387 cmp.Comparer( 388 DefaultLicenseComparer, 389 ), 390 } 391 392 if diff := cmp.Diff(a, b, opts...); diff != "" { 393 t.Errorf("unexpected packages from parsing (-expected +actual)\n%s", diff) 394 } 395 } 396 397 // diffReporter is a simple custom reporter that only records differences detected during comparison. 398 type diffReporter struct { 399 path cmp.Path 400 diffs []string 401 } 402 403 func (r *diffReporter) PushStep(ps cmp.PathStep) { 404 r.path = append(r.path, ps) 405 } 406 407 func (r *diffReporter) Report(rs cmp.Result) { 408 if !rs.Equal() { 409 vx, vy := r.path.Last().Values() 410 r.diffs = append(r.diffs, fmt.Sprintf("%#v:\n\t-: %+v\n\t+: %+v\n", r.path, vx, vy)) 411 } 412 } 413 414 func (r *diffReporter) PopStep() { 415 r.path = r.path[:len(r.path)-1] 416 } 417 418 func (r *diffReporter) String() string { 419 return strings.Join(r.diffs, "\n") 420 }