//go:build unix package archive import ( "errors" "io" "os" "path/filepath" "strings" "syscall" "testing" "time" ) // Between the walk and the open, a tenant can swap a listed file for a // FIFO (an open would block forever), another file, or a symlink out of // the files directory (the open would read a host file into the archive). // Each is refused. func TestOpenRefusesASwappedEntry(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "a"), []byte("a"), 0o644) mustWrite(t, filepath.Join(dir, "b"), []byte("b"), 0o644) outside := filepath.Join(t.TempDir(), "host-secret") mustWrite(t, outside, []byte("secret"), 0o600) if err := os.Symlink(outside, filepath.Join(dir, "esc")); err != nil { t.Fatal(err) } if err := syscall.Mkfifo(filepath.Join(dir, "fifo"), 0o644); err != nil { t.Fatal(err) } root, err := os.OpenRoot(dir) if err != nil { t.Fatal(err) } defer root.Close() listed, err := root.Lstat("a") if err != nil { t.Fatal(err) } for _, swapped := range []string{"fifo", "b", "esc"} { done := make(chan error, 1) go func() { rc, err := openItemChecked(item{name: "files/" + swapped, rel: swapped, root: root, info: listed}) if rc != nil { rc.Close() } done <- err }() select { case err := <-done: if err == nil { t.Fatalf("%s opened as the listed file", swapped) } if swapped != "esc" && !errors.Is(err, ErrChanged) { t.Fatalf("%s: %v", swapped, err) } case <-time.After(5 * time.Second): t.Fatalf("%s: open blocked", swapped) } } // The listed file itself opens. rc, err := openItemChecked(item{name: "files/a", rel: "a", root: root, info: listed}) if err != nil { t.Fatal(err) } rc.Close() } // Fix round 1, ruling 1: Phase 1 does not carry hard links. A hard link to // a file outside the instance directory is a regular file with the listed // identity, and os.Root constrains names, not inodes: without a link-count // check Build would read the outside inode. Two links inside the directory // are refused too. func TestBuildRefusesHardLinks(t *testing.T) { dir, env := filesFixture(t) outside := filepath.Join(t.TempDir(), "host-secret") mustWrite(t, outside, []byte("secret"), 0o600) if err := os.Link(outside, filepath.Join(dir, "hl")); err != nil { t.Skipf("no hard links here: %v", err) } if _, _, _, err := Build(dir, env, io.Discard); !errors.Is(err, ErrSpecialFile) || !strings.Contains(err.Error(), "hl") || !strings.Contains(err.Error(), "hard link") { t.Fatalf("outside hard link: %v", err) } if _, _, _, err := PlainDigest(dir, env); !errors.Is(err, ErrSpecialFile) { t.Fatalf("PlainDigest of an outside hard link: %v", err) } dir2, env2 := filesFixture(t) if err := os.Link(filepath.Join(dir2, "a.txt"), filepath.Join(dir2, "sub", "a-again")); err != nil { t.Fatal(err) } if _, _, _, err := Build(dir2, env2, io.Discard); !errors.Is(err, ErrSpecialFile) || !strings.Contains(err.Error(), "hard link") { t.Fatalf("two links inside: %v", err) } } // A Build refusal that names a file carries the path as data // (*archive.PathError), so a caller can say which file without reading the // error's text; errors.Is and the text are unchanged. func TestBuildRefusalsCarryTheirPath(t *testing.T) { for name, tc := range map[string]struct { setup func(t *testing.T, files string) sentinel error path string }{ "symlink": {func(t *testing.T, files string) { if err := os.Symlink("/etc/passwd", filepath.Join(files, "link")); err != nil { t.Fatal(err) } }, ErrSpecialFile, "link"}, "hard link": {func(t *testing.T, files string) { if err := os.WriteFile(filepath.Join(files, "a"), []byte("x"), 0o644); err != nil { t.Fatal(err) } if err := os.Link(filepath.Join(files, "a"), filepath.Join(files, "b")); err != nil { t.Fatal(err) } }, ErrSpecialFile, "a"}, } { t.Run(name, func(t *testing.T) { files := filepath.Join(t.TempDir(), "files") if err := os.MkdirAll(files, 0o755); err != nil { t.Fatal(err) } tc.setup(t, files) _, _, _, err := PlainDigest(files, "") var pe *PathError if !errors.Is(err, tc.sentinel) || !errors.As(err, &pe) || pe.Path != tc.path { t.Fatalf("err %v, path error %+v", err, pe) } if !strings.Contains(err.Error(), tc.sentinel.Error()+": ") { t.Fatalf("the text changed: %q", err) } }) } } // A FIFO (a platform socket or pipe in a nested instance directory) that // skip accepts is never opened or typed: BuildFiltered archives around it. // The same FIFO unskipped is refused, naming it. func TestBuildFilteredNeverTypesASkippedFIFO(t *testing.T) { files := t.TempDir() if err := os.WriteFile(filepath.Join(files, "a"), []byte("a"), 0o644); err != nil { t.Fatal(err) } if err := syscall.Mkfifo(filepath.Join(files, "app.fifo"), 0o644); err != nil { t.Fatal(err) } var pe *PathError if _, _, _, err := BuildFiltered(files, "", nil, io.Discard); !errors.Is(err, ErrSpecialFile) || !errors.As(err, &pe) || pe.Path != "app.fifo" { t.Fatalf("unskipped FIFO: %v", err) } _, entries, _, err := BuildFiltered(files, "", func(rel string) bool { return rel == "app.fifo" }, io.Discard) if err != nil || len(entries) != 1 || entries[0].Path != "files/a" { t.Fatalf("skipped FIFO: %v %v", entries, err) } }