Add --exclude flag (#695)

This commit is contained in:
Keith Zantow 2021-12-20 10:35:25 -05:00 committed by GitHub
parent a27907659d
commit 006ba9b557
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 672 additions and 25 deletions

View file

@ -93,6 +93,23 @@ file:path/to/yourproject/file read directly from a path on disk (any si
registry:yourrepo/yourimage:tag pull image directly from a registry (no container runtime required)
```
### Excluding file paths
Syft can exclude files and paths from being scanned within a source by using glob expressions
with one or more `--exclude` parameters:
```
syft <source> --exclude './out/**/*.json' --exclude /etc
```
**Note:** in the case of _image scanning_, since the entire filesystem is scanned it is
possible to use absolute paths like `/etc` or `/usr/**/*.txt` whereas _directory scans_
exclude files _relative to the specified directory_. For example: scanning `/usr/foo` with
`--exclude ./package.json` would exclude `/usr/foo/package.json` and `--exclude '**/package.json'`
would exclude all `package.json` files under `/usr/foo`. For _directory scans_,
it is required to begin path expressions with `./`, `*/`, or `**/`, all of which
will be resolved _relative to the specified scan directory_. Keep in mind, your shell
may attempt to expand wildcards, so put those parameters in single quotes, like:
`'**/*.json'`.
### Output formats
The output format for Syft is configurable as well:
@ -219,6 +236,12 @@ file: ""
# same as SYFT_CHECK_FOR_APP_UPDATE env var
check-for-app-update: true
# a list of globs to exclude from scanning. same as --exclude ; for example:
# exclude:
# - '/etc/**'
# - './out/**/*.json'
exclude:
# cataloging packages is exposed through the packages and power-user subcommands
package:
cataloger:

View file

@ -132,6 +132,11 @@ func setPackageFlags(flags *pflag.FlagSet) {
"include dockerfile for upload to Anchore Enterprise",
)
flags.StringArrayP(
"exclude", "", nil,
"exclude paths from being scanned using a glob expression",
)
flags.Bool(
"overwrite-existing-image", false,
"overwrite an existing image during the upload to Anchore Enterprise",
@ -158,6 +163,10 @@ func bindPackagesConfigOptions(flags *pflag.FlagSet) error {
return err
}
if err := viper.BindPFlag("exclude", flags.Lookup("exclude")); err != nil {
return err
}
// Upload options //////////////////////////////////////////////////////////
if err := viper.BindPFlag("anchore.host", flags.Lookup("host")); err != nil {
@ -253,7 +262,7 @@ func packagesExecWorker(userInput string) <-chan error {
checkForApplicationUpdate()
src, cleanup, err := source.New(userInput, appConfig.Registry.ToOptions())
src, cleanup, err := source.New(userInput, appConfig.Registry.ToOptions(), appConfig.Exclusions...)
if err != nil {
errs <- fmt.Errorf("failed to determine image source: %w", err)
return

View file

@ -113,7 +113,7 @@ func powerUserExecWorker(userInput string) <-chan error {
checkForApplicationUpdate()
src, cleanup, err := source.New(userInput, appConfig.Registry.ToOptions())
src, cleanup, err := source.New(userInput, appConfig.Registry.ToOptions(), appConfig.Exclusions...)
if err != nil {
errs <- err
return

View file

@ -44,6 +44,7 @@ type Application struct {
FileContents fileContents `yaml:"file-contents" json:"file-contents" mapstructure:"file-contents"`
Secrets secrets `yaml:"secrets" json:"secrets" mapstructure:"secrets"`
Registry registry `yaml:"registry" json:"registry" mapstructure:"registry"`
Exclusions []string `yaml:"exclude" json:"exclude" mapstructure:"exclude"`
}
// PowerUserCatalogerEnabledDefault switches all catalogers to be enabled when running power-user command

View file

@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
@ -59,17 +60,13 @@ func newDirectoryResolver(root string, pathFilters ...pathFilterFn) (*directoryR
currentWdRelRoot = filepath.Clean(root)
}
if pathFilters == nil {
pathFilters = []pathFilterFn{isUnallowableFileType, isUnixSystemRuntimePath}
}
resolver := directoryResolver{
path: root,
currentWd: currentWd,
currentWdRelativeToRoot: currentWdRelRoot,
fileTree: filetree.NewFileTree(),
metadata: make(map[file.ID]FileMetadata),
pathFilterFns: pathFilters,
pathFilterFns: append([]pathFilterFn{isUnallowableFileType, isUnixSystemRuntimePath}, pathFilters...),
refsByMIMEType: make(map[string][]file.Reference),
errPaths: make(map[string]error),
}
@ -95,7 +92,7 @@ func (r *directoryResolver) indexTree(root string, stager *progress.Stage) ([]st
fi, err := os.Stat(root)
if err != nil && fi != nil && !fi.IsDir() {
// note: we want to index the path regardless of an error stat-ing the path
newRoot := r.indexPath(root, fi, nil)
newRoot, _ := r.indexPath(root, fi, nil)
if newRoot != "" {
roots = append(roots, newRoot)
}
@ -106,7 +103,12 @@ func (r *directoryResolver) indexTree(root string, stager *progress.Stage) ([]st
func(path string, info os.FileInfo, err error) error {
stager.Current = path
newRoot := r.indexPath(path, info, err)
newRoot, err := r.indexPath(path, info, err)
if err != nil {
return err
}
if newRoot != "" {
roots = append(roots, newRoot)
}
@ -115,35 +117,38 @@ func (r *directoryResolver) indexTree(root string, stager *progress.Stage) ([]st
})
}
func (r *directoryResolver) indexPath(path string, info os.FileInfo, err error) string {
func (r *directoryResolver) indexPath(path string, info os.FileInfo, err error) (string, error) {
// ignore any path which a filter function returns true
for _, filterFn := range r.pathFilterFns {
if filterFn(path, info) {
return ""
if filterFn != nil && filterFn(path, info) {
if info.IsDir() {
return "", fs.SkipDir
}
return "", nil
}
}
if r.isFileAccessErr(path, err) {
return ""
return "", nil
}
// link cycles could cause a revisit --we should not allow this
if r.fileTree.HasPath(file.Path(path)) {
return ""
return "", nil
}
if info == nil {
// walk may not be able to provide a FileInfo object, don't allow for this to stop indexing; keep track of the paths and continue.
r.errPaths[path] = fmt.Errorf("no file info observable at path=%q", path)
return ""
return "", nil
}
newRoot, err := r.addPathToIndex(path, info)
if r.isFileAccessErr(path, err) {
return ""
return "", nil
}
return newRoot
return newRoot, nil
}
func (r *directoryResolver) isFileAccessErr(path string, err error) bool {

View file

@ -0,0 +1,101 @@
package source
import (
"fmt"
"io"
)
type excludeFn func(string) bool
// excludingResolver decorates a resolver with an exclusion function that is used to
// filter out entries in the delegate resolver
type excludingResolver struct {
delegate FileResolver
excludeFn excludeFn
}
// NewExcludingResolver create a new resolver which wraps the provided delegate and excludes
// entries based on a provided path exclusion function
func NewExcludingResolver(delegate FileResolver, excludeFn excludeFn) FileResolver {
return &excludingResolver{
delegate,
excludeFn,
}
}
func (r *excludingResolver) FileContentsByLocation(location Location) (io.ReadCloser, error) {
if locationMatches(&location, r.excludeFn) {
return nil, fmt.Errorf("no such location: %+v", location.RealPath)
}
return r.delegate.FileContentsByLocation(location)
}
func (r *excludingResolver) FileMetadataByLocation(location Location) (FileMetadata, error) {
if locationMatches(&location, r.excludeFn) {
return FileMetadata{}, fmt.Errorf("no such location: %+v", location.RealPath)
}
return r.delegate.FileMetadataByLocation(location)
}
func (r *excludingResolver) HasPath(path string) bool {
if r.excludeFn(path) {
return false
}
return r.delegate.HasPath(path)
}
func (r *excludingResolver) FilesByPath(paths ...string) ([]Location, error) {
locations, err := r.delegate.FilesByPath(paths...)
return filterLocations(locations, err, r.excludeFn)
}
func (r *excludingResolver) FilesByGlob(patterns ...string) ([]Location, error) {
locations, err := r.delegate.FilesByGlob(patterns...)
return filterLocations(locations, err, r.excludeFn)
}
func (r *excludingResolver) FilesByMIMEType(types ...string) ([]Location, error) {
locations, err := r.delegate.FilesByMIMEType(types...)
return filterLocations(locations, err, r.excludeFn)
}
func (r *excludingResolver) RelativeFileByPath(location Location, path string) *Location {
l := r.delegate.RelativeFileByPath(location, path)
if l != nil && locationMatches(l, r.excludeFn) {
return nil
}
return l
}
func (r *excludingResolver) AllLocations() <-chan Location {
c := make(chan Location)
go func() {
defer close(c)
for location := range r.delegate.AllLocations() {
if !locationMatches(&location, r.excludeFn) {
c <- location
}
}
}()
return c
}
func locationMatches(location *Location, exclusionFn excludeFn) bool {
return exclusionFn(location.RealPath) || exclusionFn(location.VirtualPath)
}
func filterLocations(locations []Location, err error, exclusionFn excludeFn) ([]Location, error) {
if err != nil {
return nil, err
}
if exclusionFn != nil {
for i := 0; i < len(locations); i++ {
location := &locations[i]
if locationMatches(location, exclusionFn) {
locations = append(locations[:i], locations[i+1:]...)
i--
}
}
}
return locations, nil
}

View file

@ -0,0 +1,197 @@
package source
import (
"io"
"strings"
"testing"
"github.com/anchore/stereoscope/pkg/file"
"github.com/stretchr/testify/assert"
)
func TestExcludingResolver(t *testing.T) {
tests := []struct {
name string
locations []string
excludeFn excludeFn
expected []string
}{
{
name: "keeps locations",
locations: []string{"a", "b", "c"},
excludeFn: func(s string) bool {
return false
},
expected: []string{"a", "b", "c"},
},
{
name: "removes locations",
locations: []string{"d", "e", "f"},
excludeFn: func(s string) bool {
return true
},
expected: []string{},
},
{
name: "removes first match",
locations: []string{"g", "h", "i"},
excludeFn: func(s string) bool {
return s == "g"
},
expected: []string{"h", "i"},
},
{
name: "removes last match",
locations: []string{"j", "k", "l"},
excludeFn: func(s string) bool {
return s == "l"
},
expected: []string{"j", "k"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
resolver := &mockResolver{
locations: test.locations,
}
excludingResolver := NewExcludingResolver(resolver, test.excludeFn)
locations, _ := excludingResolver.FilesByPath()
assert.ElementsMatch(t, locationPaths(locations), test.expected)
locations, _ = excludingResolver.FilesByGlob()
assert.ElementsMatch(t, locationPaths(locations), test.expected)
locations, _ = excludingResolver.FilesByMIMEType()
assert.ElementsMatch(t, locationPaths(locations), test.expected)
locations = []Location{}
channel := excludingResolver.AllLocations()
for location := range channel {
locations = append(locations, location)
}
assert.ElementsMatch(t, locationPaths(locations), test.expected)
diff := difference(test.locations, test.expected)
for _, path := range diff {
assert.False(t, excludingResolver.HasPath(path))
c, err := excludingResolver.FileContentsByLocation(makeLocation(path))
assert.Nil(t, c)
assert.Error(t, err)
m, err := excludingResolver.FileMetadataByLocation(makeLocation(path))
assert.Empty(t, m.LinkDestination)
assert.Error(t, err)
l := excludingResolver.RelativeFileByPath(makeLocation(""), path)
assert.Nil(t, l)
}
for _, path := range test.expected {
assert.True(t, excludingResolver.HasPath(path))
c, err := excludingResolver.FileContentsByLocation(makeLocation(path))
assert.NotNil(t, c)
assert.Nil(t, err)
m, err := excludingResolver.FileMetadataByLocation(makeLocation(path))
assert.NotEmpty(t, m.LinkDestination)
assert.Nil(t, err)
l := excludingResolver.RelativeFileByPath(makeLocation(""), path)
assert.NotNil(t, l)
}
})
}
}
// difference returns the elements in `a` that aren't in `b`.
func difference(a, b []string) []string {
mb := make(map[string]struct{}, len(b))
for _, x := range b {
mb[x] = struct{}{}
}
var diff []string
for _, x := range a {
if _, found := mb[x]; !found {
diff = append(diff, x)
}
}
return diff
}
func makeLocation(path string) Location {
return Location{
Coordinates: Coordinates{
RealPath: path,
FileSystemID: "",
},
VirtualPath: "",
ref: file.Reference{},
}
}
func locationPaths(locations []Location) []string {
paths := []string{}
for _, l := range locations {
paths = append(paths, l.RealPath)
}
return paths
}
type mockResolver struct {
locations []string
}
func (r *mockResolver) getLocations() ([]Location, error) {
out := []Location{}
for _, path := range r.locations {
out = append(out, makeLocation(path))
}
return out, nil
}
func (r *mockResolver) FileContentsByLocation(_ Location) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("Hello, world!")), nil
}
func (r *mockResolver) FileMetadataByLocation(_ Location) (FileMetadata, error) {
return FileMetadata{
LinkDestination: "MOCK",
}, nil
}
func (r *mockResolver) HasPath(_ string) bool {
return true
}
func (r *mockResolver) FilesByPath(_ ...string) ([]Location, error) {
return r.getLocations()
}
func (r *mockResolver) FilesByGlob(_ ...string) ([]Location, error) {
return r.getLocations()
}
func (r *mockResolver) FilesByMIMEType(_ ...string) ([]Location, error) {
return r.getLocations()
}
func (r *mockResolver) RelativeFileByPath(_ Location, path string) *Location {
return &Location{
Coordinates: Coordinates{
RealPath: path,
},
}
}
func (r *mockResolver) AllLocations() <-chan Location {
c := make(chan Location)
go func() {
defer close(c)
locations, _ := r.getLocations()
for _, location := range locations {
c <- location
}
}()
return c
}

View file

@ -9,11 +9,14 @@ import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"sync"
"github.com/anchore/stereoscope"
"github.com/anchore/stereoscope/pkg/image"
"github.com/anchore/syft/internal/log"
"github.com/bmatcuk/doublestar/v2"
"github.com/mholt/archiver/v3"
"github.com/spf13/afero"
)
@ -26,28 +29,38 @@ type Source struct {
directoryResolver *directoryResolver
path string
mutex *sync.Mutex
Exclusions []string
}
type sourceDetector func(string) (image.Source, string, error)
// New produces a Source based on userInput like dir: or image:tag
func New(userInput string, registryOptions *image.RegistryOptions) (*Source, func(), error) {
func New(userInput string, registryOptions *image.RegistryOptions, exclusions ...string) (*Source, func(), error) {
fs := afero.NewOsFs()
parsedScheme, imageSource, location, err := detectScheme(fs, image.DetectSource, userInput)
if err != nil {
return &Source{}, func() {}, fmt.Errorf("unable to parse input=%q: %w", userInput, err)
}
source := &Source{}
cleanupFn := func() {}
switch parsedScheme {
case FileScheme:
return generateFileSource(fs, location)
source, cleanupFn, err = generateFileSource(fs, location)
case DirectoryScheme:
return generateDirectorySource(fs, location)
source, cleanupFn, err = generateDirectorySource(fs, location)
case ImageScheme:
return generateImageSource(location, userInput, imageSource, registryOptions)
source, cleanupFn, err = generateImageSource(location, userInput, imageSource, registryOptions)
default:
err = fmt.Errorf("unable to process input for scanning: '%s'", userInput)
}
return &Source{}, func() {}, fmt.Errorf("unable to process input for scanning: '%s'", userInput)
if err == nil {
source.Exclusions = exclusions
}
return source, cleanupFn, err
}
func generateImageSource(location, userInput string, imageSource image.Source, registryOptions *image.RegistryOptions) (*Source, func(), error) {
@ -180,7 +193,11 @@ func (s *Source) FileResolver(scope Scope) (FileResolver, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.directoryResolver == nil {
resolver, err := newDirectoryResolver(s.path)
exclusionFunctions, err := getDirectoryExclusionFunctions(s.path, s.Exclusions)
if err != nil {
return nil, err
}
resolver, err := newDirectoryResolver(s.path, exclusionFunctions...)
if err != nil {
return nil, err
}
@ -188,14 +205,24 @@ func (s *Source) FileResolver(scope Scope) (FileResolver, error) {
}
return s.directoryResolver, nil
case ImageScheme:
var resolver FileResolver
var err error
switch scope {
case SquashedScope:
return newImageSquashResolver(s.Image)
resolver, err = newImageSquashResolver(s.Image)
case AllLayersScope:
return newAllLayersResolver(s.Image)
resolver, err = newAllLayersResolver(s.Image)
default:
return nil, fmt.Errorf("bad image scope provided: %+v", scope)
}
if err != nil {
return nil, err
}
// image tree contains all paths, so we filter out the excluded entries afterwards
if len(s.Exclusions) > 0 {
resolver = NewExcludingResolver(resolver, getImageExclusionFunction(s.Exclusions))
}
return resolver, nil
}
return nil, fmt.Errorf("unable to determine FilePathResolver with current scheme=%q", s.Metadata.Scheme)
}
@ -214,3 +241,71 @@ func unarchiveToTmp(path string, unarchiver archiver.Unarchiver) (string, func()
return tempDir, cleanupFn, unarchiver.Unarchive(path, tempDir)
}
func getImageExclusionFunction(exclusions []string) func(string) bool {
if len(exclusions) == 0 {
return nil
}
// add subpath exclusions
for _, exclusion := range exclusions {
exclusions = append(exclusions, exclusion+"/**")
}
return func(path string) bool {
for _, exclusion := range exclusions {
matches, err := doublestar.Match(exclusion, path)
if err != nil {
return false
}
if matches {
return true
}
}
return false
}
}
func getDirectoryExclusionFunctions(root string, exclusions []string) ([]pathFilterFn, error) {
if len(exclusions) == 0 {
return nil, nil
}
// this is what directoryResolver.indexTree is doing to get the absolute path:
root, err := filepath.Abs(root)
if err != nil {
return nil, err
}
if !strings.HasSuffix(root, "/") {
root += "/"
}
var errors []string
for idx, exclusion := range exclusions {
// check exclusions for supported paths, these are all relative to the "scan root"
if strings.HasPrefix(exclusion, "./") || strings.HasPrefix(exclusion, "*/") || strings.HasPrefix(exclusion, "**/") {
exclusion = strings.TrimPrefix(exclusion, "./")
exclusions[idx] = root + exclusion
} else {
errors = append(errors, exclusion)
}
}
if errors != nil {
return nil, fmt.Errorf("invalid exclusion pattern(s): '%s' (must start with one of: './', '*/', or '**/')", strings.Join(errors, "', '"))
}
return []pathFilterFn{
func(path string, _ os.FileInfo) bool {
for _, exclusion := range exclusions {
matches, err := doublestar.Match(exclusion, path)
if err != nil {
return false
}
if matches {
return true
}
}
return false
},
}, nil
}

View file

@ -6,9 +6,12 @@ import (
"os/exec"
"path"
"path/filepath"
"strings"
"syscall"
"testing"
"github.com/anchore/stereoscope/pkg/imagetest"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/assert"
@ -317,6 +320,219 @@ func TestFilesByGlob(t *testing.T) {
}
}
func TestDirectoryExclusions(t *testing.T) {
testCases := []struct {
desc string
input string
glob string
expected int
exclusions []string
err bool
}{
{
input: "test-fixtures/system_paths",
desc: "exclude everything",
glob: "**",
expected: 0,
exclusions: []string{"**/*"},
},
{
input: "test-fixtures/image-simple",
desc: "a single path excluded",
glob: "**",
expected: 3,
exclusions: []string{"**/target/**"},
},
{
input: "test-fixtures/image-simple",
desc: "exclude explicit directory relative to the root",
glob: "**",
expected: 3,
exclusions: []string{"./target"},
},
{
input: "test-fixtures/image-simple",
desc: "exclude explicit file relative to the root",
glob: "**",
expected: 3,
exclusions: []string{"./file-1.txt"},
},
{
input: "test-fixtures/image-simple",
desc: "exclude wildcard relative to the root",
glob: "**",
expected: 2,
exclusions: []string{"./*.txt"},
},
{
input: "test-fixtures/image-simple",
desc: "exclude files deeper",
glob: "**",
expected: 3,
exclusions: []string{"**/really/**"},
},
{
input: "test-fixtures/image-simple",
desc: "files excluded with extension",
glob: "**",
expected: 1,
exclusions: []string{"**/*.txt"},
},
{
input: "test-fixtures/image-simple",
desc: "keep files with different extensions",
glob: "**",
expected: 4,
exclusions: []string{"**/target/**/*.jar"},
},
{
input: "test-fixtures/path-detected",
desc: "file directly excluded",
glob: "**",
expected: 1,
exclusions: []string{"**/empty"},
},
{
input: "test-fixtures/path-detected",
desc: "pattern error containing **/",
glob: "**",
expected: 1,
exclusions: []string{"/**/empty"},
err: true,
},
{
input: "test-fixtures/path-detected",
desc: "pattern error incorrect start",
glob: "**",
expected: 1,
exclusions: []string{"empty"},
err: true,
},
{
input: "test-fixtures/path-detected",
desc: "pattern error starting with /",
glob: "**",
expected: 1,
exclusions: []string{"/empty"},
err: true,
},
}
registryOpts := &image.RegistryOptions{}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
src, fn, err := New("dir:"+test.input, registryOpts, test.exclusions...)
defer fn()
if test.err {
_, err = src.FileResolver(SquashedScope)
if err == nil {
t.Errorf("expected an error for patterns: %s", strings.Join(test.exclusions, " or "))
}
return
}
if err != nil {
t.Errorf("could not create NewDirScope: %+v", err)
}
resolver, err := src.FileResolver(SquashedScope)
if err != nil {
t.Errorf("could not get resolver error: %+v", err)
}
contents, err := resolver.FilesByGlob(test.glob)
if err != nil {
t.Errorf("could not get files by glob: %s+v", err)
}
if len(contents) != test.expected {
t.Errorf("wrong number of files after exclusions (%s): %d != %d", test.glob, len(contents), test.expected)
}
})
}
}
func TestImageExclusions(t *testing.T) {
testCases := []struct {
desc string
input string
glob string
expected int
exclusions []string
}{
// NOTE: in the Dockerfile, /target is moved to /, which makes /really a top-level dir
{
input: "image-simple",
desc: "a single path excluded",
glob: "**",
expected: 2,
exclusions: []string{"/really/**"},
},
{
input: "image-simple",
desc: "a directly referenced directory is excluded",
glob: "**",
expected: 2,
exclusions: []string{"/really"},
},
{
input: "image-simple",
desc: "a partial directory is not excluded",
glob: "**",
expected: 3,
exclusions: []string{"/reall"},
},
{
input: "image-simple",
desc: "exclude files deeper",
glob: "**",
expected: 2,
exclusions: []string{"**/nested/**"},
},
{
input: "image-simple",
desc: "files excluded with extension",
glob: "**",
expected: 2,
exclusions: []string{"**/*1.txt"},
},
{
input: "image-simple",
desc: "keep files with different extensions",
glob: "**",
expected: 3,
exclusions: []string{"**/target/**/*.jar"},
},
{
input: "image-simple",
desc: "file directly excluded",
glob: "**",
expected: 2,
exclusions: []string{"**/somefile-1.txt"}, // file-1 renamed to somefile-1 in Dockerfile
},
}
registryOpts := &image.RegistryOptions{}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
archiveLocation := imagetest.PrepareFixtureImage(t, "docker-archive", test.input)
src, fn, err := New(archiveLocation, registryOpts, test.exclusions...)
defer fn()
if err != nil {
t.Errorf("could not create NewDirScope: %+v", err)
}
resolver, err := src.FileResolver(SquashedScope)
if err != nil {
t.Errorf("could not get resolver error: %+v", err)
}
contents, err := resolver.FilesByGlob(test.glob)
if err != nil {
t.Errorf("could not get files by glob: %s+v", err)
}
if len(contents) != test.expected {
t.Errorf("wrong number of files after exclusions (%s): %d != %d", test.glob, len(contents), test.expected)
}
})
}
}
// createArchive creates a new archive file at destinationArchivePath based on the directory found at sourceDirPath.
func createArchive(t testing.TB, sourceDirPath, destinationArchivePath string) {
t.Helper()