github.com/anchore/syft@v1.38.2/syft/internal/fileresolver/container_image_all_layers_test.go (about) 1 package fileresolver 2 3 import ( 4 "context" 5 "io" 6 "sort" 7 "testing" 8 9 "github.com/google/go-cmp/cmp" 10 "github.com/scylladb/go-set/strset" 11 "github.com/stretchr/testify/assert" 12 "github.com/stretchr/testify/require" 13 14 "github.com/anchore/stereoscope/pkg/imagetest" 15 "github.com/anchore/syft/syft/file" 16 ) 17 18 type resolution struct { 19 layer uint 20 path string 21 } 22 23 func TestAllLayersResolver_FilesByPath(t *testing.T) { 24 cases := []struct { 25 name string 26 linkPath string 27 resolutions []resolution 28 forcePositiveHasPath bool 29 }{ 30 { 31 name: "link with previous data", 32 linkPath: "/link-1", 33 resolutions: []resolution{ 34 { 35 layer: 1, 36 path: "/file-1.txt", 37 }, 38 }, 39 }, 40 { 41 name: "link with in layer data", 42 linkPath: "/link-within", 43 resolutions: []resolution{ 44 { 45 layer: 5, 46 path: "/file-3.txt", 47 }, 48 }, 49 }, 50 { 51 name: "link with overridden data", 52 linkPath: "/link-2", 53 resolutions: []resolution{ 54 { 55 layer: 4, 56 path: "/file-2.txt", 57 }, 58 { 59 layer: 7, 60 path: "/file-2.txt", 61 }, 62 }, 63 }, 64 { 65 name: "indirect link (with overridden data)", 66 linkPath: "/link-indirect", 67 resolutions: []resolution{ 68 { 69 layer: 4, 70 path: "/file-2.txt", 71 }, 72 { 73 layer: 7, 74 path: "/file-2.txt", 75 }, 76 }, 77 }, 78 { 79 name: "dead link", 80 linkPath: "/link-dead", 81 resolutions: []resolution{}, 82 forcePositiveHasPath: true, 83 }, 84 { 85 name: "ignore directories", 86 linkPath: "/bin", 87 resolutions: []resolution{}, 88 // directories don't resolve BUT do exist 89 forcePositiveHasPath: true, 90 }, 91 } 92 for _, c := range cases { 93 t.Run(c.name, func(t *testing.T) { 94 img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") 95 96 resolver, err := NewFromContainerImageAllLayers(img) 97 require.NoError(t, err) 98 99 hasPath := resolver.HasPath(c.linkPath) 100 if !c.forcePositiveHasPath { 101 if len(c.resolutions) > 0 && !hasPath { 102 t.Errorf("expected HasPath() to indicate existance, but did not") 103 } else if len(c.resolutions) == 0 && hasPath { 104 t.Errorf("expeced HasPath() to NOT indicate existance, but does") 105 } 106 } else if !hasPath { 107 t.Errorf("expected HasPath() to indicate existance, but did not (force path)") 108 } 109 110 refs, err := resolver.FilesByPath(c.linkPath) 111 require.NoError(t, err) 112 113 if len(refs) != len(c.resolutions) { 114 t.Fatalf("unexpected number of resolutions: %d", len(refs)) 115 } 116 117 for idx, actual := range refs { 118 expected := c.resolutions[idx] 119 120 if string(actual.Reference().RealPath) != expected.path { 121 t.Errorf("bad resolve path: '%s'!='%s'", string(actual.Reference().RealPath), expected.path) 122 } 123 124 if expected.path != "" && string(actual.Reference().RealPath) != actual.RealPath { 125 t.Errorf("we should always prefer real paths over ones with links") 126 } 127 128 layer := img.FileCatalog.Layer(actual.Reference()) 129 if layer.Metadata.Index != expected.layer { 130 t.Errorf("bad resolve layer: '%d'!='%d'", layer.Metadata.Index, expected.layer) 131 } 132 } 133 }) 134 } 135 } 136 137 func TestAllLayersResolver_FilesByGlob(t *testing.T) { 138 cases := []struct { 139 name string 140 glob string 141 resolutions []resolution 142 }{ 143 { 144 name: "link with previous data", 145 glob: "**/*ink-1", 146 resolutions: []resolution{ 147 { 148 layer: 1, 149 path: "/file-1.txt", 150 }, 151 }, 152 }, 153 { 154 name: "link with in layer data", 155 glob: "**/*nk-within", 156 resolutions: []resolution{ 157 { 158 layer: 5, 159 path: "/file-3.txt", 160 }, 161 }, 162 }, 163 { 164 name: "link with overridden data", 165 glob: "**/*ink-2", 166 resolutions: []resolution{ 167 { 168 layer: 4, 169 path: "/file-2.txt", 170 }, 171 { 172 layer: 7, 173 path: "/file-2.txt", 174 }, 175 }, 176 }, 177 { 178 name: "indirect link (with overridden data)", 179 glob: "**/*nk-indirect", 180 resolutions: []resolution{ 181 { 182 layer: 4, 183 path: "/file-2.txt", 184 }, 185 { 186 layer: 7, 187 path: "/file-2.txt", 188 }, 189 }, 190 }, 191 { 192 name: "dead link", 193 glob: "**/*k-dead", 194 resolutions: []resolution{}, 195 }, 196 { 197 name: "ignore directories", 198 glob: "**/bin", 199 resolutions: []resolution{}, 200 }, 201 } 202 for _, c := range cases { 203 t.Run(c.name, func(t *testing.T) { 204 img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") 205 206 resolver, err := NewFromContainerImageAllLayers(img) 207 require.NoError(t, err) 208 209 refs, err := resolver.FilesByGlob(c.glob) 210 require.NoError(t, err) 211 212 if len(refs) != len(c.resolutions) { 213 t.Fatalf("unexpected number of resolutions: %d", len(refs)) 214 } 215 216 for idx, actual := range refs { 217 expected := c.resolutions[idx] 218 219 if string(actual.Reference().RealPath) != expected.path { 220 t.Errorf("bad resolve path: '%s'!='%s'", string(actual.Reference().RealPath), expected.path) 221 } 222 223 if expected.path != "" && string(actual.Reference().RealPath) != actual.RealPath { 224 t.Errorf("we should always prefer real paths over ones with links") 225 } 226 227 layer := img.FileCatalog.Layer(actual.Reference()) 228 229 if layer.Metadata.Index != expected.layer { 230 t.Errorf("bad resolve layer: '%d'!='%d'", layer.Metadata.Index, expected.layer) 231 } 232 } 233 }) 234 } 235 } 236 237 func Test_imageAllLayersResolver_FilesByMIMEType(t *testing.T) { 238 239 tests := []struct { 240 fixtureName string 241 mimeType string 242 expectedPaths []string 243 }{ 244 { 245 fixtureName: "image-duplicate-path", 246 mimeType: "text/plain", 247 expectedPaths: []string{"/somefile-1.txt", "/somefile-1.txt"}, 248 }, 249 } 250 for _, test := range tests { 251 t.Run(test.fixtureName, func(t *testing.T) { 252 img := imagetest.GetFixtureImage(t, "docker-archive", test.fixtureName) 253 254 resolver, err := NewFromContainerImageAllLayers(img) 255 assert.NoError(t, err) 256 257 locations, err := resolver.FilesByMIMEType(test.mimeType) 258 assert.NoError(t, err) 259 260 assert.Len(t, test.expectedPaths, len(locations)) 261 for idx, l := range locations { 262 assert.Equal(t, test.expectedPaths[idx], l.RealPath, "does not have path %q", l.RealPath) 263 } 264 }) 265 } 266 } 267 268 func Test_imageAllLayersResolver_hasFilesystemIDInLocation(t *testing.T) { 269 img := imagetest.GetFixtureImage(t, "docker-archive", "image-duplicate-path") 270 271 resolver, err := NewFromContainerImageAllLayers(img) 272 assert.NoError(t, err) 273 274 locations, err := resolver.FilesByMIMEType("text/plain") 275 assert.NoError(t, err) 276 assert.NotEmpty(t, locations) 277 for _, location := range locations { 278 assert.NotEmpty(t, location.FileSystemID) 279 } 280 281 locations, err = resolver.FilesByGlob("*.txt") 282 assert.NoError(t, err) 283 assert.NotEmpty(t, locations) 284 for _, location := range locations { 285 assert.NotEmpty(t, location.FileSystemID) 286 } 287 288 locations, err = resolver.FilesByPath("/somefile-1.txt") 289 assert.NoError(t, err) 290 assert.NotEmpty(t, locations) 291 for _, location := range locations { 292 assert.NotEmpty(t, location.FileSystemID) 293 } 294 295 } 296 297 func TestAllLayersImageResolver_FilesContents(t *testing.T) { 298 299 tests := []struct { 300 name string 301 fixture string 302 contents []string 303 }{ 304 { 305 name: "one degree", 306 fixture: "link-2", 307 contents: []string{ 308 "file 2!", // from the first resolved layer's perspective 309 "NEW file override!", // from the second resolved layers perspective 310 }, 311 }, 312 { 313 name: "two degrees", 314 fixture: "link-indirect", 315 contents: []string{ 316 "file 2!", 317 "NEW file override!", 318 }, 319 }, 320 { 321 name: "dead link", 322 fixture: "link-dead", 323 contents: []string{}, 324 }, 325 } 326 327 for _, test := range tests { 328 t.Run(test.name, func(t *testing.T) { 329 img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") 330 331 resolver, err := NewFromContainerImageAllLayers(img) 332 assert.NoError(t, err) 333 334 refs, err := resolver.FilesByPath(test.fixture) 335 require.NoError(t, err) 336 337 // the given path should have an overridden file 338 require.Len(t, refs, len(test.contents)) 339 340 for idx, loc := range refs { 341 reader, err := resolver.FileContentsByLocation(loc) 342 require.NoError(t, err) 343 344 actual, err := io.ReadAll(reader) 345 require.NoError(t, err) 346 347 assert.Equal(t, test.contents[idx], string(actual)) 348 } 349 350 }) 351 } 352 } 353 354 func TestAllLayersImageResolver_FilesContents_errorOnDirRequest(t *testing.T) { 355 356 img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") 357 358 resolver, err := NewFromContainerImageAllLayers(img) 359 assert.NoError(t, err) 360 361 var dirLoc *file.Location 362 ctx, cancel := context.WithCancel(context.Background()) 363 defer cancel() 364 for loc := range resolver.AllLocations(ctx) { 365 entry, err := resolver.img.FileCatalog.Get(loc.Reference()) 366 require.NoError(t, err) 367 if entry.Metadata.IsDir() { 368 dirLoc = &loc 369 break 370 } 371 } 372 373 require.NotNil(t, dirLoc) 374 375 reader, err := resolver.FileContentsByLocation(*dirLoc) 376 require.Error(t, err) 377 require.Nil(t, reader) 378 } 379 380 func Test_imageAllLayersResolver_resolvesLinks(t *testing.T) { 381 tests := []struct { 382 name string 383 runner func(file.Resolver) []file.Location 384 expected []file.Location 385 }{ 386 { 387 name: "by mimetype", 388 runner: func(resolver file.Resolver) []file.Location { 389 // links should not show up when searching mimetype 390 actualLocations, err := resolver.FilesByMIMEType("text/plain") 391 assert.NoError(t, err) 392 return actualLocations 393 }, 394 expected: []file.Location{ 395 file.NewVirtualLocation("/etc/group", "/etc/group"), 396 file.NewVirtualLocation("/etc/passwd", "/etc/passwd"), 397 file.NewVirtualLocation("/etc/shadow", "/etc/shadow"), 398 file.NewVirtualLocation("/file-1.txt", "/file-1.txt"), 399 file.NewVirtualLocation("/file-2.txt", "/file-2.txt"), // copy 1 400 // note: we're de-duping the redundant access to file-3.txt 401 // ... (there would usually be two copies) 402 file.NewVirtualLocation("/file-3.txt", "/file-3.txt"), 403 file.NewVirtualLocation("/file-2.txt", "/file-2.txt"), // copy 2 404 file.NewVirtualLocation("/parent/file-4.txt", "/parent/file-4.txt"), // copy 1 405 file.NewVirtualLocation("/parent/file-4.txt", "/parent/file-4.txt"), // copy 2 406 }, 407 }, 408 { 409 name: "by glob to links", 410 runner: func(resolver file.Resolver) []file.Location { 411 // links are searched, but resolve to the real files 412 actualLocations, err := resolver.FilesByGlob("*ink-*") 413 assert.NoError(t, err) 414 return actualLocations 415 }, 416 expected: []file.Location{ 417 file.NewVirtualLocation("/file-1.txt", "/link-1"), 418 file.NewVirtualLocation("/file-2.txt", "/link-2"), // copy 1 419 file.NewVirtualLocation("/file-2.txt", "/link-2"), // copy 2 420 file.NewVirtualLocation("/file-3.txt", "/link-within"), 421 }, 422 }, 423 { 424 name: "by basename", 425 runner: func(resolver file.Resolver) []file.Location { 426 // links are searched, but resolve to the real files 427 actualLocations, err := resolver.FilesByGlob("**/file-2.txt") 428 assert.NoError(t, err) 429 return actualLocations 430 }, 431 expected: []file.Location{ 432 file.NewVirtualLocation("/file-2.txt", "/file-2.txt"), // copy 1 433 file.NewVirtualLocation("/file-2.txt", "/file-2.txt"), // copy 2 434 }, 435 }, 436 { 437 name: "by basename glob", 438 runner: func(resolver file.Resolver) []file.Location { 439 // links are searched, but resolve to the real files 440 actualLocations, err := resolver.FilesByGlob("**/file-?.txt") 441 assert.NoError(t, err) 442 return actualLocations 443 }, 444 expected: []file.Location{ 445 file.NewVirtualLocation("/file-1.txt", "/file-1.txt"), 446 file.NewVirtualLocation("/file-2.txt", "/file-2.txt"), // copy 1 447 file.NewVirtualLocation("/file-2.txt", "/file-2.txt"), // copy 2 448 file.NewVirtualLocation("/file-3.txt", "/file-3.txt"), 449 file.NewVirtualLocation("/parent/file-4.txt", "/parent/file-4.txt"), 450 file.NewVirtualLocation("/parent/file-4.txt", "/parent/file-4.txt"), // when we copy into the link path, the same file-4.txt is copied 451 }, 452 }, 453 { 454 name: "by extension", 455 runner: func(resolver file.Resolver) []file.Location { 456 // links are searched, but resolve to the real files 457 actualLocations, err := resolver.FilesByGlob("**/*.txt") 458 assert.NoError(t, err) 459 return actualLocations 460 }, 461 expected: []file.Location{ 462 file.NewVirtualLocation("/file-1.txt", "/file-1.txt"), 463 file.NewVirtualLocation("/file-2.txt", "/file-2.txt"), // copy 1 464 file.NewVirtualLocation("/file-2.txt", "/file-2.txt"), // copy 2 465 file.NewVirtualLocation("/file-3.txt", "/file-3.txt"), 466 file.NewVirtualLocation("/parent/file-4.txt", "/parent/file-4.txt"), 467 file.NewVirtualLocation("/parent/file-4.txt", "/parent/file-4.txt"), // when we copy into the link path, the same file-4.txt is copied 468 }, 469 }, 470 { 471 name: "by path to degree 1 link", 472 runner: func(resolver file.Resolver) []file.Location { 473 // links resolve to the final file 474 actualLocations, err := resolver.FilesByPath("/link-2") 475 assert.NoError(t, err) 476 return actualLocations 477 }, 478 expected: []file.Location{ 479 // we have multiple copies across layers 480 file.NewVirtualLocation("/file-2.txt", "/link-2"), 481 file.NewVirtualLocation("/file-2.txt", "/link-2"), 482 }, 483 }, 484 { 485 name: "by path to degree 2 link", 486 runner: func(resolver file.Resolver) []file.Location { 487 // multiple links resolves to the final file 488 actualLocations, err := resolver.FilesByPath("/link-indirect") 489 assert.NoError(t, err) 490 return actualLocations 491 }, 492 expected: []file.Location{ 493 // we have multiple copies across layers 494 file.NewVirtualLocation("/file-2.txt", "/link-indirect"), 495 file.NewVirtualLocation("/file-2.txt", "/link-indirect"), 496 }, 497 }, 498 } 499 500 for _, test := range tests { 501 t.Run(test.name, func(t *testing.T) { 502 503 img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") 504 505 resolver, err := NewFromContainerImageAllLayers(img) 506 assert.NoError(t, err) 507 508 actual := test.runner(resolver) 509 510 compareLocations(t, test.expected, actual) 511 }) 512 } 513 514 } 515 516 func TestAllLayersResolver_AllLocations(t *testing.T) { 517 img := imagetest.GetFixtureImage(t, "docker-archive", "image-files-deleted") 518 519 resolver, err := NewFromContainerImageAllLayers(img) 520 assert.NoError(t, err) 521 522 paths := strset.New() 523 ctx, cancel := context.WithCancel(context.Background()) 524 defer cancel() 525 visibleSet := strset.New() 526 hiddenSet := strset.New() 527 for loc := range resolver.AllLocations(ctx) { 528 paths.Add(loc.RealPath) 529 switch loc.Annotations[file.VisibleAnnotationKey] { 530 case file.VisibleAnnotation: 531 visibleSet.Add(loc.RealPath) 532 case file.HiddenAnnotation: 533 hiddenSet.Add(loc.RealPath) 534 case "": 535 t.Errorf("expected visibility annotation for location: %+v", loc) 536 } 537 } 538 visible := []string{ 539 "/Dockerfile", 540 "/file-3.txt", // this is a deadlink pointing to /file-1.txt (which has been deleted) 541 "/target", 542 "/target/file-2.txt", 543 } 544 hidden := []string{ 545 "/.wh.bin", 546 "/.wh.file-1.txt", 547 "/.wh.lib", 548 "/bin", 549 "/bin/arch", 550 "/bin/ash", 551 "/bin/base64", 552 "/bin/bbconfig", 553 "/bin/busybox", 554 "/bin/cat", 555 "/bin/chattr", 556 "/bin/chgrp", 557 "/bin/chmod", 558 "/bin/chown", 559 "/bin/cp", 560 "/bin/date", 561 "/bin/dd", 562 "/bin/df", 563 "/bin/dmesg", 564 "/bin/dnsdomainname", 565 "/bin/dumpkmap", 566 "/bin/echo", 567 "/bin/ed", 568 "/bin/egrep", 569 "/bin/false", 570 "/bin/fatattr", 571 "/bin/fdflush", 572 "/bin/fgrep", 573 "/bin/fsync", 574 "/bin/getopt", 575 "/bin/grep", 576 "/bin/gunzip", 577 "/bin/gzip", 578 "/bin/hostname", 579 "/bin/ionice", 580 "/bin/iostat", 581 "/bin/ipcalc", 582 "/bin/kbd_mode", 583 "/bin/kill", 584 "/bin/link", 585 "/bin/linux32", 586 "/bin/linux64", 587 "/bin/ln", 588 "/bin/login", 589 "/bin/ls", 590 "/bin/lsattr", 591 "/bin/lzop", 592 "/bin/makemime", 593 "/bin/mkdir", 594 "/bin/mknod", 595 "/bin/mktemp", 596 "/bin/more", 597 "/bin/mount", 598 "/bin/mountpoint", 599 "/bin/mpstat", 600 "/bin/mv", 601 "/bin/netstat", 602 "/bin/nice", 603 "/bin/pidof", 604 "/bin/ping", 605 "/bin/ping6", 606 "/bin/pipe_progress", 607 "/bin/printenv", 608 "/bin/ps", 609 "/bin/pwd", 610 "/bin/reformime", 611 "/bin/rev", 612 "/bin/rm", 613 "/bin/rmdir", 614 "/bin/run-parts", 615 "/bin/sed", 616 "/bin/setpriv", 617 "/bin/setserial", 618 "/bin/sh", 619 "/bin/sleep", 620 "/bin/stat", 621 "/bin/stty", 622 "/bin/su", 623 "/bin/sync", 624 "/bin/tar", 625 "/bin/touch", 626 "/bin/true", 627 "/bin/umount", 628 "/bin/uname", 629 "/bin/usleep", 630 "/bin/watch", 631 "/bin/zcat", 632 "/file-1.txt", 633 "/lib", 634 "/lib/apk", 635 "/lib/apk/db", 636 "/lib/apk/db/installed", 637 "/lib/apk/db/lock", 638 "/lib/apk/db/scripts.tar", 639 "/lib/apk/db/triggers", 640 "/lib/apk/exec", 641 "/lib/firmware", 642 "/lib/ld-musl-x86_64.so.1", 643 "/lib/libapk.so.3.12.0", 644 "/lib/libc.musl-x86_64.so.1", 645 "/lib/libcrypto.so.3", 646 "/lib/libssl.so.3", 647 "/lib/libz.so.1", 648 "/lib/libz.so.1.2.13", 649 "/lib/mdev", 650 "/lib/modules-load.d", 651 "/lib/sysctl.d", 652 "/lib/sysctl.d/00-alpine.conf", 653 } 654 655 var expected []string 656 expected = append(expected, visible...) 657 expected = append(expected, hidden...) 658 sort.Strings(expected) 659 660 cleanPaths := func(s *strset.Set) { 661 // depending on how the image is built (either from linux or mac), sys and proc might accidentally be added to the image. 662 // this isn't important for the test, so we remove them. 663 s.Remove("/proc", "/sys", "/dev", "/etc") 664 665 // Remove cache created by Mac Rosetta when emulating different arches 666 s.Remove("/.cache/rosetta", "/.cache") 667 } 668 669 cleanPaths(paths) 670 cleanPaths(visibleSet) 671 cleanPaths(hiddenSet) 672 673 pathsList := paths.List() 674 sort.Strings(pathsList) 675 visibleSetList := visibleSet.List() 676 sort.Strings(visibleSetList) 677 hiddenSetList := hiddenSet.List() 678 sort.Strings(hiddenSetList) 679 680 if d := cmp.Diff(expected, pathsList); d != "" { 681 t.Errorf("unexpected paths (-want +got):\n%s", d) 682 } 683 684 if d := cmp.Diff(visible, visibleSetList); d != "" { 685 t.Errorf("unexpected visible paths (-want +got):\n%s", d) 686 } 687 688 if d := cmp.Diff(hidden, hiddenSetList); d != "" { 689 t.Errorf("unexpected hidden paths (-want +got):\n%s", d) 690 } 691 692 }