| 1 | //go:build unix |
| 2 | |
| 3 | package archive |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "io" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "syscall" |
| 12 | "testing" |
| 13 | "time" |
| 14 | ) |
| 15 | |
| 16 | // Between the walk and the open, a tenant can swap a listed file for a |
| 17 | // FIFO (an open would block forever), another file, or a symlink out of |
| 18 | // the files directory (the open would read a host file into the archive). |
| 19 | // Each is refused. |
| 20 | func TestOpenRefusesASwappedEntry(t *testing.T) { |
| 21 | dir := t.TempDir() |
| 22 | mustWrite(t, filepath.Join(dir, "a"), []byte("a"), 0o644) |
| 23 | mustWrite(t, filepath.Join(dir, "b"), []byte("b"), 0o644) |
| 24 | outside := filepath.Join(t.TempDir(), "host-secret") |
| 25 | mustWrite(t, outside, []byte("secret"), 0o600) |
| 26 | if err := os.Symlink(outside, filepath.Join(dir, "esc")); err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | if err := syscall.Mkfifo(filepath.Join(dir, "fifo"), 0o644); err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | root, err := os.OpenRoot(dir) |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | defer root.Close() |
| 37 | listed, err := root.Lstat("a") |
| 38 | if err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | for _, swapped := range []string{"fifo", "b", "esc"} { |
| 42 | done := make(chan error, 1) |
| 43 | go func() { |
| 44 | rc, err := openItemChecked(item{name: "files/" + swapped, rel: swapped, root: root, info: listed}) |
| 45 | if rc != nil { |
| 46 | rc.Close() |
| 47 | } |
| 48 | done <- err |
| 49 | }() |
| 50 | select { |
| 51 | case err := <-done: |
| 52 | if err == nil { |
| 53 | t.Fatalf("%s opened as the listed file", swapped) |
| 54 | } |
| 55 | if swapped != "esc" && !errors.Is(err, ErrChanged) { |
| 56 | t.Fatalf("%s: %v", swapped, err) |
| 57 | } |
| 58 | case <-time.After(5 * time.Second): |
| 59 | t.Fatalf("%s: open blocked", swapped) |
| 60 | } |
| 61 | } |
| 62 | // The listed file itself opens. |
| 63 | rc, err := openItemChecked(item{name: "files/a", rel: "a", root: root, info: listed}) |
| 64 | if err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | rc.Close() |
| 68 | } |
| 69 | |
| 70 | // Fix round 1, ruling 1: Phase 1 does not carry hard links. A hard link to |
| 71 | // a file outside the instance directory is a regular file with the listed |
| 72 | // identity, and os.Root constrains names, not inodes: without a link-count |
| 73 | // check Build would read the outside inode. Two links inside the directory |
| 74 | // are refused too. |
| 75 | func TestBuildRefusesHardLinks(t *testing.T) { |
| 76 | dir, env := filesFixture(t) |
| 77 | outside := filepath.Join(t.TempDir(), "host-secret") |
| 78 | mustWrite(t, outside, []byte("secret"), 0o600) |
| 79 | if err := os.Link(outside, filepath.Join(dir, "hl")); err != nil { |
| 80 | t.Skipf("no hard links here: %v", err) |
| 81 | } |
| 82 | if _, _, _, err := Build(dir, env, io.Discard); !errors.Is(err, ErrSpecialFile) || !strings.Contains(err.Error(), "hl") || !strings.Contains(err.Error(), "hard link") { |
| 83 | t.Fatalf("outside hard link: %v", err) |
| 84 | } |
| 85 | if _, _, _, err := PlainDigest(dir, env); !errors.Is(err, ErrSpecialFile) { |
| 86 | t.Fatalf("PlainDigest of an outside hard link: %v", err) |
| 87 | } |
| 88 | |
| 89 | dir2, env2 := filesFixture(t) |
| 90 | if err := os.Link(filepath.Join(dir2, "a.txt"), filepath.Join(dir2, "sub", "a-again")); err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | if _, _, _, err := Build(dir2, env2, io.Discard); !errors.Is(err, ErrSpecialFile) || !strings.Contains(err.Error(), "hard link") { |
| 94 | t.Fatalf("two links inside: %v", err) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | // A Build refusal that names a file carries the path as data |
| 99 | // (*archive.PathError), so a caller can say which file without reading the |
| 100 | // error's text; errors.Is and the text are unchanged. |
| 101 | func TestBuildRefusalsCarryTheirPath(t *testing.T) { |
| 102 | for name, tc := range map[string]struct { |
| 103 | setup func(t *testing.T, files string) |
| 104 | sentinel error |
| 105 | path string |
| 106 | }{ |
| 107 | "symlink": {func(t *testing.T, files string) { |
| 108 | if err := os.Symlink("/etc/passwd", filepath.Join(files, "link")); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | }, ErrSpecialFile, "link"}, |
| 112 | "hard link": {func(t *testing.T, files string) { |
| 113 | if err := os.WriteFile(filepath.Join(files, "a"), []byte("x"), 0o644); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | if err := os.Link(filepath.Join(files, "a"), filepath.Join(files, "b")); err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | }, ErrSpecialFile, "a"}, |
| 120 | } { |
| 121 | t.Run(name, func(t *testing.T) { |
| 122 | files := filepath.Join(t.TempDir(), "files") |
| 123 | if err := os.MkdirAll(files, 0o755); err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | tc.setup(t, files) |
| 127 | _, _, _, err := PlainDigest(files, "") |
| 128 | var pe *PathError |
| 129 | if !errors.Is(err, tc.sentinel) || !errors.As(err, &pe) || pe.Path != tc.path { |
| 130 | t.Fatalf("err %v, path error %+v", err, pe) |
| 131 | } |
| 132 | if !strings.Contains(err.Error(), tc.sentinel.Error()+": ") { |
| 133 | t.Fatalf("the text changed: %q", err) |
| 134 | } |
| 135 | }) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // A FIFO (a platform socket or pipe in a nested instance directory) that |
| 140 | // skip accepts is never opened or typed: BuildFiltered archives around it. |
| 141 | // The same FIFO unskipped is refused, naming it. |
| 142 | func TestBuildFilteredNeverTypesASkippedFIFO(t *testing.T) { |
| 143 | files := t.TempDir() |
| 144 | if err := os.WriteFile(filepath.Join(files, "a"), []byte("a"), 0o644); err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | if err := syscall.Mkfifo(filepath.Join(files, "app.fifo"), 0o644); err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | var pe *PathError |
| 151 | if _, _, _, err := BuildFiltered(files, "", nil, io.Discard); !errors.Is(err, ErrSpecialFile) || !errors.As(err, &pe) || pe.Path != "app.fifo" { |
| 152 | t.Fatalf("unskipped FIFO: %v", err) |
| 153 | } |
| 154 | _, entries, _, err := BuildFiltered(files, "", func(rel string) bool { return rel == "app.fifo" }, io.Discard) |
| 155 | if err != nil || len(entries) != 1 || entries[0].Path != "files/a" { |
| 156 | t.Fatalf("skipped FIFO: %v %v", entries, err) |
| 157 | } |
| 158 | } |
| 159 | |