github.com/google/osv-scalibr@v0.4.1/artifact/image/unpack/unpack_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 unpack_test 16 17 import ( 18 "bytes" 19 "fmt" 20 "io" 21 "io/fs" 22 "maps" 23 "os" 24 "path/filepath" 25 "runtime" 26 "strings" 27 "testing" 28 29 "archive/tar" 30 31 "github.com/google/go-cmp/cmp" 32 "github.com/google/go-cmp/cmp/cmpopts" 33 v1 "github.com/google/go-containerregistry/pkg/v1" 34 "github.com/google/go-containerregistry/pkg/v1/empty" 35 "github.com/google/go-containerregistry/pkg/v1/mutate" 36 "github.com/google/go-containerregistry/pkg/v1/tarball" 37 "github.com/google/osv-scalibr/artifact/image/require" 38 "github.com/google/osv-scalibr/artifact/image/unpack" 39 ) 40 41 type contentAndMode struct { 42 content string 43 mode fs.FileMode 44 } 45 46 func TestNewUnpacker(t *testing.T) { 47 tests := []struct { 48 name string 49 cfg *unpack.UnpackerConfig 50 want *unpack.Unpacker 51 wantErr error 52 }{{ 53 name: "missing_SymlinkResolution", 54 cfg: &unpack.UnpackerConfig{ 55 SymlinkErrStrategy: unpack.SymlinkErrLog, 56 Requirer: &require.FileRequirerAll{}, 57 }, 58 wantErr: cmpopts.AnyError, 59 }, { 60 name: "missing_SymlinkErrStrategy", 61 cfg: &unpack.UnpackerConfig{ 62 SymlinkResolution: unpack.SymlinkRetain, 63 Requirer: &require.FileRequirerAll{}, 64 }, 65 wantErr: cmpopts.AnyError, 66 }, { 67 name: "missing_Requirer", 68 cfg: &unpack.UnpackerConfig{ 69 SymlinkResolution: unpack.SymlinkRetain, 70 SymlinkErrStrategy: unpack.SymlinkErrLog, 71 }, 72 wantErr: cmpopts.AnyError, 73 }, { 74 name: "0_MaxFileBytes_bytes", 75 cfg: &unpack.UnpackerConfig{ 76 SymlinkResolution: unpack.SymlinkRetain, 77 SymlinkErrStrategy: unpack.SymlinkErrLog, 78 MaxPass: 100, 79 MaxFileBytes: 0, 80 Requirer: &require.FileRequirerAll{}, 81 }, 82 want: &unpack.Unpacker{ 83 SymlinkResolution: unpack.SymlinkRetain, 84 SymlinkErrStrategy: unpack.SymlinkErrLog, 85 MaxPass: 100, 86 MaxSizeBytes: 1024 * 1024 * 1024 * 1024, // 1TB 87 Requirer: &require.FileRequirerAll{}, 88 }, 89 }, { 90 name: "all_fields_populated", 91 cfg: &unpack.UnpackerConfig{ 92 SymlinkResolution: unpack.SymlinkRetain, 93 SymlinkErrStrategy: unpack.SymlinkErrLog, 94 MaxPass: 100, 95 MaxFileBytes: 1024 * 1024 * 5, // 5MB 96 Requirer: &require.FileRequirerAll{}, 97 }, 98 want: &unpack.Unpacker{ 99 SymlinkResolution: unpack.SymlinkRetain, 100 SymlinkErrStrategy: unpack.SymlinkErrLog, 101 MaxPass: 100, 102 MaxSizeBytes: 1024 * 1024 * 5, // 5MB 103 Requirer: &require.FileRequirerAll{}, 104 }, 105 }, { 106 name: "default_config", 107 cfg: unpack.DefaultUnpackerConfig(), 108 want: &unpack.Unpacker{ 109 SymlinkResolution: unpack.SymlinkRetain, 110 SymlinkErrStrategy: unpack.SymlinkErrLog, 111 MaxPass: 3, 112 MaxSizeBytes: unpack.DefaultMaxFileBytes, 113 Requirer: &require.FileRequirerAll{}, 114 }, 115 }} 116 117 for _, tc := range tests { 118 t.Run(tc.name, func(t *testing.T) { 119 got, err := unpack.NewUnpacker(tc.cfg) 120 if !cmp.Equal(err, tc.wantErr, cmpopts.EquateErrors()) { 121 t.Fatalf("NewUnpacker(%+v) error: got %v, want %v\n", tc.cfg, err, tc.wantErr) 122 } 123 124 opts := []cmp.Option{ 125 cmp.AllowUnexported(unpack.Unpacker{}), 126 } 127 if diff := cmp.Diff(tc.want, got, opts...); diff != "" { 128 t.Fatalf("NewUnpacker(%+v) returned unexpected diff (-want +got):\n%s", tc.cfg, diff) 129 } 130 }) 131 } 132 } 133 134 func TestUnpackSquashed(t *testing.T) { 135 if runtime.GOOS != "linux" { 136 // TODO(b/366163334): Make tests work on Mac and Windows. 137 return 138 } 139 140 tests := []struct { 141 name string 142 cfg *unpack.UnpackerConfig 143 dir string 144 image v1.Image 145 want map[string]contentAndMode 146 wantErr error 147 }{{ 148 name: "missing directory", 149 cfg: unpack.DefaultUnpackerConfig(), 150 dir: "", 151 image: empty.Image, 152 wantErr: cmpopts.AnyError, 153 }, { 154 name: "nil image", 155 cfg: unpack.DefaultUnpackerConfig(), 156 dir: t.TempDir(), 157 image: nil, 158 wantErr: cmpopts.AnyError, 159 }, { 160 name: "empty image", 161 cfg: unpack.DefaultUnpackerConfig(), 162 dir: t.TempDir(), 163 image: empty.Image, 164 want: map[string]contentAndMode{}, 165 }, { 166 name: "single layer image", 167 cfg: unpack.DefaultUnpackerConfig(), 168 dir: t.TempDir(), 169 image: mustImageFromPath(t, filepath.Join("testdata", "basic.tar")), 170 want: map[string]contentAndMode{ 171 "sample.txt": {content: "sample text file\n", mode: fs.FileMode(0644)}, 172 "larger-sample.txt": {content: strings.Repeat("sample text file\n", 400), mode: fs.FileMode(0644)}, 173 }, 174 }, { 175 name: "large files are skipped", 176 cfg: unpack.DefaultUnpackerConfig().WithMaxFileBytes(1024), 177 dir: t.TempDir(), 178 image: mustImageFromPath(t, filepath.Join("testdata", "basic.tar")), 179 want: map[string]contentAndMode{ 180 "sample.txt": {content: "sample text file\n", mode: fs.FileMode(0644)}, 181 }, 182 }, { 183 name: "image with restricted file permissions", 184 cfg: unpack.DefaultUnpackerConfig(), 185 dir: t.TempDir(), 186 image: mustImageFromPath(t, filepath.Join("testdata", "permissions.tar")), 187 want: map[string]contentAndMode{ 188 "sample.txt": {content: "sample text file\n", mode: fs.FileMode(0600)}, 189 }, 190 }, { 191 name: "image_with_symlinks", 192 cfg: unpack.DefaultUnpackerConfig().WithMaxPass(1), 193 dir: func() string { 194 // Create an inner directory to unpack in and an outer directory to test if symlinks try pointing to it. 195 // This test checks that symlinks that attempt to point outside the unpack directory are removed. 196 dir := t.TempDir() 197 innerDir := filepath.Join(dir, "innerdir") 198 err := os.Mkdir(innerDir, 0777) 199 if err != nil { 200 t.Fatalf("Failed to create temp dir: %v", err) 201 } 202 _ = os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("some secret\n"), 0644) 203 return innerDir 204 }(), 205 image: mustImageFromPath(t, filepath.Join("testdata", "symlinks.tar")), 206 want: map[string]contentAndMode{ 207 filepath.FromSlash("dir1/absolute-symlink.txt"): {content: "sample text\n", mode: fs.ModeSymlink | fs.FileMode(0777)}, 208 filepath.FromSlash("dir1/chain-symlink.txt"): {content: "sample text\n", mode: fs.ModeSymlink | fs.FileMode(0777)}, 209 filepath.FromSlash("dir1/relative-dot-symlink.txt"): {content: "sample text\n", mode: fs.ModeSymlink | fs.FileMode(0777)}, 210 filepath.FromSlash("dir1/relative-symlink.txt"): {content: "sample text\n", mode: fs.ModeSymlink | fs.FileMode(0777)}, 211 filepath.FromSlash("dir1/sample.txt"): {content: "sample text\n", mode: fs.FileMode(0644)}, 212 filepath.FromSlash("dir2/dir3/absolute-chain-symlink.txt"): {content: "sample text\n", mode: fs.ModeSymlink | fs.FileMode(0777)}, 213 filepath.FromSlash("dir2/dir3/absolute-subfolder-symlink.txt"): {content: "sample text\n", mode: fs.ModeSymlink | fs.FileMode(0777)}, 214 filepath.FromSlash("dir2/dir3/absolute-symlink-inside-root.txt"): {content: "sample text\n", mode: fs.ModeSymlink | fs.FileMode(0777)}, 215 filepath.FromSlash("dir2/dir3/relative-subfolder-symlink.txt"): {content: "sample text\n", mode: fs.ModeSymlink | fs.FileMode(0777)}, 216 }, 217 }, { 218 name: "image_with_absolute_path_symlink_but_only_the_symlink_is_required", 219 cfg: unpack.DefaultUnpackerConfig().WithMaxPass(2).WithRequirer( 220 require.NewFileRequirerPaths([]string{ 221 filepath.FromSlash("dir1/absolute-symlink.txt"), 222 }), 223 ), 224 dir: t.TempDir(), 225 image: mustImageFromPath(t, filepath.Join("testdata", "symlinks.tar")), 226 want: map[string]contentAndMode{ 227 filepath.FromSlash("dir1/absolute-symlink.txt"): {content: "sample text\n", mode: fs.ModeSymlink | fs.FileMode(0777)}, 228 filepath.FromSlash("dir1/sample.txt"): {content: "sample text\n", mode: fs.FileMode(0644)}, 229 }, 230 }, { 231 name: "image_with_a_chain_of_symlinks_but_only_the_first_symlink_is_required", 232 cfg: unpack.DefaultUnpackerConfig().WithMaxPass(2).WithRequirer( 233 require.NewFileRequirerPaths([]string{ 234 filepath.FromSlash("dir1/chain-symlink.txt"), 235 }), 236 ), 237 dir: t.TempDir(), 238 image: mustImageFromPath(t, filepath.Join("testdata", "symlinks.tar")), 239 want: map[string]contentAndMode{ 240 filepath.FromSlash("dir1/absolute-symlink.txt"): {content: "sample text\n", mode: fs.ModeSymlink | fs.FileMode(0777)}, 241 filepath.FromSlash("dir1/chain-symlink.txt"): {content: "sample text\n", mode: fs.ModeSymlink | fs.FileMode(0777)}, 242 filepath.FromSlash("dir1/sample.txt"): {content: "sample text\n", mode: fs.FileMode(0644)}, 243 }, 244 }, { 245 name: "image_with_absolute_path_symlink,_only_the_symlink_is_required,_but_there_were_not_enough_passes_to_resolve_the_symlink", 246 cfg: unpack.DefaultUnpackerConfig().WithMaxPass(1).WithRequirer( 247 require.NewFileRequirerPaths([]string{ 248 filepath.FromSlash("dir1/chain-symlink.txt"), 249 }), 250 ), 251 dir: t.TempDir(), 252 image: mustImageFromPath(t, filepath.Join("testdata", "symlinks.tar")), 253 want: map[string]contentAndMode{}, 254 }, { 255 name: "image_built_from_scratch_(not_through_a_tool_like_Docker)", 256 cfg: unpack.DefaultUnpackerConfig().WithMaxPass(1), 257 dir: t.TempDir(), 258 image: mustNewSquashedImage(t, map[string]contentAndMode{ 259 filepath.FromSlash("some/file.txt"): {"some text", 0600}, 260 filepath.FromSlash("another/file.json"): {"some other text", 0600}, 261 }), 262 want: map[string]contentAndMode{ 263 filepath.FromSlash("some/file.txt"): {content: "some text", mode: fs.FileMode(0600)}, 264 filepath.FromSlash("another/file.json"): {content: "some other text", mode: fs.FileMode(0600)}, 265 }, 266 }, { 267 name: "only_some_files_are_required", 268 cfg: unpack.DefaultUnpackerConfig().WithRequirer(require.NewFileRequirerPaths([]string{"some/file.txt"})), 269 dir: t.TempDir(), 270 image: mustNewSquashedImage(t, map[string]contentAndMode{ 271 filepath.FromSlash("some/file.txt"): {"some text", 0600}, 272 filepath.FromSlash("another/file.json"): {"some other text", 0600}, 273 }), 274 want: map[string]contentAndMode{ 275 filepath.FromSlash("some/file.txt"): {content: "some text", mode: fs.FileMode(0600)}, 276 }, 277 }, { 278 name: "dangling symlinks are removed", 279 cfg: unpack.DefaultUnpackerConfig(), 280 dir: t.TempDir(), 281 image: mustImageFromPath(t, filepath.Join("testdata", "dangling-symlinks.tar")), 282 want: map[string]contentAndMode{}, 283 }, { 284 name: "return error for unimplemented symlink ignore resolution strategy", 285 cfg: unpack.DefaultUnpackerConfig().WithSymlinkResolution(unpack.SymlinkIgnore), 286 dir: t.TempDir(), 287 image: mustImageFromPath(t, filepath.Join("testdata", "dangling-symlinks.tar")), 288 wantErr: cmpopts.AnyError, 289 }} 290 291 for _, tc := range tests { 292 t.Run(tc.name, func(t *testing.T) { 293 defer os.RemoveAll(tc.dir) 294 u := mustNewUnpacker(t, tc.cfg) 295 gotErr := u.UnpackSquashed(tc.dir, tc.image) 296 if !cmp.Equal(gotErr, tc.wantErr, cmpopts.EquateErrors()) { 297 t.Fatalf("Unpacker{%+v}.UnpackSquashed(%q, %q) error: got %v, want %v\n", tc.cfg, tc.dir, tc.image, gotErr, tc.wantErr) 298 } 299 300 if tc.wantErr != nil { 301 return 302 } 303 304 got := mustReadDir(t, tc.dir) 305 if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(contentAndMode{})); diff != "" { 306 t.Fatalf("Unpacker{%+v}.UnpackSquashed(%q, %q) returned unexpected diff (-want +got):\n%s", tc.cfg, tc.dir, tc.image, diff) 307 } 308 }) 309 } 310 } 311 312 func TestUnpackSquashedFromTarball(t *testing.T) { 313 if runtime.GOOS != "linux" { 314 // TODO(b/366163334): Make tests work on Mac and Windows. 315 return 316 } 317 318 tests := []struct { 319 name string 320 cfg *unpack.UnpackerConfig 321 dir string 322 tarEntries []tarEntry 323 want map[string]contentAndMode 324 wantErr error 325 }{ 326 { 327 name: "os.Root_fails_when_writing_files_outside_base_directory_due_to_long_symlink_target", 328 cfg: unpack.DefaultUnpackerConfig().WithRequirer(require.NewFileRequirerPaths([]string{ 329 "/usr/share/doc/a/copyright", 330 "/usr/share/doc/b/copyright", 331 "/usr/share/doc/c/copyright", 332 })), 333 dir: t.TempDir(), 334 tarEntries: []tarEntry{ 335 { 336 Header: &tar.Header{ 337 Name: "/escape/poc.txt", 338 Mode: 0777, 339 Size: int64(len("👻")), 340 }, 341 Data: bytes.NewBufferString("👻"), 342 }, 343 { 344 Header: &tar.Header{ 345 Name: "/usr/share/doc/a/copyright", 346 Typeflag: tar.TypeSymlink, 347 Linkname: "/trampoline", 348 Mode: 0777, 349 }, 350 }, 351 { 352 Header: &tar.Header{ 353 Name: "/trampoline/", 354 Typeflag: tar.TypeSymlink, 355 Linkname: ".", 356 Mode: 0777, 357 }, 358 }, 359 { 360 Header: &tar.Header{ 361 Name: "/usr/share/doc/b/copyright", 362 Typeflag: tar.TypeSymlink, 363 Linkname: "/escape", 364 Mode: 0777, 365 }, 366 }, 367 { 368 Header: &tar.Header{ 369 Name: "/escape", 370 Typeflag: tar.TypeSymlink, 371 Linkname: "trampoline/trampoline/trampoline/trampoline/trampoline/trampoline/trampoline/trampoline/trampoline/trampoline/trampoline/trampoline/trampoline/../../../../../../../../../../../tmp", 372 Mode: 0777, 373 }, 374 }, 375 { 376 Header: &tar.Header{ 377 Name: "/usr/share/doc/c/copyright", 378 Typeflag: tar.TypeSymlink, 379 Linkname: "/escape/poc.txt", 380 Mode: 0777, 381 }, 382 }, 383 }, 384 // No files should be extracted since the tar attempts to write files from outside the unpack 385 // directory. 386 want: map[string]contentAndMode{}, 387 }, 388 { 389 name: "os.Root_detects_writing_files_outside_base_directory", 390 cfg: unpack.DefaultUnpackerConfig().WithRequirer(require.NewFileRequirerPaths([]string{ 391 "/usr/share/doc/a/copyright", 392 "/usr/share/doc/b/copyright", 393 "/usr/share/doc/c/copyright", 394 })), 395 dir: t.TempDir(), 396 tarEntries: []tarEntry{ 397 { 398 Header: &tar.Header{ 399 Name: "/escape/poc.txt", 400 Mode: 0777, 401 Size: int64(len("👻")), 402 }, 403 Data: bytes.NewBufferString("👻"), 404 }, 405 { 406 Header: &tar.Header{ 407 Name: "/usr/share/doc/a/copyright", 408 Typeflag: tar.TypeSymlink, 409 Linkname: "/trampoline", 410 Mode: 0777, 411 }, 412 }, 413 { 414 Header: &tar.Header{ 415 Name: "/trampoline/", 416 Typeflag: tar.TypeSymlink, 417 Linkname: ".", 418 Mode: 0777, 419 }, 420 }, 421 { 422 Header: &tar.Header{ 423 Name: "/usr/share/doc/b/copyright", 424 Typeflag: tar.TypeSymlink, 425 Linkname: "/escape", 426 Mode: 0777, 427 }, 428 }, 429 { 430 Header: &tar.Header{ 431 Name: "/escape", 432 Typeflag: tar.TypeSymlink, 433 Linkname: "trampoline/trampoline/trampoline/trampoline/trampoline/../../../../tmp", 434 Mode: 0777, 435 }, 436 }, 437 { 438 Header: &tar.Header{ 439 Name: "/usr/share/doc/c/copyright", 440 Typeflag: tar.TypeSymlink, 441 Linkname: "/escape/poc.txt", 442 Mode: 0777, 443 }, 444 }, 445 }, 446 // No files should be extracted since the tar attempts to write files from outside the unpack 447 // directory. 448 want: map[string]contentAndMode{}, 449 }, 450 } 451 452 for _, tc := range tests { 453 t.Run(tc.name, func(t *testing.T) { 454 tarDir := t.TempDir() 455 tarPath := filepath.Join(tarDir, "tarball.tar") 456 if err := createTarball(t, tarPath, tc.tarEntries); err != nil { 457 t.Fatalf("Failed to create tarball: %v", err) 458 } 459 460 unpackDir := filepath.Join(tc.dir, "unpack") 461 if err := os.MkdirAll(unpackDir, 0777); err != nil { 462 t.Fatalf("Failed to create unpack dir: %v", err) 463 } 464 465 tmpFilesWant := filesInTmp(t, os.TempDir()) 466 467 u := mustNewUnpacker(t, tc.cfg) 468 gotErr := u.UnpackSquashedFromTarball(unpackDir, tarPath) 469 if !cmp.Equal(gotErr, tc.wantErr, cmpopts.EquateErrors()) { 470 t.Fatalf("Unpacker{%+v}.UnpackSquashedFromTarball(%q, %q) error: got %v, want %v\n", tc.cfg, unpackDir, tarPath, gotErr, tc.wantErr) 471 } 472 473 if tc.wantErr != nil { 474 return 475 } 476 477 got := mustReadDir(t, tc.dir) 478 if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(contentAndMode{})); diff != "" { 479 t.Fatalf("Unpacker{%+v}.UnpackSquashed(%q, %q) returned unexpected diff (-want +got):\n%s", tc.cfg, unpackDir, tarPath, diff) 480 } 481 482 tmpFilesGot := filesInTmp(t, os.TempDir()) 483 484 // Check that no files were added to the tmp directory. 485 less := func(a, b string) bool { return a < b } 486 if diff := cmp.Diff(tmpFilesWant, tmpFilesGot, cmpopts.SortSlices(less)); diff != "" { 487 t.Errorf("returned unexpected diff (-want +got):\n%s", diff) 488 } 489 }) 490 } 491 } 492 493 // mustNewUnpacker creates a new unpacker with the given config. 494 func mustNewUnpacker(t *testing.T, cfg *unpack.UnpackerConfig) *unpack.Unpacker { 495 t.Helper() 496 u, err := unpack.NewUnpacker(cfg) 497 if err != nil { 498 t.Fatalf("Failed to create unpacker: %v", err) 499 } 500 return u 501 } 502 503 // mustReadDir walks the directory dir returning a map of file paths to file content. 504 func mustReadDir(t *testing.T, dir string) map[string]contentAndMode { 505 t.Helper() 506 507 pathToContent := make(map[string]contentAndMode) 508 err := filepath.Walk(dir, func(file string, fi os.FileInfo, err error) error { 509 if err != nil { 510 return fmt.Errorf("failed while walking directory given root (%s): %w", file, err) 511 } 512 513 // Skip directories 514 if fi.IsDir() { 515 return nil 516 } 517 518 // If file is a symlink, check if it points to a directory. If it does point to a directory, 519 // skip it. 520 // 521 // TODO(b/366161799) Handle directories that are pointed to by symlinks. Skipping these 522 // directories won't test their behavior, which is important as some images have symlinks that 523 // point to directories. 524 if (fi.Mode() & fs.ModeType) == fs.ModeSymlink { 525 linkTarget, err := os.Readlink(file) 526 if err != nil { 527 return fmt.Errorf("failed to read destination of symlink %q: %w", file, err) 528 } 529 530 if !filepath.IsAbs(linkTarget) { 531 linkTarget = filepath.Join(filepath.Dir(file), linkTarget) 532 } 533 534 linkFileInfo, err := os.Stat(linkTarget) 535 536 if err != nil { 537 return fmt.Errorf("failed to get file info of target link %q: %w", linkTarget, err) 538 } 539 540 // TODO(b/366161799) Change this to account for directories pointed to by symlinks. 541 if linkFileInfo.IsDir() { 542 return nil 543 } 544 } 545 546 content, err := os.ReadFile(file) 547 if err != nil { 548 return fmt.Errorf("could not read file (%q): %w", file, err) 549 } 550 551 path, err := filepath.Rel(dir, file) 552 if err != nil { 553 return fmt.Errorf("filepath.Rel(%q, %q) failed: %w", dir, file, err) 554 } 555 pathToContent[path] = contentAndMode{ 556 content: string(content), 557 mode: fi.Mode(), 558 } 559 560 return nil 561 }) 562 if err != nil { 563 t.Fatalf("filepath.Walk(%q) failed: %v", dir, err) 564 } 565 566 return pathToContent 567 } 568 569 // mustNewSquashedImage returns a single layer 570 // This image may not contain parent directories because it is constructed from an intermediate tarball. 571 // This is useful for testing the parent directory creation logic of unpack. 572 func mustNewSquashedImage(t *testing.T, pathsToContent map[string]contentAndMode) v1.Image { 573 t.Helper() 574 575 // Squash layers into a single layer. 576 files := make(map[string]contentAndMode) 577 maps.Copy(files, pathsToContent) 578 579 var buf bytes.Buffer 580 w := tar.NewWriter(&buf) 581 582 // Put the files in a single tarball to make a single layer and put that layer in an empty image to 583 // make the minimal image that will work. 584 for path, file := range files { 585 hdr := &tar.Header{ 586 Name: path, 587 Mode: int64(file.mode), 588 Size: int64(len(file.content)), 589 } 590 if err := w.WriteHeader(hdr); err != nil { 591 t.Fatalf("couldn't write header for %s: %v", path, err) 592 } 593 if _, err := w.Write([]byte(file.content)); err != nil { 594 t.Fatalf("couldn't write %s: %v", path, err) 595 } 596 } 597 _ = w.Close() 598 layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { 599 return io.NopCloser(bytes.NewBuffer(buf.Bytes())), nil 600 }) 601 if err != nil { 602 t.Fatalf("unable to create layer: %v", err) 603 } 604 image, err := mutate.AppendLayers(empty.Image, layer) 605 if err != nil { 606 t.Fatalf("unable append layer to image: %v", err) 607 } 608 return image 609 } 610 611 // mustImageFromPath loads an image from a tarball at path. 612 func mustImageFromPath(t *testing.T, path string) v1.Image { 613 t.Helper() 614 image, err := tarball.ImageFromPath(path, nil) 615 if err != nil { 616 t.Fatalf("Failed to load image from path %q: %v", path, err) 617 } 618 return image 619 } 620 621 // filesInTmp returns the list of filenames in tmpDir. 622 func filesInTmp(t *testing.T, tmpDir string) []string { 623 t.Helper() 624 625 var filenames []string 626 files, err := os.ReadDir(tmpDir) 627 if err != nil { 628 t.Fatalf("os.ReadDir('%q') error: %v", tmpDir, err) 629 } 630 631 for _, f := range files { 632 if f.IsDir() { 633 continue 634 } 635 636 filenames = append(filenames, f.Name()) 637 } 638 return filenames 639 } 640 641 // tarEntry represents a single entry in a tarball. It contains the header and data for the entry. 642 // If the data is nil, the entry will be written without any content. 643 type tarEntry struct { 644 Header *tar.Header 645 Data io.Reader 646 } 647 648 // createTarball creates a tarball at tarballPath with the given tar entries. If the tar entry's 649 // data is nil, the entry will be written without any content. 650 func createTarball(t *testing.T, tarballPath string, entries []tarEntry) error { 651 t.Helper() 652 653 file, err := os.Create(tarballPath) 654 if err != nil { 655 return fmt.Errorf("Failed to create tarball: %w", err) 656 } 657 defer file.Close() 658 659 tarWriter := tar.NewWriter(file) 660 defer tarWriter.Close() 661 662 for _, entry := range entries { 663 if err := tarWriter.WriteHeader(entry.Header); err != nil { 664 return fmt.Errorf("writing header for %s: %w", entry.Header.Name, err) 665 } 666 if entry.Data != nil { 667 if _, err := io.Copy(tarWriter, entry.Data); err != nil { 668 return fmt.Errorf("writing content for %s: %w", entry.Header.Name, err) 669 } 670 } 671 } 672 return nil 673 }