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 }