github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/filesystem_test.go (about)

     1  // Copyright 2025 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package filesystem_test
    16  
    17  import (
    18  	"context"
    19  	"errors"
    20  	"io"
    21  	"io/fs"
    22  	"os"
    23  	"path"
    24  	"path/filepath"
    25  	"regexp"
    26  	"runtime"
    27  	"slices"
    28  	"sort"
    29  	"strings"
    30  	"testing"
    31  	"time"
    32  
    33  	"github.com/gobwas/glob"
    34  	"github.com/google/go-cmp/cmp"
    35  	"github.com/google/go-cmp/cmp/cmpopts"
    36  	"github.com/google/osv-scalibr/extractor"
    37  	"github.com/google/osv-scalibr/extractor/filesystem"
    38  	scalibrfs "github.com/google/osv-scalibr/fs"
    39  	"github.com/google/osv-scalibr/inventory"
    40  	"github.com/google/osv-scalibr/plugin"
    41  	"github.com/google/osv-scalibr/stats"
    42  	"github.com/google/osv-scalibr/testing/extracttest"
    43  	fe "github.com/google/osv-scalibr/testing/fakeextractor"
    44  	"github.com/google/osv-scalibr/testing/fakefs"
    45  )
    46  
    47  // Map of file paths to contents. Empty contents denote directories.
    48  type mapFS map[string][]byte
    49  
    50  func TestInitWalkContext(t *testing.T) {
    51  	dummyFS := scalibrfs.DirFS(".")
    52  	testCases := []struct {
    53  		desc           string
    54  		scanRoots      map[string][]string
    55  		pathsToExtract map[string][]string
    56  		dirsToSkip     map[string][]string
    57  		wantErr        error
    58  	}{
    59  		{
    60  			desc: "valid_config_with_pathsToExtract_raises_no_error",
    61  			scanRoots: map[string][]string{
    62  				"darwin":  {"/scanroot/"},
    63  				"linux":   {"/scanroot/"},
    64  				"windows": {"C:\\scanroot\\"},
    65  			},
    66  			pathsToExtract: map[string][]string{
    67  				"darwin":  {"/scanroot/file1.txt", "/scanroot/file2.txt"},
    68  				"linux":   {"/scanroot/file1.txt", "/scanroot/file2.txt"},
    69  				"windows": {"C:\\scanroot\\file1.txt", "C:\\scanroot\\file2.txt"},
    70  			},
    71  			wantErr: nil,
    72  		},
    73  		{
    74  			desc: "valid_config_with_dirsToSkip_raises_no_error",
    75  			scanRoots: map[string][]string{
    76  				"darwin":  {"/scanroot/", "/someotherroot/"},
    77  				"linux":   {"/scanroot/", "/someotherroot/"},
    78  				"windows": {"C:\\scanroot\\", "D:\\someotherroot\\"},
    79  			},
    80  			dirsToSkip: map[string][]string{
    81  				"darwin":  {"/scanroot/mydir/", "/someotherroot/mydir/"},
    82  				"linux":   {"/scanroot/mydir/", "/someotherroot/mydir/"},
    83  				"windows": {"C:\\scanroot\\mydir\\", "D:\\someotherroot\\mydir\\"},
    84  			},
    85  			wantErr: nil,
    86  		},
    87  		{
    88  			desc: "pathsToExtract_not_relative_to_any_root_raises_error",
    89  			scanRoots: map[string][]string{
    90  				"darwin":  {"/scanroot/"},
    91  				"linux":   {"/scanroot/"},
    92  				"windows": {"C:\\scanroot\\"},
    93  			},
    94  			pathsToExtract: map[string][]string{
    95  				"darwin":  {"/scanroot/myfile.txt", "/myotherroot/file1.txt"},
    96  				"linux":   {"/scanroot/myfile.txt", "/myotherroot/file1.txt"},
    97  				"windows": {"C:\\scanroot\\myfile.txt", "D:\\myotherroot\\file1.txt"},
    98  			},
    99  			wantErr: filesystem.ErrNotRelativeToScanRoots,
   100  		},
   101  		{
   102  			desc: "dirsToSkip_not_relative_to_any_root_raises_error",
   103  			scanRoots: map[string][]string{
   104  				"darwin":  {"/scanroot/"},
   105  				"linux":   {"/scanroot/"},
   106  				"windows": {"C:\\scanroot\\"},
   107  			},
   108  			dirsToSkip: map[string][]string{
   109  				"darwin":  {"/scanroot/mydir/", "/myotherroot/mydir/"},
   110  				"linux":   {"/scanroot/mydir/", "/myotherroot/mydir/"},
   111  				"windows": {"C:\\scanroot\\mydir\\", "D:\\myotherroot\\mydir\\"},
   112  			},
   113  			wantErr: filesystem.ErrNotRelativeToScanRoots,
   114  		},
   115  	}
   116  
   117  	for _, tc := range testCases {
   118  		t.Run(tc.desc, func(t *testing.T) {
   119  			os := runtime.GOOS
   120  			if _, ok := tc.scanRoots[os]; !ok {
   121  				t.Fatalf("system %q not defined in test, please extend the tests", os)
   122  			}
   123  			config := &filesystem.Config{
   124  				PathsToExtract: tc.pathsToExtract[os],
   125  				DirsToSkip:     tc.dirsToSkip[os],
   126  			}
   127  			scanRoots := []*scalibrfs.ScanRoot{}
   128  			for _, p := range tc.scanRoots[os] {
   129  				scanRoots = append(scanRoots, &scalibrfs.ScanRoot{FS: dummyFS, Path: p})
   130  			}
   131  			_, err := filesystem.InitWalkContext(
   132  				t.Context(), config, scanRoots,
   133  			)
   134  			if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" {
   135  				t.Errorf("filesystem.InitializeWalkContext(%v) error got diff (-want +got):\n%s", config, diff)
   136  			}
   137  		})
   138  	}
   139  }
   140  
   141  // fakeExtractorFS is a mock extractor for testing embedded filesystem extraction.
   142  // It simulates extracting an embedded filesystem from a VMDK file (e.g., disk.vmdk)
   143  // and provides a function to return the embedded filesystem for scanning.
   144  type fakeExtractorFS struct {
   145  	name          string                                          // Name of the extractor (e.g., "fake-ex-fs").
   146  	getEmbeddedFS func(ctx context.Context) (scalibrfs.FS, error) // Function to return the embedded filesystem for disk.vmdk:1.
   147  }
   148  
   149  func (e *fakeExtractorFS) Name() string                       { return e.name }
   150  func (e *fakeExtractorFS) Version() int                       { return 1 }
   151  func (e *fakeExtractorFS) Requirements() *plugin.Capabilities { return &plugin.Capabilities{} }
   152  func (e *fakeExtractorFS) FileRequired(api filesystem.FileAPI) bool {
   153  	path := api.Path()
   154  	return path == "disk.vmdk"
   155  }
   156  func (e *fakeExtractorFS) Extract(ctx context.Context, input *filesystem.ScanInput) (inventory.Inventory, error) {
   157  	path := input.Path
   158  	if path != "disk.vmdk" {
   159  		return inventory.Inventory{}, errors.New("unrecognized path")
   160  	}
   161  	return inventory.Inventory{
   162  		EmbeddedFSs: []*inventory.EmbeddedFS{
   163  			{
   164  				Path:          "disk.vmdk:1",
   165  				GetEmbeddedFS: e.getEmbeddedFS, // Use stored function
   166  			},
   167  		},
   168  	}, nil
   169  }
   170  
   171  // fakeExtractorSoftware is a mock extractor for testing package detection.
   172  // It simulates detecting a software package from a file (e.g., file.txt) within
   173  // an embedded filesystem.
   174  type fakeExtractorSoftware struct {
   175  	name string // Name of the extractor (e.g., "fake-ex-software").
   176  }
   177  
   178  func (e *fakeExtractorSoftware) Name() string                       { return e.name }
   179  func (e *fakeExtractorSoftware) Version() int                       { return 1 }
   180  func (e *fakeExtractorSoftware) Requirements() *plugin.Capabilities { return &plugin.Capabilities{} }
   181  func (e *fakeExtractorSoftware) FileRequired(api filesystem.FileAPI) bool {
   182  	path := filepath.ToSlash(api.Path())
   183  	return strings.HasSuffix(path, "file.txt") || strings.HasSuffix(path, "/file.txt") || path == "file.txt" || path == "./file.txt"
   184  }
   185  func (e *fakeExtractorSoftware) Extract(ctx context.Context, input *filesystem.ScanInput) (inventory.Inventory, error) {
   186  	path := filepath.ToSlash(input.Path)
   187  	if !strings.HasSuffix(path, "file.txt") {
   188  		return inventory.Inventory{}, errors.New("not a file.txt")
   189  	}
   190  	return inventory.Inventory{
   191  		Packages: []*extractor.Package{
   192  			{
   193  				Name:      "Software",
   194  				Locations: []string{path},
   195  				Plugins:   []string{e.Name()},
   196  			},
   197  		},
   198  	}, nil
   199  }
   200  
   201  func TestRun_EmbeddedFS(t *testing.T) {
   202  	success := &plugin.ScanStatus{Status: plugin.ScanStatusSucceeded}
   203  	fsys := setupMapFS(t, mapFS{
   204  		"disk.vmdk": []byte("VMDK Content"),
   205  	})
   206  
   207  	// Create temporary directory for embedded filesystem
   208  	embeddedDir := t.TempDir()
   209  	err := os.WriteFile(filepath.Join(embeddedDir, "file.txt"), []byte("Content"), fs.ModePerm)
   210  	if err != nil {
   211  		t.Fatalf("os.WriteFile(%q): %v", filepath.Join(embeddedDir, "file.txt"), err)
   212  	}
   213  	embeddedFS := scalibrfs.DirFS(embeddedDir)
   214  
   215  	fakeExFS := &fakeExtractorFS{
   216  		name: "fake-ex-fs",
   217  		getEmbeddedFS: func(ctx context.Context) (scalibrfs.FS, error) {
   218  			return embeddedFS, nil
   219  		},
   220  	}
   221  	fakeExSoftware := &fakeExtractorSoftware{name: "fake-ex-software"}
   222  	extractors := []filesystem.Extractor{fakeExFS, fakeExSoftware}
   223  
   224  	// Create config with a single ScanRoot
   225  	config := &filesystem.Config{
   226  		Extractors: extractors,
   227  		ScanRoots: []*scalibrfs.ScanRoot{{
   228  			FS:   fsys,
   229  			Path: ".",
   230  		}},
   231  		Stats: &fakeCollector{},
   232  	}
   233  
   234  	// Run the test
   235  	gotInv, gotStatus, err := filesystem.Run(t.Context(), config)
   236  	if err != nil {
   237  		t.Fatalf("filesystem.Run(%v): %v", config, err)
   238  	}
   239  
   240  	// Expected inventory
   241  	wantInv := inventory.Inventory{
   242  		Packages: []*extractor.Package{
   243  			{
   244  				Name:      "Software",
   245  				Locations: []string{"disk.vmdk:1:file.txt"},
   246  				Plugins:   []string{"fake-ex-software", "fake-ex-software"}, // Expect duplicate due to observed behavior
   247  			},
   248  		},
   249  		EmbeddedFSs: []*inventory.EmbeddedFS{
   250  			{
   251  				Path:          "disk.vmdk:1",
   252  				GetEmbeddedFS: fakeExFS.getEmbeddedFS,
   253  			},
   254  		},
   255  	}
   256  
   257  	// Expected status
   258  	wantStatus := []*plugin.Status{
   259  		{Name: "fake-ex-fs", Version: 1, Status: success},
   260  		{Name: "fake-ex-software", Version: 1, Status: success},
   261  	}
   262  
   263  	// Sort package locations for comparison
   264  	for _, p := range gotInv.Packages {
   265  		sort.Strings(p.Locations)
   266  	}
   267  
   268  	// Compare inventory
   269  	if diff := cmp.Diff(wantInv, gotInv, cmpopts.SortSlices(extracttest.PackageCmpLess), fe.AllowUnexported, cmp.AllowUnexported(fakeExtractorFS{}, fakeExtractorSoftware{}), cmpopts.EquateErrors(), cmpopts.IgnoreFields(inventory.EmbeddedFS{}, "GetEmbeddedFS")); diff != "" {
   270  		t.Errorf("filesystem.Run(%v): unexpected findings (-want +got):\n%s", config, diff)
   271  	}
   272  
   273  	// Deduplicate status entries, keeping the latest for each extractor
   274  	seen := make(map[string]*plugin.Status)
   275  	for _, s := range gotStatus {
   276  		s.Status.FailureReason = ""
   277  		seen[s.Name] = s
   278  	}
   279  	var dedupedStatus []*plugin.Status
   280  	for _, s := range seen {
   281  		dedupedStatus = append(dedupedStatus, s)
   282  	}
   283  	sort.Slice(dedupedStatus, func(i, j int) bool {
   284  		return dedupedStatus[i].Name < dedupedStatus[j].Name
   285  	})
   286  
   287  	// Compare status
   288  	if diff := cmp.Diff(wantStatus, dedupedStatus, cmpopts.SortSlices(func(s1, s2 *plugin.Status) bool {
   289  		return s1.Name < s2.Name
   290  	})); diff != "" {
   291  		t.Errorf("filesystem.Run(%v): unexpected status (-want +got):\n%s", config, diff)
   292  	}
   293  }
   294  
   295  // A fake extractor that only extracts directories.
   296  type fakeExtractorDirs struct {
   297  	dir  string
   298  	name string
   299  }
   300  
   301  func (fakeExtractorDirs) Name() string { return "ex-dirs" }
   302  func (fakeExtractorDirs) Version() int { return 1 }
   303  func (fakeExtractorDirs) Requirements() *plugin.Capabilities {
   304  	return &plugin.Capabilities{ExtractFromDirs: true}
   305  }
   306  func (e fakeExtractorDirs) FileRequired(api filesystem.FileAPI) bool {
   307  	return api.Path() == e.dir
   308  }
   309  func (e fakeExtractorDirs) Extract(ctx context.Context, input *filesystem.ScanInput) (inventory.Inventory, error) {
   310  	path := filepath.ToSlash(input.Path)
   311  	if path == e.dir {
   312  		return inventory.Inventory{Packages: []*extractor.Package{&extractor.Package{
   313  			Name:      e.name,
   314  			Locations: []string{path},
   315  		}}}, nil
   316  	}
   317  	return inventory.Inventory{}, errors.New("unrecognized path")
   318  }
   319  
   320  func TestRunFS(t *testing.T) {
   321  	success := &plugin.ScanStatus{Status: plugin.ScanStatusSucceeded}
   322  	dir1 := "dir1"
   323  	path1 := "dir1/file1.txt"
   324  	path2 := "dir2/sub/file2.txt"
   325  	fsys := setupMapFS(t, mapFS{
   326  		".":                  nil,
   327  		"dir1":               nil,
   328  		"dir2":               nil,
   329  		"dir1/file1.txt":     []byte("Content"),
   330  		"dir2/sub/file2.txt": []byte("More content"),
   331  	})
   332  	name1 := "software1"
   333  	name2 := "software2"
   334  
   335  	fakeEx1 := fe.New("ex1", 1, []string{path1}, map[string]fe.NamesErr{path1: {Names: []string{name1}, Err: nil}})
   336  	fakeEx2 := fe.New("ex2", 2, []string{path2}, map[string]fe.NamesErr{path2: {Names: []string{name2}, Err: nil}})
   337  	fakeEx2WithPKG1 := fe.New("ex2", 2, []string{path2}, map[string]fe.NamesErr{path2: {Names: []string{name1}, Err: nil}})
   338  	fakeExWithPartialResult := fe.New("ex1", 1, []string{path1}, map[string]fe.NamesErr{path1: {Names: []string{name1}, Err: errors.New("extraction failed")}})
   339  	fakeExDirs := &fakeExtractorDirs{dir: dir1, name: name2}
   340  	fakeExDirsRequiresFile := &fakeExtractorDirs{dir: path1, name: name2}
   341  
   342  	cwd, err := os.Getwd()
   343  	if err != nil {
   344  		t.Fatalf("os.Getwd(): %v", err)
   345  	}
   346  
   347  	testCases := []struct {
   348  		desc             string
   349  		ex               []filesystem.Extractor
   350  		pathsToExtract   []string
   351  		ignoreSubDirs    bool
   352  		dirsToSkip       []string
   353  		skipDirGlob      string
   354  		skipDirRegex     string
   355  		storeAbsPath     bool
   356  		maxInodes        int
   357  		maxFileSizeBytes int
   358  		wantErr          error
   359  		wantPkg          inventory.Inventory
   360  		wantStatus       []*plugin.Status
   361  		wantInodeCount   int
   362  	}{
   363  		{
   364  			desc: "Extractors_successful",
   365  			ex:   []filesystem.Extractor{fakeEx1, fakeEx2},
   366  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   367  				{
   368  					Name:      name1,
   369  					Locations: []string{path1},
   370  					Plugins:   []string{fakeEx1.Name()},
   371  				},
   372  				{
   373  					Name:      name2,
   374  					Locations: []string{path2},
   375  					Plugins:   []string{fakeEx2.Name()},
   376  				},
   377  			}},
   378  			wantStatus: []*plugin.Status{
   379  				{Name: "ex1", Version: 1, Status: success},
   380  				{Name: "ex2", Version: 2, Status: success},
   381  			},
   382  			wantInodeCount: 6,
   383  		},
   384  		{
   385  			desc: "Dir_skipped",
   386  			ex:   []filesystem.Extractor{fakeEx1, fakeEx2},
   387  			// ScanRoot is CWD
   388  			dirsToSkip: []string{path.Join(cwd, "dir1")},
   389  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   390  				{
   391  					Name:      name2,
   392  					Locations: []string{path2},
   393  					Plugins:   []string{fakeEx2.Name()},
   394  				},
   395  			}},
   396  			wantStatus: []*plugin.Status{
   397  				{Name: "ex1", Version: 1, Status: success},
   398  				{Name: "ex2", Version: 2, Status: success},
   399  			},
   400  			wantInodeCount: 5,
   401  		},
   402  		{
   403  			desc:       "Dir skipped with absolute path",
   404  			ex:         []filesystem.Extractor{fakeEx1, fakeEx2},
   405  			dirsToSkip: []string{"dir1"},
   406  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   407  				{
   408  					Name:      name2,
   409  					Locations: []string{path2},
   410  					Plugins:   []string{fakeEx2.Name()},
   411  				},
   412  			}},
   413  			wantStatus: []*plugin.Status{
   414  				{Name: "ex1", Version: 1, Status: success},
   415  				{Name: "ex2", Version: 2, Status: success},
   416  			},
   417  			wantInodeCount: 5,
   418  		},
   419  		{
   420  			desc:         "Dir skipped using regex",
   421  			ex:           []filesystem.Extractor{fakeEx1, fakeEx2},
   422  			skipDirRegex: ".*1",
   423  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   424  				{
   425  					Name:      name2,
   426  					Locations: []string{path2},
   427  					Plugins:   []string{fakeEx2.Name()},
   428  				},
   429  			}},
   430  			wantStatus: []*plugin.Status{
   431  				{Name: "ex1", Version: 1, Status: success},
   432  				{Name: "ex2", Version: 2, Status: success},
   433  			},
   434  			wantInodeCount: 5,
   435  		},
   436  		{
   437  			desc:         "Dir skipped with full match of dirname",
   438  			ex:           []filesystem.Extractor{fakeEx1, fakeEx2},
   439  			skipDirRegex: "/sub$",
   440  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   441  				{
   442  					Name:      name1,
   443  					Locations: []string{path1},
   444  					Plugins:   []string{fakeEx1.Name()},
   445  				},
   446  			}},
   447  			wantStatus: []*plugin.Status{
   448  				{Name: "ex1", Version: 1, Status: success},
   449  				{Name: "ex2", Version: 2, Status: success},
   450  			},
   451  			wantInodeCount: 5,
   452  		},
   453  		{
   454  			desc:         "skip regex set but not match",
   455  			ex:           []filesystem.Extractor{fakeEx1, fakeEx2},
   456  			skipDirRegex: "asdf",
   457  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   458  				{
   459  					Name:      name1,
   460  					Locations: []string{path1},
   461  					Plugins:   []string{fakeEx1.Name()},
   462  				},
   463  				{
   464  					Name:      name2,
   465  					Locations: []string{path2},
   466  					Plugins:   []string{fakeEx2.Name()},
   467  				},
   468  			}},
   469  			wantStatus: []*plugin.Status{
   470  				{Name: "ex1", Version: 1, Status: success},
   471  				{Name: "ex2", Version: 2, Status: success},
   472  			},
   473  			wantInodeCount: 6,
   474  		},
   475  		{
   476  			desc:        "Dirs skipped using glob",
   477  			ex:          []filesystem.Extractor{fakeEx1, fakeEx2},
   478  			skipDirGlob: "dir*",
   479  			wantPkg:     inventory.Inventory{},
   480  			wantStatus: []*plugin.Status{
   481  				{Name: "ex1", Version: 1, Status: success},
   482  				{Name: "ex2", Version: 2, Status: success},
   483  			},
   484  			wantInodeCount: 3,
   485  		},
   486  		{
   487  			desc:        "Subdirectory skipped using glob",
   488  			ex:          []filesystem.Extractor{fakeEx1, fakeEx2},
   489  			skipDirGlob: "**/sub",
   490  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   491  				{
   492  					Name:      name1,
   493  					Locations: []string{path1},
   494  					Plugins:   []string{fakeEx1.Name()},
   495  				},
   496  			}},
   497  			wantStatus: []*plugin.Status{
   498  				{Name: "ex1", Version: 1, Status: success},
   499  				{Name: "ex2", Version: 2, Status: success},
   500  			},
   501  			wantInodeCount: 5,
   502  		},
   503  		{
   504  			desc:        "Dirs skipped using glob pattern lists",
   505  			ex:          []filesystem.Extractor{fakeEx1, fakeEx2},
   506  			skipDirGlob: "{dir1,dir2}",
   507  			wantPkg:     inventory.Inventory{},
   508  			wantStatus: []*plugin.Status{
   509  				{Name: "ex1", Version: 1, Status: success},
   510  				{Name: "ex2", Version: 2, Status: success},
   511  			},
   512  			wantInodeCount: 3,
   513  		},
   514  		{
   515  			desc:        "No directories matched using glob",
   516  			ex:          []filesystem.Extractor{fakeEx1, fakeEx2},
   517  			skipDirGlob: "none",
   518  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   519  				{
   520  					Name:      name1,
   521  					Locations: []string{path1},
   522  					Plugins:   []string{fakeEx1.Name()},
   523  				},
   524  				{
   525  					Name:      name2,
   526  					Locations: []string{path2},
   527  					Plugins:   []string{fakeEx2.Name()},
   528  				},
   529  			}},
   530  			wantStatus: []*plugin.Status{
   531  				{Name: "ex1", Version: 1, Status: success},
   532  				{Name: "ex2", Version: 2, Status: success},
   533  			},
   534  			wantInodeCount: 6,
   535  		},
   536  		{
   537  			desc: "Duplicate_inventory_results_kept_separate",
   538  			ex:   []filesystem.Extractor{fakeEx1, fakeEx2WithPKG1},
   539  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   540  				{
   541  					Name:      name1,
   542  					Locations: []string{path1},
   543  					Plugins:   []string{fakeEx1.Name()},
   544  				},
   545  				{
   546  					Name:      name1,
   547  					Locations: []string{path2},
   548  					Plugins:   []string{fakeEx2WithPKG1.Name()},
   549  				},
   550  			}},
   551  			wantStatus: []*plugin.Status{
   552  				{Name: "ex1", Version: 1, Status: success},
   553  				{Name: "ex2", Version: 2, Status: success},
   554  			},
   555  			wantInodeCount: 6,
   556  		},
   557  		{
   558  			desc: "Extract_specific_file",
   559  			ex:   []filesystem.Extractor{fakeEx1, fakeEx2},
   560  			// ScanRoot is CWD
   561  			pathsToExtract: []string{path.Join(cwd, path2)},
   562  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   563  				{
   564  					Name:      name2,
   565  					Locations: []string{path2},
   566  					Plugins:   []string{fakeEx2.Name()},
   567  				},
   568  			}},
   569  			wantStatus: []*plugin.Status{
   570  				{Name: "ex1", Version: 1, Status: success},
   571  				{Name: "ex2", Version: 2, Status: success},
   572  			},
   573  			wantInodeCount: 1,
   574  		},
   575  		{
   576  			desc:           "Extract specific file with absolute path",
   577  			ex:             []filesystem.Extractor{fakeEx1, fakeEx2},
   578  			pathsToExtract: []string{path2},
   579  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   580  				{
   581  					Name:      name2,
   582  					Locations: []string{path2},
   583  					Plugins:   []string{fakeEx2.Name()},
   584  				},
   585  			}},
   586  			wantStatus: []*plugin.Status{
   587  				{Name: "ex1", Version: 1, Status: success},
   588  				{Name: "ex2", Version: 2, Status: success},
   589  			},
   590  			wantInodeCount: 1,
   591  		},
   592  		{
   593  			desc:           "Extract directory contents",
   594  			ex:             []filesystem.Extractor{fakeEx1, fakeEx2},
   595  			pathsToExtract: []string{"dir2"},
   596  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   597  				{
   598  					Name:      name2,
   599  					Locations: []string{path2},
   600  					Plugins:   []string{fakeEx2.Name()},
   601  				},
   602  			}},
   603  			wantStatus: []*plugin.Status{
   604  				{Name: "ex1", Version: 1, Status: success},
   605  				{Name: "ex2", Version: 2, Status: success},
   606  			},
   607  			wantInodeCount: 3,
   608  		},
   609  		{
   610  			desc:           "Point to nonexistent file",
   611  			ex:             []filesystem.Extractor{fakeEx1, fakeEx2},
   612  			pathsToExtract: []string{"nonexistent"},
   613  			wantPkg:        inventory.Inventory{},
   614  			wantStatus: []*plugin.Status{
   615  				{Name: "ex1", Version: 1, Status: success},
   616  				{Name: "ex2", Version: 2, Status: success},
   617  			},
   618  			wantInodeCount: 1,
   619  		},
   620  		{
   621  			desc:           "Skip sub-dirs: Inventory found in root dir",
   622  			ex:             []filesystem.Extractor{fakeEx1, fakeEx2},
   623  			pathsToExtract: []string{"dir1"},
   624  			ignoreSubDirs:  true,
   625  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   626  				{
   627  					Name:      name1,
   628  					Locations: []string{path1},
   629  					Plugins:   []string{fakeEx1.Name()},
   630  				},
   631  			}},
   632  			wantStatus: []*plugin.Status{
   633  				{Name: "ex1", Version: 1, Status: success},
   634  				{Name: "ex2", Version: 2, Status: success},
   635  			},
   636  			wantInodeCount: 2,
   637  		},
   638  		{
   639  			desc:           "Skip sub-dirs: Inventory not found in root dir",
   640  			ex:             []filesystem.Extractor{fakeEx1, fakeEx2},
   641  			pathsToExtract: []string{"dir2"},
   642  			ignoreSubDirs:  true,
   643  			wantPkg:        inventory.Inventory{},
   644  			wantStatus: []*plugin.Status{
   645  				{Name: "ex1", Version: 1, Status: success},
   646  				{Name: "ex2", Version: 2, Status: success},
   647  			},
   648  			wantInodeCount: 2,
   649  		},
   650  		{
   651  			desc: "nil_result",
   652  			ex: []filesystem.Extractor{
   653  				// An Extractor that returns nil.
   654  				fe.New("ex1", 1, []string{path1}, map[string]fe.NamesErr{path1: {Names: nil, Err: nil}}),
   655  			},
   656  			wantPkg: inventory.Inventory{},
   657  			wantStatus: []*plugin.Status{
   658  				{Name: "ex1", Version: 1, Status: success},
   659  			},
   660  			wantInodeCount: 6,
   661  		},
   662  		{
   663  			desc: "Extraction_fails_with_partial_results",
   664  			ex:   []filesystem.Extractor{fakeExWithPartialResult},
   665  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   666  				{
   667  					Name:      name1,
   668  					Locations: []string{path1},
   669  					Plugins:   []string{fakeExWithPartialResult.Name()},
   670  				},
   671  			}},
   672  			wantStatus: []*plugin.Status{
   673  				{Name: "ex1", Version: 1, Status: &plugin.ScanStatus{
   674  					Status:        plugin.ScanStatusPartiallySucceeded,
   675  					FailureReason: "encountered 1 error(s) while running plugin; check file-specific errors for details",
   676  					FileErrors: []*plugin.FileError{
   677  						{FilePath: path1, ErrorMessage: "extraction failed"},
   678  					},
   679  				}},
   680  			},
   681  			wantInodeCount: 6,
   682  		},
   683  		{
   684  			desc: "Extraction_fails_with_no_results",
   685  			ex: []filesystem.Extractor{
   686  				fe.New("ex1", 1, []string{path1}, map[string]fe.NamesErr{path1: {Names: nil, Err: errors.New("extraction failed")}}),
   687  			},
   688  			wantPkg: inventory.Inventory{},
   689  			wantStatus: []*plugin.Status{
   690  				{Name: "ex1", Version: 1, Status: &plugin.ScanStatus{
   691  					Status:        plugin.ScanStatusFailed,
   692  					FailureReason: "encountered 1 error(s) while running plugin; check file-specific errors for details",
   693  					FileErrors: []*plugin.FileError{
   694  						{FilePath: path1, ErrorMessage: "extraction failed"},
   695  					},
   696  				}},
   697  			},
   698  			wantInodeCount: 6,
   699  		},
   700  		{
   701  			desc: "Extraction_fails_several_times",
   702  			ex: []filesystem.Extractor{
   703  				fe.New("ex1", 1, []string{path1, path2}, map[string]fe.NamesErr{
   704  					path1: {Names: nil, Err: errors.New("extraction failed")},
   705  					path2: {Names: nil, Err: errors.New("extraction failed")},
   706  				}),
   707  			},
   708  			wantPkg: inventory.Inventory{},
   709  			wantStatus: []*plugin.Status{
   710  				{Name: "ex1", Version: 1, Status: &plugin.ScanStatus{
   711  					Status:        plugin.ScanStatusFailed,
   712  					FailureReason: "encountered 2 error(s) while running plugin; check file-specific errors for details",
   713  					FileErrors: []*plugin.FileError{
   714  						{FilePath: path1, ErrorMessage: "extraction failed"},
   715  						{FilePath: path2, ErrorMessage: "extraction failed"},
   716  					},
   717  				}},
   718  			},
   719  			wantInodeCount: 6,
   720  		},
   721  		{
   722  			desc:      "More inodes visited than limit, Error",
   723  			ex:        []filesystem.Extractor{fakeEx1, fakeEx2},
   724  			maxInodes: 2,
   725  			wantPkg:   inventory.Inventory{},
   726  			wantStatus: []*plugin.Status{
   727  				{Name: "ex1", Version: 1, Status: success},
   728  				{Name: "ex2", Version: 2, Status: success},
   729  			},
   730  			wantInodeCount: 2,
   731  			wantErr:        cmpopts.AnyError,
   732  		},
   733  		{
   734  			desc:      "Less inodes visited than limit, no Error",
   735  			ex:        []filesystem.Extractor{fakeEx1, fakeEx2},
   736  			maxInodes: 6,
   737  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   738  				{
   739  					Name:      name1,
   740  					Locations: []string{path1},
   741  					Plugins:   []string{fakeEx1.Name()},
   742  				},
   743  				{
   744  					Name:      name2,
   745  					Locations: []string{path2},
   746  					Plugins:   []string{fakeEx2.Name()},
   747  				},
   748  			}},
   749  			wantStatus: []*plugin.Status{
   750  				{Name: "ex1", Version: 1, Status: success},
   751  				{Name: "ex2", Version: 2, Status: success},
   752  			},
   753  			wantInodeCount: 6,
   754  		},
   755  		{
   756  			desc:             "Large files skipped",
   757  			ex:               []filesystem.Extractor{fakeEx1, fakeEx2},
   758  			maxInodes:        6,
   759  			maxFileSizeBytes: 10,
   760  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   761  				{
   762  					Name:      name1,
   763  					Locations: []string{path1},
   764  					Plugins:   []string{fakeEx1.Name()},
   765  				},
   766  			}},
   767  			wantStatus: []*plugin.Status{
   768  				{Name: "ex1", Version: 1, Status: success},
   769  				{Name: "ex2", Version: 2, Status: success},
   770  			},
   771  			wantInodeCount: 6,
   772  		},
   773  		{
   774  			desc: "Extractors_successful_store_absolute_path_when_requested",
   775  			ex:   []filesystem.Extractor{fakeEx1, fakeEx2},
   776  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   777  				{
   778  					Name:      name1,
   779  					Locations: []string{filepath.Join(cwd, path1)},
   780  					Plugins:   []string{fakeEx1.Name()},
   781  				},
   782  				{
   783  					Name:      name2,
   784  					Locations: []string{filepath.Join(cwd, path2)},
   785  					Plugins:   []string{fakeEx2.Name()},
   786  				},
   787  			}},
   788  			storeAbsPath: true,
   789  			wantStatus: []*plugin.Status{
   790  				{Name: "ex1", Version: 1, Status: success},
   791  				{Name: "ex2", Version: 2, Status: success},
   792  			},
   793  			wantInodeCount: 6,
   794  		},
   795  		{
   796  			desc: "Extractor_runs_on_directory",
   797  			ex:   []filesystem.Extractor{fakeEx1, fakeExDirs},
   798  			wantPkg: inventory.Inventory{Packages: []*extractor.Package{
   799  				{
   800  					Name:      name1,
   801  					Locations: []string{path1},
   802  					Plugins:   []string{fakeEx1.Name()},
   803  				},
   804  				{
   805  					Name:      name2,
   806  					Locations: []string{dir1},
   807  					Plugins:   []string{fakeExDirs.Name()},
   808  				},
   809  			}},
   810  			wantStatus: []*plugin.Status{
   811  				{Name: "ex1", Version: 1, Status: success},
   812  				{Name: "ex-dirs", Version: 1, Status: success},
   813  			},
   814  			wantInodeCount: 6,
   815  		},
   816  		{
   817  			desc:    "Directory Extractor ignores files",
   818  			ex:      []filesystem.Extractor{fakeExDirsRequiresFile},
   819  			wantPkg: inventory.Inventory{Packages: nil},
   820  			wantStatus: []*plugin.Status{
   821  				{Name: "ex-dirs", Version: 1, Status: success},
   822  			},
   823  			wantInodeCount: 6,
   824  		},
   825  	}
   826  
   827  	for _, tc := range testCases {
   828  		t.Run(tc.desc, func(t *testing.T) {
   829  			fc := &fakeCollector{}
   830  			var skipDirRegex *regexp.Regexp
   831  			var skipDirGlob glob.Glob
   832  			if tc.skipDirRegex != "" {
   833  				skipDirRegex = regexp.MustCompile(tc.skipDirRegex)
   834  			}
   835  			if tc.skipDirGlob != "" {
   836  				skipDirGlob = glob.MustCompile(tc.skipDirGlob)
   837  			}
   838  			config := &filesystem.Config{
   839  				Extractors:     tc.ex,
   840  				PathsToExtract: tc.pathsToExtract,
   841  				IgnoreSubDirs:  tc.ignoreSubDirs,
   842  				DirsToSkip:     tc.dirsToSkip,
   843  				SkipDirRegex:   skipDirRegex,
   844  				SkipDirGlob:    skipDirGlob,
   845  				MaxInodes:      tc.maxInodes,
   846  				MaxFileSize:    tc.maxFileSizeBytes,
   847  				ScanRoots: []*scalibrfs.ScanRoot{{
   848  					FS: fsys, Path: ".",
   849  				}},
   850  				Stats:             fc,
   851  				StoreAbsolutePath: tc.storeAbsPath,
   852  			}
   853  			wc, err := filesystem.InitWalkContext(
   854  				t.Context(), config, []*scalibrfs.ScanRoot{{
   855  					FS: fsys, Path: cwd,
   856  				}},
   857  			)
   858  			if err != nil {
   859  				t.Fatalf("filesystem.InitializeWalkContext(..., %v): %v", fsys, err)
   860  			}
   861  			if err = wc.PrepareNewScan(cwd, fsys); err != nil {
   862  				t.Fatalf("wc.UpdateScanRoot(..., %v): %v", fsys, err)
   863  			}
   864  			gotInv, gotStatus, err := filesystem.RunFS(t.Context(), config, wc)
   865  			if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" {
   866  				t.Errorf("extractor.Run(%v) error got diff (-want +got):\n%s", tc.ex, diff)
   867  			}
   868  
   869  			if fc.AfterInodeVisitedCount != tc.wantInodeCount {
   870  				t.Errorf("extractor.Run(%v) inodes visited: got %d, want %d", tc.ex, fc.AfterInodeVisitedCount, tc.wantInodeCount)
   871  			}
   872  
   873  			// The order of the locations doesn't matter.
   874  			for _, p := range gotInv.Packages {
   875  				sort.Strings(p.Locations)
   876  			}
   877  
   878  			if diff := cmp.Diff(tc.wantPkg, gotInv, cmpopts.SortSlices(extracttest.PackageCmpLess), fe.AllowUnexported, cmp.AllowUnexported(fakeExtractorDirs{}), cmpopts.EquateErrors()); diff != "" {
   879  				t.Errorf("extractor.Run(%v): unexpected findings (-want +got):\n%s", tc.ex, diff)
   880  			}
   881  
   882  			// The order of the statuses doesn't matter.
   883  			for _, s := range gotStatus {
   884  				if s.Status.FileErrors != nil {
   885  					sort.Slice(s.Status.FileErrors, func(i, j int) bool {
   886  						return s.Status.FileErrors[i].FilePath < s.Status.FileErrors[j].FilePath
   887  					})
   888  				}
   889  			}
   890  
   891  			sortStatus := func(s1, s2 *plugin.Status) bool {
   892  				return s1.Name < s2.Name
   893  			}
   894  			if diff := cmp.Diff(tc.wantStatus, gotStatus, cmpopts.SortSlices(sortStatus)); diff != "" {
   895  				t.Errorf("extractor.Run(%v): unexpected status (-want +got):\n%s", tc.ex, diff)
   896  			}
   897  		})
   898  	}
   899  }
   900  
   901  func TestRunFSGitignore(t *testing.T) {
   902  	cwd, err := os.Getwd()
   903  	if err != nil {
   904  		t.Fatalf("os.Getwd(): %v", err)
   905  	}
   906  
   907  	name1 := "software1"
   908  	name2 := "software2"
   909  	path1 := "dir1/file1.txt"
   910  	path2 := "dir2/sub/file2.txt"
   911  	fakeEx1 := fe.New("ex1", 1, []string{path1}, map[string]fe.NamesErr{path1: {Names: []string{name1}, Err: nil}})
   912  	fakeEx2 := fe.New("ex2", 2, []string{path2}, map[string]fe.NamesErr{path2: {Names: []string{name2}, Err: nil}})
   913  	ex := []filesystem.Extractor{fakeEx1, fakeEx2}
   914  
   915  	testCases := []struct {
   916  		desc           string
   917  		mapFS          mapFS
   918  		pathToExtract  string
   919  		ignoreSubDirs  bool
   920  		wantPkg1       bool
   921  		wantPkg2       bool
   922  		wantInodeCount int
   923  	}{
   924  		{
   925  			desc: "Skip_file",
   926  			mapFS: mapFS{
   927  				".":               nil,
   928  				"dir1":            nil,
   929  				"dir1/file1.txt":  []byte("Content 1"),
   930  				"dir1/.gitignore": []byte("file1.txt"),
   931  			},
   932  			pathToExtract:  "dir1",
   933  			wantPkg1:       false,
   934  			wantInodeCount: 3,
   935  		},
   936  		{
   937  			desc: "Skip_dir",
   938  			mapFS: mapFS{
   939  				".":                  nil,
   940  				"dir2":               nil,
   941  				"dir2/sub":           nil,
   942  				"dir2/sub/file2.txt": []byte("Content 2"),
   943  				"dir2/.gitignore":    []byte("sub"),
   944  			},
   945  			pathToExtract:  "",
   946  			wantPkg2:       false,
   947  			wantInodeCount: 4,
   948  		},
   949  		{
   950  			desc: "Dont_skip_if_no_match",
   951  			mapFS: mapFS{
   952  				".":               nil,
   953  				"dir1":            nil,
   954  				"dir1/file1.txt":  []byte("Content 1"),
   955  				"dir1/.gitignore": []byte("no-match.txt"),
   956  			},
   957  			pathToExtract:  "",
   958  			wantPkg1:       true,
   959  			wantPkg2:       false,
   960  			wantInodeCount: 4,
   961  		},
   962  		{
   963  			desc: "Skip_based_on_parent_gitignore",
   964  			mapFS: mapFS{
   965  				".":                  nil,
   966  				"dir2":               nil,
   967  				"dir2/sub":           nil,
   968  				"dir2/sub/file2.txt": []byte("Content 1"),
   969  				"dir2/.gitignore":    []byte("file2.txt"),
   970  			},
   971  			pathToExtract:  "dir2/sub",
   972  			wantPkg1:       false,
   973  			wantInodeCount: 2,
   974  		},
   975  		{
   976  			desc: "Skip_based_on_child_gitignore",
   977  			mapFS: mapFS{
   978  				".":               nil,
   979  				"dir1":            nil,
   980  				"dir2":            nil,
   981  				"dir2/sub":        nil,
   982  				"dir1/file1.txt":  []byte("Content 1"),
   983  				"dir1/.gitignore": []byte("file1.txt\nfile2.txt"),
   984  				// Not skipped since the skip pattern is in dir1
   985  				"dir2/sub/file2.txt": []byte("Content 2"),
   986  			},
   987  			pathToExtract:  "",
   988  			wantPkg1:       false,
   989  			wantPkg2:       true,
   990  			wantInodeCount: 7,
   991  		},
   992  		{
   993  			desc: "ignore_sub_dirs",
   994  			mapFS: mapFS{
   995  				".":              nil,
   996  				"dir":            nil,
   997  				".gitignore":     []byte("file1.txt"),
   998  				"file1.txt":      []byte("Content 1"),
   999  				"dir/.gitignore": []byte("file1.txt"),
  1000  				"dir/file2.txt":  []byte("Content 2"),
  1001  			},
  1002  			pathToExtract:  "",
  1003  			ignoreSubDirs:  true,
  1004  			wantPkg1:       false, // Skipped because of .gitignore
  1005  			wantPkg2:       false, // Skipped because of IgnoreSubDirs
  1006  			wantInodeCount: 4,
  1007  		},
  1008  	}
  1009  
  1010  	for _, tc := range testCases {
  1011  		t.Run(tc.desc, func(t *testing.T) {
  1012  			fc := &fakeCollector{}
  1013  			fsys := setupMapFS(t, tc.mapFS)
  1014  			config := &filesystem.Config{
  1015  				Extractors:     ex,
  1016  				PathsToExtract: []string{tc.pathToExtract},
  1017  				IgnoreSubDirs:  tc.ignoreSubDirs,
  1018  				UseGitignore:   true,
  1019  				ScanRoots: []*scalibrfs.ScanRoot{{
  1020  					FS: fsys, Path: ".",
  1021  				}},
  1022  				Stats:             fc,
  1023  				StoreAbsolutePath: false,
  1024  			}
  1025  			wc, err := filesystem.InitWalkContext(
  1026  				t.Context(), config, []*scalibrfs.ScanRoot{{
  1027  					FS: fsys, Path: cwd,
  1028  				}},
  1029  			)
  1030  			if err != nil {
  1031  				t.Fatalf("filesystem.InitializeWalkContext(..., %v): %v", fsys, err)
  1032  			}
  1033  			if err = wc.PrepareNewScan(cwd, fsys); err != nil {
  1034  				t.Fatalf("wc.UpdateScanRoot(..., %v): %v", fsys, err)
  1035  			}
  1036  			gotInv, _, err := filesystem.RunFS(t.Context(), config, wc)
  1037  			if err != nil {
  1038  				t.Errorf("filesystem.RunFS(%v, %v): %v", config, wc, err)
  1039  			}
  1040  
  1041  			if fc.AfterInodeVisitedCount != tc.wantInodeCount {
  1042  				t.Errorf("filesystem.RunFS(%v, %v) inodes visited: got %d, want %d", config, wc, fc.AfterInodeVisitedCount, tc.wantInodeCount)
  1043  			}
  1044  
  1045  			gotPkg1 := slices.ContainsFunc(gotInv.Packages, func(p *extractor.Package) bool {
  1046  				return p.Name == name1
  1047  			})
  1048  			gotPkg2 := slices.ContainsFunc(gotInv.Packages, func(p *extractor.Package) bool {
  1049  				return p.Name == name2
  1050  			})
  1051  			if gotPkg1 != tc.wantPkg1 {
  1052  				t.Errorf("filesystem.Run(%v, %v): got inv1: %v, want: %v", config, wc, gotPkg1, tc.wantPkg1)
  1053  			}
  1054  			if gotPkg2 != tc.wantPkg2 {
  1055  				t.Errorf("filesystem.Run(%v, %v): got inv2: %v, want: %v", config, wc, gotPkg2, tc.wantPkg2)
  1056  			}
  1057  		})
  1058  	}
  1059  }
  1060  
  1061  func setupMapFS(t *testing.T, mapFS mapFS) scalibrfs.FS {
  1062  	t.Helper()
  1063  
  1064  	root := t.TempDir()
  1065  	for path, content := range mapFS {
  1066  		path = filepath.FromSlash(path)
  1067  		if content == nil {
  1068  			err := os.MkdirAll(filepath.Join(root, path), fs.ModePerm)
  1069  			if err != nil {
  1070  				t.Fatalf("os.MkdirAll(%q): %v", path, err)
  1071  			}
  1072  		} else {
  1073  			dir := filepath.Dir(path)
  1074  			err := os.MkdirAll(filepath.Join(root, dir), fs.ModePerm)
  1075  			if err != nil {
  1076  				t.Fatalf("os.MkdirAll(%q): %v", dir, err)
  1077  			}
  1078  			err = os.WriteFile(filepath.Join(root, path), content, fs.ModePerm)
  1079  			if err != nil {
  1080  				t.Fatalf("os.WriteFile(%q): %v", path, err)
  1081  			}
  1082  		}
  1083  	}
  1084  	return scalibrfs.DirFS(root)
  1085  }
  1086  
  1087  // To not break the test every time we add a new metric, we inherit from the NoopCollector.
  1088  type fakeCollector struct {
  1089  	stats.NoopCollector
  1090  
  1091  	AfterInodeVisitedCount int
  1092  }
  1093  
  1094  func (c *fakeCollector) AfterInodeVisited(path string) { c.AfterInodeVisitedCount++ }
  1095  
  1096  // A fake implementation of fs.FS with a single file under root which errors when its opened.
  1097  type fakeFS struct{}
  1098  
  1099  func (fakeFS) Open(name string) (fs.File, error) {
  1100  	if name == "." {
  1101  		return &fakeDir{dirs: []fs.DirEntry{&fakeDirEntry{}}}, nil
  1102  	}
  1103  	return nil, errors.New("failed to open")
  1104  }
  1105  func (fakeFS) ReadDir(name string) ([]fs.DirEntry, error) {
  1106  	return nil, errors.New("not implemented")
  1107  }
  1108  func (fakeFS) Stat(name string) (fs.FileInfo, error) {
  1109  	return &fakeFileInfo{dir: true}, nil
  1110  }
  1111  
  1112  type fakeDir struct {
  1113  	dirs []fs.DirEntry
  1114  }
  1115  
  1116  func (fakeDir) Stat() (fs.FileInfo, error) { return &fakeFileInfo{dir: true}, nil }
  1117  func (fakeDir) Read([]byte) (int, error)   { return 0, errors.New("failed to read") }
  1118  func (fakeDir) Close() error               { return nil }
  1119  func (f *fakeDir) ReadDir(n int) ([]fs.DirEntry, error) {
  1120  	if n <= 0 {
  1121  		t := f.dirs
  1122  		f.dirs = []fs.DirEntry{}
  1123  		return t, nil
  1124  	}
  1125  	if len(f.dirs) == 0 {
  1126  		return f.dirs, io.EOF
  1127  	}
  1128  	n = min(n, len(f.dirs))
  1129  	t := f.dirs[:n]
  1130  	f.dirs = f.dirs[n:]
  1131  	return t, nil
  1132  }
  1133  
  1134  type fakeFileInfo struct{ dir bool }
  1135  
  1136  func (fakeFileInfo) Name() string { return "/" }
  1137  func (fakeFileInfo) Size() int64  { return 1 }
  1138  func (i *fakeFileInfo) Mode() fs.FileMode {
  1139  	if i.dir {
  1140  		return fs.ModeDir + 0777
  1141  	}
  1142  	return 0777
  1143  }
  1144  func (fakeFileInfo) ModTime() time.Time { return time.Now() }
  1145  func (i *fakeFileInfo) IsDir() bool     { return i.dir }
  1146  func (fakeFileInfo) Sys() any           { return nil }
  1147  
  1148  type fakeDirEntry struct{}
  1149  
  1150  func (fakeDirEntry) Name() string               { return "file" }
  1151  func (fakeDirEntry) IsDir() bool                { return false }
  1152  func (fakeDirEntry) Type() fs.FileMode          { return 0777 }
  1153  func (fakeDirEntry) Info() (fs.FileInfo, error) { return &fakeFileInfo{dir: false}, nil }
  1154  
  1155  func TestRunFS_ReadError(t *testing.T) {
  1156  	ex := []filesystem.Extractor{
  1157  		fe.New("ex1", 1, []string{"file"},
  1158  			map[string]fe.NamesErr{"file": {Names: []string{"software"}, Err: nil}}),
  1159  	}
  1160  	wantStatus := []*plugin.Status{
  1161  		{Name: "ex1", Version: 1, Status: &plugin.ScanStatus{
  1162  			Status: plugin.ScanStatusFailed, FailureReason: "encountered 1 error(s) while running plugin; check file-specific errors for details", FileErrors: []*plugin.FileError{
  1163  				{FilePath: "file", ErrorMessage: "Open(file): failed to open"},
  1164  			},
  1165  		}},
  1166  	}
  1167  	fsys := &fakeFS{}
  1168  	config := &filesystem.Config{
  1169  		Extractors: ex,
  1170  		DirsToSkip: []string{},
  1171  		ScanRoots: []*scalibrfs.ScanRoot{{
  1172  			FS: fsys, Path: ".",
  1173  		}},
  1174  		Stats: stats.NoopCollector{},
  1175  	}
  1176  	wc, err := filesystem.InitWalkContext(t.Context(), config, config.ScanRoots)
  1177  	if err != nil {
  1178  		t.Fatalf("filesystem.InitializeWalkContext(%v): %v", config, err)
  1179  	}
  1180  	if err := wc.PrepareNewScan(".", fsys); err != nil {
  1181  		t.Fatalf("wc.UpdateScanRoot(%v): %v", config, err)
  1182  	}
  1183  	gotInv, gotStatus, err := filesystem.RunFS(t.Context(), config, wc)
  1184  	if err != nil {
  1185  		t.Fatalf("extractor.Run(%v): %v", ex, err)
  1186  	}
  1187  
  1188  	if !gotInv.IsEmpty() {
  1189  		t.Errorf("extractor.Run(%v): expected empty inventory, got %v", ex, gotInv)
  1190  	}
  1191  
  1192  	if diff := cmp.Diff(wantStatus, gotStatus); diff != "" {
  1193  		t.Errorf("extractor.Run(%v): unexpected status (-want +got):\n%s", ex, diff)
  1194  	}
  1195  }
  1196  
  1197  type fakeFileAPI struct {
  1198  	path string
  1199  	info fakefs.FakeFileInfo
  1200  }
  1201  
  1202  func (f fakeFileAPI) Path() string { return f.path }
  1203  func (f fakeFileAPI) Stat() (fs.FileInfo, error) {
  1204  	return f.info, nil
  1205  }
  1206  
  1207  func TestIsInterestingExecutable(t *testing.T) {
  1208  	tests := []struct {
  1209  		name        string
  1210  		path        string
  1211  		mode        fs.FileMode
  1212  		want        bool
  1213  		wantWindows bool
  1214  	}{
  1215  		{
  1216  			name: "user_executable",
  1217  			path: "some/path/a",
  1218  			mode: 0766,
  1219  			want: true,
  1220  		},
  1221  		{
  1222  			name: "group_executable",
  1223  			path: "some/path/a",
  1224  			mode: 0676,
  1225  			want: true,
  1226  		},
  1227  		{
  1228  			name: "other_executable",
  1229  			path: "some/path/a",
  1230  			mode: 0667,
  1231  			want: true,
  1232  		},
  1233  		{
  1234  			name: "windows_exe",
  1235  			path: "some/path/a.exe",
  1236  			mode: 0666,
  1237  			want: true,
  1238  		},
  1239  		{
  1240  			name: "windows_dll",
  1241  			path: "some/path/a.dll",
  1242  			mode: 0666,
  1243  			want: true,
  1244  		},
  1245  		{
  1246  			name:        "not executable bit set",
  1247  			path:        "some/path/a",
  1248  			mode:        0640,
  1249  			want:        false,
  1250  			wantWindows: false,
  1251  		},
  1252  		{
  1253  			name: "executable_required",
  1254  			path: "some/path/a",
  1255  			mode: 0766,
  1256  			want: true,
  1257  		},
  1258  		{
  1259  			name: "unwanted_extension",
  1260  			path: "some/path/a.html",
  1261  			mode: 0766,
  1262  			want: false,
  1263  		},
  1264  		{
  1265  			name: "another_unwanted_extension",
  1266  			path: "some/path/a.txt",
  1267  			mode: 0766,
  1268  			want: false,
  1269  		},
  1270  		{
  1271  			name: "python_script_without_execute_permissions",
  1272  			path: "some/path/a.py",
  1273  			mode: 0666,
  1274  			want: true,
  1275  		},
  1276  		{
  1277  			name: "shell_script_without_execute_permissions",
  1278  			path: "some/path/a.sh",
  1279  			mode: 0666,
  1280  			want: true,
  1281  		},
  1282  		{
  1283  			name: "shared_library_without_execute_permissions",
  1284  			path: "some/path/a.so",
  1285  			mode: 0666,
  1286  			want: true,
  1287  		},
  1288  		{
  1289  			name: "binary_file_without_execute_permissions",
  1290  			path: "some/path/a.bin",
  1291  			mode: 0666,
  1292  			want: true,
  1293  		},
  1294  		{
  1295  			name: "versioned_shared_library",
  1296  			path: "some/path/library.so.1",
  1297  			mode: 0666,
  1298  			want: true,
  1299  		},
  1300  		{
  1301  			name: "versioned_shared_library_with_multiple_digits",
  1302  			path: "some/path/library.so.12",
  1303  			mode: 0666,
  1304  			want: true,
  1305  		},
  1306  		{
  1307  			name: "not_a_versioned_shared_library",
  1308  			path: "some/path/library.so.foo",
  1309  			mode: 0666,
  1310  			want: false,
  1311  		},
  1312  	}
  1313  
  1314  	for _, tt := range tests {
  1315  		t.Run(tt.name, func(t *testing.T) {
  1316  			got := filesystem.IsInterestingExecutable(fakeFileAPI{tt.path, fakefs.FakeFileInfo{
  1317  				FileName: filepath.Base(tt.path),
  1318  				FileMode: tt.mode,
  1319  			}})
  1320  
  1321  			want := tt.want
  1322  			// For Windows we don't check the executable bit on files.
  1323  			if runtime.GOOS == "windows" && !want {
  1324  				want = tt.wantWindows
  1325  			}
  1326  
  1327  			if got != want {
  1328  				t.Fatalf("FileRequired(%s): got %v, want %v", tt.path, got, want)
  1329  			}
  1330  		})
  1331  	}
  1332  }