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  }