From 006ba9b557d5a4d77684b4b56c60493dfd63f132 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Mon, 20 Dec 2021 10:35:25 -0500 Subject: [PATCH] Add --exclude flag (#695) --- README.md | 23 +++ cmd/packages.go | 11 +- cmd/power_user.go | 2 +- internal/config/application.go | 1 + syft/source/directory_resolver.go | 35 ++-- syft/source/excluding_file_resolver.go | 101 +++++++++ syft/source/excluding_file_resolver_test.go | 197 ++++++++++++++++++ syft/source/source.go | 111 +++++++++- syft/source/source_test.go | 216 ++++++++++++++++++++ 9 files changed, 672 insertions(+), 25 deletions(-) create mode 100644 syft/source/excluding_file_resolver.go create mode 100644 syft/source/excluding_file_resolver_test.go diff --git a/README.md b/README.md index 68b4903a7..a627a8a52 100644 --- a/README.md +++ b/README.md @@ -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 --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: diff --git a/cmd/packages.go b/cmd/packages.go index 6d4629738..a2d51e05c 100644 --- a/cmd/packages.go +++ b/cmd/packages.go @@ -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 diff --git a/cmd/power_user.go b/cmd/power_user.go index 08d0f910c..dfcd18bb7 100644 --- a/cmd/power_user.go +++ b/cmd/power_user.go @@ -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 diff --git a/internal/config/application.go b/internal/config/application.go index cf844a9d5..56b2654b0 100644 --- a/internal/config/application.go +++ b/internal/config/application.go @@ -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 diff --git a/syft/source/directory_resolver.go b/syft/source/directory_resolver.go index e0a9608c6..0d329a07e 100644 --- a/syft/source/directory_resolver.go +++ b/syft/source/directory_resolver.go @@ -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 { diff --git a/syft/source/excluding_file_resolver.go b/syft/source/excluding_file_resolver.go new file mode 100644 index 000000000..50969116a --- /dev/null +++ b/syft/source/excluding_file_resolver.go @@ -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 +} diff --git a/syft/source/excluding_file_resolver_test.go b/syft/source/excluding_file_resolver_test.go new file mode 100644 index 000000000..4cfe18727 --- /dev/null +++ b/syft/source/excluding_file_resolver_test.go @@ -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 +} diff --git a/syft/source/source.go b/syft/source/source.go index d01550e22..9b0be8c57 100644 --- a/syft/source/source.go +++ b/syft/source/source.go @@ -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 +} diff --git a/syft/source/source_test.go b/syft/source/source_test.go index 53cef9435..edcc8ef21 100644 --- a/syft/source/source_test.go +++ b/syft/source/source_test.go @@ -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()