| 1 | package safefile |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "golang.org/x/sys/unix" |
| 12 | ) |
| 13 | |
| 14 | func reapTestStage(t *testing.T, parent, name string, data bool) string { |
| 15 | t.Helper() |
| 16 | path := filepath.Join(parent, name) |
| 17 | if err := os.MkdirAll(path, 0o700); err != nil { |
| 18 | t.Fatal(err) |
| 19 | } |
| 20 | if data { |
| 21 | if err := os.WriteFile(filepath.Join(path, "data"), []byte("abandoned"), 0o600); err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | reapTestAge(t, filepath.Join(path, "data")) |
| 25 | } |
| 26 | reapTestAge(t, path) |
| 27 | return path |
| 28 | } |
| 29 | |
| 30 | func reapTestAge(t *testing.T, path string) { |
| 31 | t.Helper() |
| 32 | old := time.Now().Add(-2 * time.Hour) |
| 33 | if err := os.Chtimes(path, old, old); err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | func TestReapStaleWriterStages(t *testing.T) { |
| 39 | root := t.TempDir() |
| 40 | var removed, retained []string |
| 41 | for i, parent := range []string{root, filepath.Join(root, "versions", "v1"), filepath.Join(root, "staging")} { |
| 42 | removed = append(removed, reapTestStage(t, parent, fmt.Sprintf(".carlos-write-%032x", i+1), true)) |
| 43 | } |
| 44 | removed = append(removed, reapTestStage(t, root, ".carlos-write-"+strings.Repeat("e", 32), false)) |
| 45 | fresh := reapTestStage(t, root, ".carlos-write-"+strings.Repeat("f", 32), true) |
| 46 | now := time.Now() |
| 47 | if err := os.Chtimes(fresh, now, now); err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | retained = append(retained, fresh) |
| 51 | active := reapTestStage(t, root, ".carlos-write-"+strings.Repeat("a", 32), true) |
| 52 | if err := os.Chtimes(filepath.Join(active, "data"), now, now); err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | retained = append(retained, active) |
| 56 | for _, name := range []string{"ordinary", ".carlos-write-abc", ".carlos-write-" + strings.Repeat("A", 32), ".carlos-write-" + strings.Repeat("0", 33), ".carlos-write-" + strings.Repeat("g", 32)} { |
| 57 | retained = append(retained, reapTestStage(t, root, name, true)) |
| 58 | } |
| 59 | if err := Reap(root); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | for _, path := range removed { |
| 63 | if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| 64 | t.Errorf("stale stage remains: %s: %v", path, err) |
| 65 | } |
| 66 | } |
| 67 | for _, path := range retained { |
| 68 | if _, err := os.Stat(filepath.Join(path, "data")); err != nil { |
| 69 | t.Errorf("retained stage changed: %s: %v", path, err) |
| 70 | } |
| 71 | } |
| 72 | if err := Reap(filepath.Join(root, "missing")); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | func TestReapRefusesUnsafeStages(t *testing.T) { |
| 78 | for _, kind := range []string{"stage-symlink", "data-symlink", "hardlink", "fifo", "nested", "extra", "writable", "foreign-owner"} { |
| 79 | t.Run(kind, func(t *testing.T) { |
| 80 | root := t.TempDir() |
| 81 | outside := t.TempDir() |
| 82 | victim := filepath.Join(outside, "victim") |
| 83 | if err := os.WriteFile(victim, []byte("untouched"), 0o600); err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | name := ".carlos-write-" + strings.Repeat("b", 32) |
| 87 | path := reapTestStage(t, root, name, false) |
| 88 | var err error |
| 89 | switch kind { |
| 90 | case "stage-symlink": |
| 91 | if err = os.Remove(path); err == nil { |
| 92 | err = os.Symlink(outside, path) |
| 93 | } |
| 94 | case "data-symlink": |
| 95 | err = os.Symlink(victim, filepath.Join(path, "data")) |
| 96 | case "hardlink": |
| 97 | err = os.Link(victim, filepath.Join(path, "data")) |
| 98 | case "fifo": |
| 99 | err = unix.Mkfifo(filepath.Join(path, "data"), 0o600) |
| 100 | case "nested": |
| 101 | reapTestStage(t, path, name, true) |
| 102 | case "extra": |
| 103 | err = os.WriteFile(filepath.Join(path, "unknown"), nil, 0o600) |
| 104 | case "writable": |
| 105 | err = os.Chmod(path, 0o777) |
| 106 | case "foreign-owner": |
| 107 | if os.Geteuid() != 0 { |
| 108 | t.Skip("requires root to make a foreign-owned stage") |
| 109 | } |
| 110 | err = os.Chown(path, 65534, 65534) |
| 111 | } |
| 112 | if err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | if kind != "stage-symlink" { |
| 116 | reapTestAge(t, path) |
| 117 | } |
| 118 | if err := Reap(root); err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | if _, err := os.Lstat(path); err != nil { |
| 122 | t.Fatalf("unsafe stage removed: %v", err) |
| 123 | } |
| 124 | if kind == "nested" { |
| 125 | if _, err := os.Stat(filepath.Join(path, name, "data")); err != nil { |
| 126 | t.Fatalf("unexpected recursive deletion inside stage: %v", err) |
| 127 | } |
| 128 | } |
| 129 | if body, err := os.ReadFile(victim); err != nil || string(body) != "untouched" { |
| 130 | t.Fatalf("victim changed: %q %v", body, err) |
| 131 | } |
| 132 | }) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | func TestReapPinsRootAndRefusesSwappedStage(t *testing.T) { |
| 137 | root := t.TempDir() |
| 138 | parent := filepath.Join(root, "parent") |
| 139 | out := filepath.Join(root, "outside") |
| 140 | name := ".carlos-write-" + strings.Repeat("c", 32) |
| 141 | stage := reapTestStage(t, parent, name, true) |
| 142 | victim := reapTestStage(t, out, name, true) |
| 143 | dir, err := OpenDir(parent) |
| 144 | if err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | defer dir.Close() |
| 148 | pinned, err := OpenDir(stage) |
| 149 | if err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | defer pinned.Close() |
| 153 | if err := os.Rename(stage, stage+"-moved"); err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | if err := os.Symlink(victim, stage); err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | if err := reapStage(int(dir.Fd()), name, pinned, time.Now().Add(-time.Hour)); err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | if _, err := os.Stat(filepath.Join(stage+"-moved", "data")); err != nil { |
| 163 | t.Fatalf("replaced entry was not refused: %v", err) |
| 164 | } |
| 165 | if err := os.Rename(parent, parent+"-moved"); err != nil { |
| 166 | t.Fatal(err) |
| 167 | } |
| 168 | if err := os.Symlink(out, parent); err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | if err := reapTree(dir, time.Now().Add(-time.Hour), 0); err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | if _, err := os.Stat(filepath.Join(victim, "data")); err != nil { |
| 175 | t.Fatalf("followed swapped parent: %v", err) |
| 176 | } |
| 177 | if err := Reap(parent); err == nil { |
| 178 | t.Fatal("accepted symlink root") |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | func TestReapRefusesBindMount(t *testing.T) { |
| 183 | root := t.TempDir() |
| 184 | out := t.TempDir() |
| 185 | name := ".carlos-write-" + strings.Repeat("d", 32) |
| 186 | victim := reapTestStage(t, out, name, true) |
| 187 | mount := filepath.Join(root, "mounted") |
| 188 | if err := os.Mkdir(mount, 0o700); err != nil { |
| 189 | t.Fatal(err) |
| 190 | } |
| 191 | if err := unix.Mount(out, mount, "", unix.MS_BIND, ""); err != nil { |
| 192 | if err == unix.EPERM || err == unix.EACCES { |
| 193 | t.Skip("bind mounts unavailable") |
| 194 | } |
| 195 | t.Fatal(err) |
| 196 | } |
| 197 | defer func() { |
| 198 | if err := unix.Unmount(mount, 0); err != nil { |
| 199 | t.Errorf("unmount: %v", err) |
| 200 | } |
| 201 | }() |
| 202 | if err := Reap(root); err != nil { |
| 203 | t.Fatal(err) |
| 204 | } |
| 205 | if _, err := os.Stat(filepath.Join(victim, "data")); err != nil { |
| 206 | t.Fatalf("traversed bind mount: %v", err) |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | func TestReapContinuesPastUnreadableDirectory(t *testing.T) { |
| 211 | if os.Geteuid() == 0 { |
| 212 | t.Skip("root bypasses directory permissions") |
| 213 | } |
| 214 | root := t.TempDir() |
| 215 | unreadable := filepath.Join(root, "unreadable") |
| 216 | if err := os.Mkdir(unreadable, 0o700); err != nil { |
| 217 | t.Fatal(err) |
| 218 | } |
| 219 | if err := os.Chmod(unreadable, 0); err != nil { |
| 220 | t.Fatal(err) |
| 221 | } |
| 222 | defer os.Chmod(unreadable, 0o700) |
| 223 | stale := reapTestStage(t, root, ".carlos-write-"+strings.Repeat("9", 32), true) |
| 224 | if err := Reap(root); err == nil { |
| 225 | t.Fatal("unreadable directory was not reported") |
| 226 | } |
| 227 | if _, err := os.Lstat(stale); !os.IsNotExist(err) { |
| 228 | t.Fatalf("unreadable sibling prevented cleanup: %v", err) |
| 229 | } |
| 230 | } |
| 231 | |