| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "syscall" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/moves" |
| 16 | ) |
| 17 | |
| 18 | // The floor gate (carlos system floor-allows-prev) is what the two system |
| 19 | // units run, as root, in place of `cmp -s` on the agent's floor file. The |
| 20 | // agent owns /data, so between the shell's test and the compare it could |
| 21 | // swap in a symlink (root compares a file of the agent's choosing, a |
| 22 | // one-bit oracle) or a FIFO (root blocks until the unit times out). The |
| 23 | // gate opens each file once, refuses anything but a small single-link |
| 24 | // regular file with an expected owner, and compares what it read from that |
| 25 | // same descriptor. |
| 26 | |
| 27 | type floorGateFixture struct { |
| 28 | data, opt string |
| 29 | floor, verdict string |
| 30 | floorOwners []uint32 |
| 31 | verdictOwners []uint32 |
| 32 | floorContent string |
| 33 | verdictContent string |
| 34 | } |
| 35 | |
| 36 | func newFloorGateFixture(t *testing.T) *floorGateFixture { |
| 37 | t.Helper() |
| 38 | root := t.TempDir() |
| 39 | f := &floorGateFixture{ |
| 40 | data: filepath.Join(root, "data"), |
| 41 | opt: filepath.Join(root, "opt"), |
| 42 | floorOwners: []uint32{uint32(os.Getuid())}, |
| 43 | verdictOwners: []uint32{uint32(os.Getuid())}, |
| 44 | floorContent: "v0.30.0\n", |
| 45 | verdictContent: "v0.30.0\n", |
| 46 | } |
| 47 | f.floor = filepath.Join(f.data, moves.FloorMinReleaseFile) |
| 48 | f.verdict = filepath.Join(f.opt, moves.PrevAllowedFile) |
| 49 | for _, d := range []string{filepath.Dir(f.floor), f.opt} { |
| 50 | if err := os.MkdirAll(d, 0o755); err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | } |
| 54 | return f |
| 55 | } |
| 56 | |
| 57 | func (f *floorGateFixture) write(t *testing.T) { |
| 58 | t.Helper() |
| 59 | if err := os.WriteFile(f.floor, []byte(f.floorContent), 0o644); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | if err := os.WriteFile(f.verdict, []byte(f.verdictContent), 0o644); err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | func (f *floorGateFixture) run() error { |
| 68 | return floorAllowsPrev(f.floor, f.verdict, f.floorOwners, f.verdictOwners) |
| 69 | } |
| 70 | |
| 71 | // runWithin runs the gate and fails the test if it has not answered within |
| 72 | // d: a gate that opens a FIFO for reading without O_NONBLOCK parks forever. |
| 73 | func (f *floorGateFixture) runWithin(t *testing.T, d time.Duration) error { |
| 74 | t.Helper() |
| 75 | done := make(chan error, 1) |
| 76 | go func() { done <- f.run() }() |
| 77 | select { |
| 78 | case err := <-done: |
| 79 | return err |
| 80 | case <-time.After(d): |
| 81 | t.Fatalf("the floor gate did not answer within %v: it blocked on the file", d) |
| 82 | return nil |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func TestFloorGateMatchingContentPasses(t *testing.T) { |
| 87 | f := newFloorGateFixture(t) |
| 88 | f.write(t) |
| 89 | if err := f.run(); err != nil { |
| 90 | t.Fatalf("a verdict naming the floor byte for byte was refused: %v", err) |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | func TestFloorGateMismatchFails(t *testing.T) { |
| 95 | for _, tc := range []struct{ name, floor, verdict string }{ |
| 96 | {"an older verdict", "v0.30.0\n", "v0.29.0\n"}, |
| 97 | {"a missing newline", "v0.30.0\n", "v0.30.0"}, |
| 98 | {"an extra byte", "v0.30.0\n", "v0.30.0\n\n"}, |
| 99 | {"an empty verdict", "v0.30.0\n", ""}, |
| 100 | } { |
| 101 | t.Run(tc.name, func(t *testing.T) { |
| 102 | f := newFloorGateFixture(t) |
| 103 | f.floorContent, f.verdictContent = tc.floor, tc.verdict |
| 104 | f.write(t) |
| 105 | err := f.run() |
| 106 | if err == nil { |
| 107 | t.Fatal("a verdict that does not name the floor passed") |
| 108 | } |
| 109 | if !errors.Is(err, errFloorGateMismatch) { |
| 110 | t.Fatalf("err = %v, want the mismatch", err) |
| 111 | } |
| 112 | }) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | func TestFloorGateMissingFilesFail(t *testing.T) { |
| 117 | t.Run("no verdict", func(t *testing.T) { |
| 118 | f := newFloorGateFixture(t) |
| 119 | f.write(t) |
| 120 | os.Remove(f.verdict) |
| 121 | if err := f.run(); err == nil { |
| 122 | t.Fatal("no verdict passed") |
| 123 | } |
| 124 | }) |
| 125 | t.Run("no floor", func(t *testing.T) { |
| 126 | f := newFloorGateFixture(t) |
| 127 | f.write(t) |
| 128 | os.Remove(f.floor) |
| 129 | if err := f.run(); err == nil { |
| 130 | t.Fatal("a floor removed after the shell's test passed") |
| 131 | } |
| 132 | }) |
| 133 | } |
| 134 | |
| 135 | func TestFloorGateRefusesASymlinkedFloor(t *testing.T) { |
| 136 | f := newFloorGateFixture(t) |
| 137 | f.write(t) |
| 138 | // The agent's pick: a file that happens to hold the verdict's bytes. |
| 139 | chosen := filepath.Join(f.data, "chosen") |
| 140 | if err := os.WriteFile(chosen, []byte(f.verdictContent), 0o644); err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | os.Remove(f.floor) |
| 144 | if err := os.Symlink(chosen, f.floor); err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | if err := f.run(); err == nil { |
| 148 | t.Fatal("a symlink at the floor path was followed") |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | func TestFloorGateRefusesASymlinkedMovesDir(t *testing.T) { |
| 153 | f := newFloorGateFixture(t) |
| 154 | f.write(t) |
| 155 | elsewhere := filepath.Join(f.data, "elsewhere") |
| 156 | if err := os.Rename(filepath.Dir(f.floor), elsewhere); err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | if err := os.Symlink(elsewhere, filepath.Dir(f.floor)); err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | if err := f.run(); err == nil { |
| 163 | t.Fatal("a symlink in place of .moves was followed") |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | func TestFloorGateRefusesAFIFOWithoutBlocking(t *testing.T) { |
| 168 | f := newFloorGateFixture(t) |
| 169 | f.write(t) |
| 170 | os.Remove(f.floor) |
| 171 | if err := syscall.Mkfifo(f.floor, 0o644); err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | if err := f.runWithin(t, 5*time.Second); err == nil { |
| 175 | t.Fatal("a FIFO at the floor path passed") |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | func TestFloorGateRefusesAFIFOVerdictWithoutBlocking(t *testing.T) { |
| 180 | f := newFloorGateFixture(t) |
| 181 | f.write(t) |
| 182 | os.Remove(f.verdict) |
| 183 | if err := syscall.Mkfifo(f.verdict, 0o644); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | if err := f.runWithin(t, 5*time.Second); err == nil { |
| 187 | t.Fatal("a FIFO at the verdict path passed") |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | func TestFloorGateRefusesASymlinkedVerdict(t *testing.T) { |
| 192 | f := newFloorGateFixture(t) |
| 193 | f.write(t) |
| 194 | other := filepath.Join(f.opt, "other") |
| 195 | if err := os.WriteFile(other, []byte(f.floorContent), 0o644); err != nil { |
| 196 | t.Fatal(err) |
| 197 | } |
| 198 | os.Remove(f.verdict) |
| 199 | if err := os.Symlink(other, f.verdict); err != nil { |
| 200 | t.Fatal(err) |
| 201 | } |
| 202 | if err := f.run(); err == nil { |
| 203 | t.Fatal("a symlink at the verdict path was followed") |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | func TestFloorGateRefusesAHardLinkedFloor(t *testing.T) { |
| 208 | f := newFloorGateFixture(t) |
| 209 | f.write(t) |
| 210 | if err := os.Link(f.floor, filepath.Join(f.data, "second-name")); err != nil { |
| 211 | t.Fatal(err) |
| 212 | } |
| 213 | if err := f.run(); err == nil { |
| 214 | t.Fatal("a floor file with two links passed") |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | func TestFloorGateRefusesAHardLinkedVerdict(t *testing.T) { |
| 219 | f := newFloorGateFixture(t) |
| 220 | f.write(t) |
| 221 | if err := os.Link(f.verdict, filepath.Join(f.opt, "second-name")); err != nil { |
| 222 | t.Fatal(err) |
| 223 | } |
| 224 | if err := f.run(); err == nil { |
| 225 | t.Fatal("a verdict file with two links passed") |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | func TestFloorGateRefusesOversizedFiles(t *testing.T) { |
| 230 | // Equal bytes, which cmp -s would have passed: the size is refused |
| 231 | // before any compare. |
| 232 | f := newFloorGateFixture(t) |
| 233 | big := strings.Repeat("v", floorGateMaxBytes) + "\n" |
| 234 | f.floorContent, f.verdictContent = big, big |
| 235 | f.write(t) |
| 236 | if err := f.run(); err == nil { |
| 237 | t.Fatalf("a %d-byte floor passed", len(big)) |
| 238 | } |
| 239 | t.Run("verdict alone", func(t *testing.T) { |
| 240 | f := newFloorGateFixture(t) |
| 241 | f.verdictContent = big |
| 242 | f.write(t) |
| 243 | if err := f.run(); err == nil { |
| 244 | t.Fatalf("a %d-byte verdict passed", len(big)) |
| 245 | } |
| 246 | }) |
| 247 | t.Run("at the limit", func(t *testing.T) { |
| 248 | f := newFloorGateFixture(t) |
| 249 | exact := strings.Repeat("v", floorGateMaxBytes) |
| 250 | f.floorContent, f.verdictContent = exact, exact |
| 251 | f.write(t) |
| 252 | if err := f.run(); err != nil { |
| 253 | t.Fatalf("a %d-byte floor and verdict were refused: %v", len(exact), err) |
| 254 | } |
| 255 | }) |
| 256 | } |
| 257 | |
| 258 | // A file that passes the fstat's size check and then grows before the read |
| 259 | // is refused by the bounded read. Both files grow by the same bytes, so |
| 260 | // without that bound the two reads, cut at the same length, would match. |
| 261 | func TestFloorGateRefusesAFileThatGrowsAfterTheStat(t *testing.T) { |
| 262 | f := newFloorGateFixture(t) |
| 263 | f.write(t) |
| 264 | grown := map[string]bool{} |
| 265 | orig := floorGateAfterStat |
| 266 | floorGateAfterStat = func(path string) { |
| 267 | grown[path] = true |
| 268 | w, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0) |
| 269 | if err != nil { |
| 270 | t.Error(err) |
| 271 | return |
| 272 | } |
| 273 | defer w.Close() |
| 274 | if _, err := w.WriteString(strings.Repeat("v", 2*floorGateMaxBytes)); err != nil { |
| 275 | t.Error(err) |
| 276 | } |
| 277 | } |
| 278 | t.Cleanup(func() { floorGateAfterStat = orig }) |
| 279 | err := f.run() |
| 280 | if err == nil { |
| 281 | t.Fatal("a floor that grew past the limit after the fstat passed") |
| 282 | } |
| 283 | if !errors.Is(err, errFloorGateUnsafe) { |
| 284 | t.Fatalf("err = %v, want the bounded read's refusal", err) |
| 285 | } |
| 286 | if !grown[f.floor] { |
| 287 | t.Fatal("the hook never ran between the floor's fstat and its read") |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | func TestFloorGateRefusesAnUnexpectedOwner(t *testing.T) { |
| 292 | // Without root a test cannot chown, so the expected owners move instead. |
| 293 | other := uint32(os.Getuid()) + 1 |
| 294 | t.Run("floor", func(t *testing.T) { |
| 295 | f := newFloorGateFixture(t) |
| 296 | f.write(t) |
| 297 | f.floorOwners = []uint32{other} |
| 298 | if err := f.run(); err == nil { |
| 299 | t.Fatal("a floor owned by an unexpected account passed") |
| 300 | } |
| 301 | }) |
| 302 | t.Run("verdict", func(t *testing.T) { |
| 303 | f := newFloorGateFixture(t) |
| 304 | f.write(t) |
| 305 | f.verdictOwners = []uint32{other} |
| 306 | if err := f.run(); err == nil { |
| 307 | t.Fatal("a verdict owned by an unexpected account passed") |
| 308 | } |
| 309 | }) |
| 310 | t.Run("no owner expected", func(t *testing.T) { |
| 311 | f := newFloorGateFixture(t) |
| 312 | f.write(t) |
| 313 | f.floorOwners = nil |
| 314 | if err := f.run(); err == nil { |
| 315 | t.Fatal("a floor passed with no expected owner") |
| 316 | } |
| 317 | }) |
| 318 | } |
| 319 | |
| 320 | // checkFloorGateStat is the fstat judgement on its own, so the owner rule |
| 321 | // is pinned with real root and service uids a test process cannot create. |
| 322 | func TestCheckFloorGateStat(t *testing.T) { |
| 323 | const svc = 998 |
| 324 | ok := floorGateStat{mode: syscall.S_IFREG | 0o644, uid: svc, nlink: 1, size: 8} |
| 325 | owners := []uint32{0, svc} |
| 326 | if err := checkFloorGateStat(ok, owners); err != nil { |
| 327 | t.Fatalf("a good file was refused: %v", err) |
| 328 | } |
| 329 | asRoot := ok |
| 330 | asRoot.uid = 0 |
| 331 | if err := checkFloorGateStat(asRoot, owners); err != nil { |
| 332 | t.Fatalf("a root-owned file (root's apply writes the floor) was refused: %v", err) |
| 333 | } |
| 334 | for name, mut := range map[string]func(*floorGateStat){ |
| 335 | "another account": func(s *floorGateStat) { s.uid = 1000 }, |
| 336 | "a FIFO": func(s *floorGateStat) { s.mode = syscall.S_IFIFO | 0o644 }, |
| 337 | "a symlink": func(s *floorGateStat) { s.mode = syscall.S_IFLNK | 0o777 }, |
| 338 | "a directory": func(s *floorGateStat) { s.mode = syscall.S_IFDIR | 0o755 }, |
| 339 | "a device": func(s *floorGateStat) { s.mode = syscall.S_IFCHR | 0o644 }, |
| 340 | "a socket": func(s *floorGateStat) { s.mode = syscall.S_IFSOCK | 0o644 }, |
| 341 | "two links": func(s *floorGateStat) { s.nlink = 2 }, |
| 342 | "no links": func(s *floorGateStat) { s.nlink = 0 }, |
| 343 | "too big": func(s *floorGateStat) { s.size = floorGateMaxBytes + 1 }, |
| 344 | "negative size": func(s *floorGateStat) { s.size = -1 }, |
| 345 | } { |
| 346 | s := ok |
| 347 | mut(&s) |
| 348 | if err := checkFloorGateStat(s, owners); err == nil { |
| 349 | t.Errorf("%s passed", name) |
| 350 | } |
| 351 | } |
| 352 | if err := checkFloorGateStat(ok, []uint32{0}); err == nil { |
| 353 | t.Error("a service-owned file passed where only root is expected (the verdict)") |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | // The command's exit code is what the shell reads: 0 only for a match, and |
| 358 | // every refusal, failure or misuse the same non-zero as a mismatch. |
| 359 | func TestFloorGateMainExitCodes(t *testing.T) { |
| 360 | f := newFloorGateFixture(t) |
| 361 | f.write(t) |
| 362 | run := func(args ...string) int { |
| 363 | var b strings.Builder |
| 364 | return floorGateMain(args, f.floorOwners, f.verdictOwners, &b) |
| 365 | } |
| 366 | if code := run(f.floor, f.verdict); code != 0 { |
| 367 | t.Fatalf("a match exited %d", code) |
| 368 | } |
| 369 | mismatch := newFloorGateFixture(t) |
| 370 | mismatch.verdictContent = "v0.29.0\n" |
| 371 | mismatch.write(t) |
| 372 | codeMismatch := run(mismatch.floor, mismatch.verdict) |
| 373 | if codeMismatch == 0 { |
| 374 | t.Fatal("a mismatch exited 0") |
| 375 | } |
| 376 | for name, args := range map[string][]string{ |
| 377 | "no arguments": nil, |
| 378 | "one argument": {f.floor}, |
| 379 | "three arguments": {f.floor, f.verdict, f.verdict}, |
| 380 | "a relative floor": {"data/.moves/floor.min-release", f.verdict}, |
| 381 | "a relative verdict": {f.floor, "carlos.prev.allowed"}, |
| 382 | "a missing verdict": {f.floor, filepath.Join(f.opt, "absent")}, |
| 383 | } { |
| 384 | if code := run(args...); code != codeMismatch { |
| 385 | t.Errorf("%s exited %d, want the mismatch's %d", name, code, codeMismatch) |
| 386 | } |
| 387 | } |
| 388 | } |
| 389 | |
| 390 | // MV-N317 is said whole by floorGateUnsafe, and says exactly what the old |
| 391 | // fmt.Errorf("%s %w", path, errFloorGateUnsafe) said, with errors.Is intact. |
| 392 | func TestFloorGateUnsafeSaysTheSameSentence(t *testing.T) { |
| 393 | const path = "/opt/carlos/moves-floor" |
| 394 | err := floorGateUnsafe(path) |
| 395 | if got, want := err.Error(), fmt.Errorf("%s %w", path, errFloorGateUnsafe).Error(); got != want { |
| 396 | t.Errorf("err = %q, want %q", got, want) |
| 397 | } |
| 398 | if !errors.Is(err, errFloorGateUnsafe) { |
| 399 | t.Error("errors.Is(floorGateUnsafe(path), errFloorGateUnsafe) = false") |
| 400 | } |
| 401 | } |
| 402 | |