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  }