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  }