github.com/anchore/syft@v1.38.2/syft/internal/fileresolver/directory_indexer_test.go (about)

     1  package fileresolver
     2  
     3  import (
     4  	"fmt"
     5  	"io/fs"
     6  	"os"
     7  	"path"
     8  	"path/filepath"
     9  	"runtime"
    10  	"sort"
    11  	"strings"
    12  	"testing"
    13  
    14  	"github.com/google/go-cmp/cmp"
    15  	"github.com/scylladb/go-set/strset"
    16  	"github.com/stretchr/testify/assert"
    17  	"github.com/stretchr/testify/require"
    18  	"github.com/wagoodman/go-progress"
    19  
    20  	"github.com/anchore/stereoscope/pkg/file"
    21  )
    22  
    23  type indexerMock struct {
    24  	observedRoots   []string
    25  	additionalRoots map[string][]string
    26  }
    27  
    28  func (m *indexerMock) indexer(s string, _ *progress.AtomicStage) ([]string, error) {
    29  	m.observedRoots = append(m.observedRoots, s)
    30  	return m.additionalRoots[s], nil
    31  }
    32  
    33  func Test_indexAllRoots(t *testing.T) {
    34  	tests := []struct {
    35  		name          string
    36  		root          string
    37  		mock          indexerMock
    38  		expectedRoots []string
    39  	}{
    40  		{
    41  			name: "no additional roots",
    42  			root: "a/place",
    43  			mock: indexerMock{
    44  				additionalRoots: make(map[string][]string),
    45  			},
    46  			expectedRoots: []string{
    47  				"a/place",
    48  			},
    49  		},
    50  		{
    51  			name: "additional roots from a single call",
    52  			root: "a/place",
    53  			mock: indexerMock{
    54  				additionalRoots: map[string][]string{
    55  					"a/place": {
    56  						"another/place",
    57  						"yet-another/place",
    58  					},
    59  				},
    60  			},
    61  			expectedRoots: []string{
    62  				"a/place",
    63  				"another/place",
    64  				"yet-another/place",
    65  			},
    66  		},
    67  		{
    68  			name: "additional roots from a multiple calls",
    69  			root: "a/place",
    70  			mock: indexerMock{
    71  				additionalRoots: map[string][]string{
    72  					"a/place": {
    73  						"another/place",
    74  						"yet-another/place",
    75  					},
    76  					"yet-another/place": {
    77  						"a-quiet-place-2",
    78  						"a-final/place",
    79  					},
    80  				},
    81  			},
    82  			expectedRoots: []string{
    83  				"a/place",
    84  				"another/place",
    85  				"yet-another/place",
    86  				"a-quiet-place-2",
    87  				"a-final/place",
    88  			},
    89  		},
    90  	}
    91  
    92  	for _, test := range tests {
    93  		t.Run(test.name, func(t *testing.T) {
    94  			assert.NoError(t, indexAllRoots(test.root, test.mock.indexer))
    95  		})
    96  	}
    97  }
    98  
    99  func TestDirectoryIndexer_handleFileAccessErr(t *testing.T) {
   100  	tests := []struct {
   101  		name                string
   102  		input               error
   103  		expectedPathTracked bool
   104  	}{
   105  		{
   106  			name:                "permission error does not propagate",
   107  			input:               os.ErrPermission,
   108  			expectedPathTracked: true,
   109  		},
   110  		{
   111  			name:                "file does not exist error does not propagate",
   112  			input:               os.ErrNotExist,
   113  			expectedPathTracked: true,
   114  		},
   115  		{
   116  			name:                "non-permission errors are tracked",
   117  			input:               os.ErrInvalid,
   118  			expectedPathTracked: true,
   119  		},
   120  		{
   121  			name:                "non-errors ignored",
   122  			input:               nil,
   123  			expectedPathTracked: false,
   124  		},
   125  	}
   126  
   127  	for _, test := range tests {
   128  		t.Run(test.name, func(t *testing.T) {
   129  			r := directoryIndexer{
   130  				errPaths: make(map[string]error),
   131  			}
   132  			p := "a/place"
   133  			assert.Equal(t, r.isFileAccessErr(p, test.input), test.expectedPathTracked)
   134  			_, exists := r.errPaths[p]
   135  			assert.Equal(t, test.expectedPathTracked, exists)
   136  		})
   137  	}
   138  }
   139  
   140  func TestDirectoryIndexer_IncludeRootPathInIndex(t *testing.T) {
   141  	filterFn := func(_, path string, _ os.FileInfo, _ error) error {
   142  		if path != "/" {
   143  			return fs.SkipDir
   144  		}
   145  		return nil
   146  	}
   147  
   148  	indexer := newDirectoryIndexer("/", "", filterFn)
   149  	tree, index, err := indexer.build()
   150  	require.NoError(t, err)
   151  
   152  	exists, ref, err := tree.File(file.Path("/"))
   153  	require.NoError(t, err)
   154  	require.NotNil(t, ref)
   155  	assert.True(t, exists)
   156  
   157  	_, err = index.Get(*ref.Reference)
   158  	require.NoError(t, err)
   159  }
   160  
   161  func TestDirectoryIndexer_indexPath_skipsNilFileInfo(t *testing.T) {
   162  	// TODO: Ideally we can use an OS abstraction, which would obviate the need for real FS setup.
   163  	tempFile, err := os.CreateTemp("", "")
   164  	require.NoError(t, err)
   165  
   166  	indexer := newDirectoryIndexer(tempFile.Name(), "")
   167  
   168  	t.Run("filtering path with nil os.FileInfo", func(t *testing.T) {
   169  		assert.NotPanics(t, func() {
   170  			_, err := indexer.indexPath("/dont-care", nil, nil)
   171  			assert.NoError(t, err)
   172  			assert.False(t, indexer.tree.HasPath("/dont-care"))
   173  		})
   174  	})
   175  }
   176  
   177  func TestDirectoryIndexer_index(t *testing.T) {
   178  	// note: this test is testing the effects from NewFromDirectory, indexTree, and addPathToIndex
   179  	indexer := newDirectoryIndexer("test-fixtures/system_paths/target", "")
   180  	tree, index, err := indexer.build()
   181  	require.NoError(t, err)
   182  
   183  	tests := []struct {
   184  		name string
   185  		path string
   186  	}{
   187  		{
   188  			name: "has dir",
   189  			path: "test-fixtures/system_paths/target/home",
   190  		},
   191  		{
   192  			name: "has path",
   193  			path: "test-fixtures/system_paths/target/home/place",
   194  		},
   195  		{
   196  			name: "has symlink",
   197  			path: "test-fixtures/system_paths/target/link/a-symlink",
   198  		},
   199  		{
   200  			name: "has symlink target",
   201  			path: "test-fixtures/system_paths/outside_root/link_target/place",
   202  		},
   203  	}
   204  	for _, test := range tests {
   205  		t.Run(test.name, func(t *testing.T) {
   206  			info, err := os.Stat(test.path)
   207  			assert.NoError(t, err)
   208  
   209  			// note: the index uses absolute paths, so assertions MUST keep this in mind
   210  			cwd, err := os.Getwd()
   211  			require.NoError(t, err)
   212  
   213  			p := file.Path(path.Join(cwd, test.path))
   214  			assert.Equal(t, true, tree.HasPath(p))
   215  			exists, ref, err := tree.File(p)
   216  			assert.Equal(t, true, exists)
   217  			if assert.NoError(t, err) {
   218  				return
   219  			}
   220  
   221  			entry, err := index.Get(*ref.Reference)
   222  			require.NoError(t, err)
   223  			assert.Equal(t, info.Mode(), entry.Mode)
   224  		})
   225  	}
   226  }
   227  
   228  func TestDirectoryIndexer_index_for_AncestorSymlinks(t *testing.T) {
   229  	// note: this test is testing the effects from NewFromDirectory, indexTree, and addPathToIndex
   230  	_, filename, _, ok := runtime.Caller(0)
   231  	require.True(t, ok)
   232  	dir := filepath.Dir(filename)
   233  
   234  	tests := []struct {
   235  		name          string
   236  		path          string
   237  		relative_base string
   238  	}{
   239  		{
   240  			name:          "the parent directory has symlink target",
   241  			path:          "test-fixtures/system_paths/target/symlinks-to-dev",
   242  			relative_base: "test-fixtures/system_paths/target/symlinks-to-dev",
   243  		},
   244  		{
   245  			name:          "the ancestor directory has symlink target",
   246  			path:          "test-fixtures/system_paths/target/symlinks-to-hierarchical-dev",
   247  			relative_base: "test-fixtures/system_paths/target/symlinks-to-hierarchical-dev/module_1/module_1_1",
   248  		},
   249  	}
   250  	for _, test := range tests {
   251  		t.Run(test.name, func(t *testing.T) {
   252  			indexer := newDirectoryIndexer("test-fixtures/system_paths/target",
   253  				fmt.Sprintf("%v/%v", dir, test.relative_base))
   254  			tree, index, err := indexer.build()
   255  			require.NoError(t, err)
   256  			info, err := os.Stat(test.path)
   257  			assert.NoError(t, err)
   258  
   259  			// note: the index uses absolute paths, so assertions MUST keep this in mind
   260  			cwd, err := os.Getwd()
   261  			require.NoError(t, err)
   262  
   263  			p := file.Path(path.Join(cwd, test.path))
   264  			assert.Equal(t, true, tree.HasPath(p))
   265  			exists, ref, err := tree.File(p)
   266  			assert.Equal(t, true, exists)
   267  			if assert.NoError(t, err) {
   268  				return
   269  			}
   270  
   271  			entry, err := index.Get(*ref.Reference)
   272  			require.NoError(t, err)
   273  			assert.Equal(t, info.Mode(), entry.Mode)
   274  		})
   275  	}
   276  }
   277  func TestDirectoryIndexer_index_survive_badSymlink(t *testing.T) {
   278  	// test-fixtures/bad-symlinks
   279  	// ├── root
   280  	// │   ├── place
   281  	// │   │   └── fd -> ../somewhere/self/fd
   282  	// │   └── somewhere
   283  	// ...
   284  	indexer := newDirectoryIndexer("test-fixtures/bad-symlinks/root/place/fd", "test-fixtures/bad-symlinks/root/place/fd")
   285  	_, _, err := indexer.build()
   286  	require.NoError(t, err)
   287  }
   288  
   289  func TestDirectoryIndexer_SkipsAlreadyVisitedLinkDestinations(t *testing.T) {
   290  	var observedPaths []string
   291  	pathObserver := func(_, p string, _ os.FileInfo, _ error) error {
   292  		fields := strings.Split(p, "test-fixtures/symlinks-prune-indexing")
   293  		if len(fields) < 2 {
   294  			return nil
   295  		}
   296  		clean := strings.TrimLeft(fields[1], "/")
   297  		if clean != "" {
   298  			observedPaths = append(observedPaths, clean)
   299  		}
   300  		return nil
   301  	}
   302  	resolver := newDirectoryIndexer("./test-fixtures/symlinks-prune-indexing", "")
   303  	// we want to cut ahead of any possible filters to see what paths are considered for indexing (closest to walking)
   304  	resolver.pathIndexVisitors = append([]PathIndexVisitor{pathObserver}, resolver.pathIndexVisitors...)
   305  
   306  	// note: this test is NOT about the effects left on the tree or the index, but rather the WHICH paths that are
   307  	// considered for indexing and HOW traversal prunes paths that have already been visited
   308  	_, _, err := resolver.build()
   309  	require.NoError(t, err)
   310  
   311  	expected := []string{
   312  		"before-path",
   313  		"c-file.txt",
   314  		"c-path",
   315  		"path",
   316  		"path/1",
   317  		"path/1/2",
   318  		"path/1/2/3",
   319  		"path/1/2/3/4",
   320  		"path/1/2/3/4/dont-index-me-twice.txt",
   321  		"path/5",
   322  		"path/5/6",
   323  		"path/5/6/7",
   324  		"path/5/6/7/8",
   325  		"path/5/6/7/8/dont-index-me-twice-either.txt",
   326  		"path/file.txt",
   327  		// everything below is after the original tree is indexed, and we are now indexing additional roots from symlinks
   328  		"path",          // considered from symlink before-path, but pruned
   329  		"path/file.txt", // leaf
   330  		"before-path",   // considered from symlink c-path, but pruned
   331  		"path/file.txt", // leaf
   332  		"before-path",   // considered from symlink c-path, but pruned
   333  	}
   334  
   335  	assert.Equal(t, expected, observedPaths, "visited paths differ \n %s", cmp.Diff(expected, observedPaths))
   336  
   337  }
   338  
   339  func TestDirectoryIndexer_IndexesAllTypes(t *testing.T) {
   340  	indexer := newDirectoryIndexer("./test-fixtures/symlinks-prune-indexing", "")
   341  
   342  	tree, index, err := indexer.build()
   343  	require.NoError(t, err)
   344  
   345  	allRefs := tree.AllFiles(file.AllTypes()...)
   346  	var pathRefs []file.Reference
   347  	paths := strset.New()
   348  	for _, ref := range allRefs {
   349  		fields := strings.Split(string(ref.RealPath), "test-fixtures/symlinks-prune-indexing")
   350  		if len(fields) != 2 {
   351  			continue
   352  		}
   353  		clean := strings.TrimLeft(fields[1], "/")
   354  		if clean == "" {
   355  			continue
   356  		}
   357  		paths.Add(clean)
   358  		pathRefs = append(pathRefs, ref)
   359  	}
   360  
   361  	pathsList := paths.List()
   362  	sort.Strings(pathsList)
   363  
   364  	expected := []string{
   365  		"before-path",                          // link
   366  		"c-file.txt",                           // link
   367  		"c-path",                               // link
   368  		"path",                                 // dir
   369  		"path/1",                               // dir
   370  		"path/1/2",                             // dir
   371  		"path/1/2/3",                           // dir
   372  		"path/1/2/3/4",                         // dir
   373  		"path/1/2/3/4/dont-index-me-twice.txt", // file
   374  		"path/5",                               // dir
   375  		"path/5/6",                             // dir
   376  		"path/5/6/7",                           // dir
   377  		"path/5/6/7/8",                         // dir
   378  		"path/5/6/7/8/dont-index-me-twice-either.txt", // file
   379  		"path/file.txt", // file
   380  	}
   381  	expectedSet := strset.New(expected...)
   382  
   383  	// make certain all expected paths are in the tree (and no extra ones are their either)
   384  
   385  	assert.True(t, paths.IsEqual(expectedSet), "expected all paths to be indexed, but found different paths: \n%s", cmp.Diff(expected, pathsList))
   386  
   387  	// make certain that the paths are also in the file index
   388  
   389  	for _, ref := range pathRefs {
   390  		_, err := index.Get(ref)
   391  		require.NoError(t, err)
   392  	}
   393  
   394  }
   395  
   396  func Test_allContainedPaths(t *testing.T) {
   397  
   398  	tests := []struct {
   399  		name string
   400  		path string
   401  		want []string
   402  	}{
   403  		{
   404  			name: "empty",
   405  			path: "",
   406  			want: nil,
   407  		},
   408  		{
   409  			name: "single relative",
   410  			path: "a",
   411  			want: []string{"a"},
   412  		},
   413  		{
   414  			name: "single absolute",
   415  			path: "/a",
   416  			want: []string{"/a"},
   417  		},
   418  		{
   419  			name: "multiple relative",
   420  			path: "a/b/c",
   421  			want: []string{"a", "a/b", "a/b/c"},
   422  		},
   423  		{
   424  			name: "multiple absolute",
   425  			path: "/a/b/c",
   426  			want: []string{"/a", "/a/b", "/a/b/c"},
   427  		},
   428  		{
   429  			name: "multiple absolute with extra slashs",
   430  			path: "///a/b//c/",
   431  			want: []string{"/a", "/a/b", "/a/b/c"},
   432  		},
   433  		{
   434  			name: "relative with single dot",
   435  			path: "a/./b",
   436  			want: []string{"a", "a/b"},
   437  		},
   438  		{
   439  			name: "relative with double single dot",
   440  			path: "a/../b",
   441  			want: []string{"b"},
   442  		},
   443  	}
   444  	for _, tt := range tests {
   445  		t.Run(tt.name, func(t *testing.T) {
   446  			assert.Equal(t, tt.want, allContainedPaths(tt.path))
   447  		})
   448  	}
   449  }
   450  
   451  func Test_relativePath(t *testing.T) {
   452  	tests := []struct {
   453  		name      string
   454  		basePath  string
   455  		givenPath string
   456  		want      string
   457  	}{
   458  		{
   459  			name:      "root: same relative path",
   460  			basePath:  "a/b/c",
   461  			givenPath: "a/b/c",
   462  			want:      "/",
   463  		},
   464  		{
   465  			name:      "root: same absolute path",
   466  			basePath:  "/a/b/c",
   467  			givenPath: "/a/b/c",
   468  			want:      "/",
   469  		},
   470  		{
   471  			name:      "contained path: relative",
   472  			basePath:  "a/b/c",
   473  			givenPath: "a/b/c/dev",
   474  			want:      "/dev",
   475  		},
   476  		{
   477  			name:      "contained path: absolute",
   478  			basePath:  "/a/b/c",
   479  			givenPath: "/a/b/c/dev",
   480  			want:      "/dev",
   481  		},
   482  	}
   483  	for _, tt := range tests {
   484  		t.Run(tt.name, func(t *testing.T) {
   485  			assert.Equal(t, tt.want, relativePath(tt.basePath, tt.givenPath))
   486  		})
   487  	}
   488  }
   489  
   490  func relativePath(basePath, givenPath string) string {
   491  	var relPath string
   492  	var relErr error
   493  
   494  	if basePath != "" {
   495  		relPath, relErr = filepath.Rel(basePath, givenPath)
   496  		cleanPath := filepath.Clean(relPath)
   497  		if relErr == nil {
   498  			if cleanPath == "." {
   499  				relPath = string(filepath.Separator)
   500  			} else {
   501  				relPath = cleanPath
   502  			}
   503  		}
   504  		if !filepath.IsAbs(relPath) {
   505  			relPath = string(filepath.Separator) + relPath
   506  		}
   507  	}
   508  
   509  	if relErr != nil || basePath == "" {
   510  		relPath = givenPath
   511  	}
   512  
   513  	return relPath
   514  }