//go:build linux package host import ( "errors" "fmt" "os" "path/filepath" "strings" "syscall" "testing" "time" "github.com/carlosframework/platform/internal/moves" ) // The floor gate (carlos system floor-allows-prev) is what the two system // units run, as root, in place of `cmp -s` on the agent's floor file. The // agent owns /data, so between the shell's test and the compare it could // swap in a symlink (root compares a file of the agent's choosing, a // one-bit oracle) or a FIFO (root blocks until the unit times out). The // gate opens each file once, refuses anything but a small single-link // regular file with an expected owner, and compares what it read from that // same descriptor. type floorGateFixture struct { data, opt string floor, verdict string floorOwners []uint32 verdictOwners []uint32 floorContent string verdictContent string } func newFloorGateFixture(t *testing.T) *floorGateFixture { t.Helper() root := t.TempDir() f := &floorGateFixture{ data: filepath.Join(root, "data"), opt: filepath.Join(root, "opt"), floorOwners: []uint32{uint32(os.Getuid())}, verdictOwners: []uint32{uint32(os.Getuid())}, floorContent: "v0.30.0\n", verdictContent: "v0.30.0\n", } f.floor = filepath.Join(f.data, moves.FloorMinReleaseFile) f.verdict = filepath.Join(f.opt, moves.PrevAllowedFile) for _, d := range []string{filepath.Dir(f.floor), f.opt} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } return f } func (f *floorGateFixture) write(t *testing.T) { t.Helper() if err := os.WriteFile(f.floor, []byte(f.floorContent), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(f.verdict, []byte(f.verdictContent), 0o644); err != nil { t.Fatal(err) } } func (f *floorGateFixture) run() error { return floorAllowsPrev(f.floor, f.verdict, f.floorOwners, f.verdictOwners) } // runWithin runs the gate and fails the test if it has not answered within // d: a gate that opens a FIFO for reading without O_NONBLOCK parks forever. func (f *floorGateFixture) runWithin(t *testing.T, d time.Duration) error { t.Helper() done := make(chan error, 1) go func() { done <- f.run() }() select { case err := <-done: return err case <-time.After(d): t.Fatalf("the floor gate did not answer within %v: it blocked on the file", d) return nil } } func TestFloorGateMatchingContentPasses(t *testing.T) { f := newFloorGateFixture(t) f.write(t) if err := f.run(); err != nil { t.Fatalf("a verdict naming the floor byte for byte was refused: %v", err) } } func TestFloorGateMismatchFails(t *testing.T) { for _, tc := range []struct{ name, floor, verdict string }{ {"an older verdict", "v0.30.0\n", "v0.29.0\n"}, {"a missing newline", "v0.30.0\n", "v0.30.0"}, {"an extra byte", "v0.30.0\n", "v0.30.0\n\n"}, {"an empty verdict", "v0.30.0\n", ""}, } { t.Run(tc.name, func(t *testing.T) { f := newFloorGateFixture(t) f.floorContent, f.verdictContent = tc.floor, tc.verdict f.write(t) err := f.run() if err == nil { t.Fatal("a verdict that does not name the floor passed") } if !errors.Is(err, errFloorGateMismatch) { t.Fatalf("err = %v, want the mismatch", err) } }) } } func TestFloorGateMissingFilesFail(t *testing.T) { t.Run("no verdict", func(t *testing.T) { f := newFloorGateFixture(t) f.write(t) os.Remove(f.verdict) if err := f.run(); err == nil { t.Fatal("no verdict passed") } }) t.Run("no floor", func(t *testing.T) { f := newFloorGateFixture(t) f.write(t) os.Remove(f.floor) if err := f.run(); err == nil { t.Fatal("a floor removed after the shell's test passed") } }) } func TestFloorGateRefusesASymlinkedFloor(t *testing.T) { f := newFloorGateFixture(t) f.write(t) // The agent's pick: a file that happens to hold the verdict's bytes. chosen := filepath.Join(f.data, "chosen") if err := os.WriteFile(chosen, []byte(f.verdictContent), 0o644); err != nil { t.Fatal(err) } os.Remove(f.floor) if err := os.Symlink(chosen, f.floor); err != nil { t.Fatal(err) } if err := f.run(); err == nil { t.Fatal("a symlink at the floor path was followed") } } func TestFloorGateRefusesASymlinkedMovesDir(t *testing.T) { f := newFloorGateFixture(t) f.write(t) elsewhere := filepath.Join(f.data, "elsewhere") if err := os.Rename(filepath.Dir(f.floor), elsewhere); err != nil { t.Fatal(err) } if err := os.Symlink(elsewhere, filepath.Dir(f.floor)); err != nil { t.Fatal(err) } if err := f.run(); err == nil { t.Fatal("a symlink in place of .moves was followed") } } func TestFloorGateRefusesAFIFOWithoutBlocking(t *testing.T) { f := newFloorGateFixture(t) f.write(t) os.Remove(f.floor) if err := syscall.Mkfifo(f.floor, 0o644); err != nil { t.Fatal(err) } if err := f.runWithin(t, 5*time.Second); err == nil { t.Fatal("a FIFO at the floor path passed") } } func TestFloorGateRefusesAFIFOVerdictWithoutBlocking(t *testing.T) { f := newFloorGateFixture(t) f.write(t) os.Remove(f.verdict) if err := syscall.Mkfifo(f.verdict, 0o644); err != nil { t.Fatal(err) } if err := f.runWithin(t, 5*time.Second); err == nil { t.Fatal("a FIFO at the verdict path passed") } } func TestFloorGateRefusesASymlinkedVerdict(t *testing.T) { f := newFloorGateFixture(t) f.write(t) other := filepath.Join(f.opt, "other") if err := os.WriteFile(other, []byte(f.floorContent), 0o644); err != nil { t.Fatal(err) } os.Remove(f.verdict) if err := os.Symlink(other, f.verdict); err != nil { t.Fatal(err) } if err := f.run(); err == nil { t.Fatal("a symlink at the verdict path was followed") } } func TestFloorGateRefusesAHardLinkedFloor(t *testing.T) { f := newFloorGateFixture(t) f.write(t) if err := os.Link(f.floor, filepath.Join(f.data, "second-name")); err != nil { t.Fatal(err) } if err := f.run(); err == nil { t.Fatal("a floor file with two links passed") } } func TestFloorGateRefusesAHardLinkedVerdict(t *testing.T) { f := newFloorGateFixture(t) f.write(t) if err := os.Link(f.verdict, filepath.Join(f.opt, "second-name")); err != nil { t.Fatal(err) } if err := f.run(); err == nil { t.Fatal("a verdict file with two links passed") } } func TestFloorGateRefusesOversizedFiles(t *testing.T) { // Equal bytes, which cmp -s would have passed: the size is refused // before any compare. f := newFloorGateFixture(t) big := strings.Repeat("v", floorGateMaxBytes) + "\n" f.floorContent, f.verdictContent = big, big f.write(t) if err := f.run(); err == nil { t.Fatalf("a %d-byte floor passed", len(big)) } t.Run("verdict alone", func(t *testing.T) { f := newFloorGateFixture(t) f.verdictContent = big f.write(t) if err := f.run(); err == nil { t.Fatalf("a %d-byte verdict passed", len(big)) } }) t.Run("at the limit", func(t *testing.T) { f := newFloorGateFixture(t) exact := strings.Repeat("v", floorGateMaxBytes) f.floorContent, f.verdictContent = exact, exact f.write(t) if err := f.run(); err != nil { t.Fatalf("a %d-byte floor and verdict were refused: %v", len(exact), err) } }) } // A file that passes the fstat's size check and then grows before the read // is refused by the bounded read. Both files grow by the same bytes, so // without that bound the two reads, cut at the same length, would match. func TestFloorGateRefusesAFileThatGrowsAfterTheStat(t *testing.T) { f := newFloorGateFixture(t) f.write(t) grown := map[string]bool{} orig := floorGateAfterStat floorGateAfterStat = func(path string) { grown[path] = true w, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0) if err != nil { t.Error(err) return } defer w.Close() if _, err := w.WriteString(strings.Repeat("v", 2*floorGateMaxBytes)); err != nil { t.Error(err) } } t.Cleanup(func() { floorGateAfterStat = orig }) err := f.run() if err == nil { t.Fatal("a floor that grew past the limit after the fstat passed") } if !errors.Is(err, errFloorGateUnsafe) { t.Fatalf("err = %v, want the bounded read's refusal", err) } if !grown[f.floor] { t.Fatal("the hook never ran between the floor's fstat and its read") } } func TestFloorGateRefusesAnUnexpectedOwner(t *testing.T) { // Without root a test cannot chown, so the expected owners move instead. other := uint32(os.Getuid()) + 1 t.Run("floor", func(t *testing.T) { f := newFloorGateFixture(t) f.write(t) f.floorOwners = []uint32{other} if err := f.run(); err == nil { t.Fatal("a floor owned by an unexpected account passed") } }) t.Run("verdict", func(t *testing.T) { f := newFloorGateFixture(t) f.write(t) f.verdictOwners = []uint32{other} if err := f.run(); err == nil { t.Fatal("a verdict owned by an unexpected account passed") } }) t.Run("no owner expected", func(t *testing.T) { f := newFloorGateFixture(t) f.write(t) f.floorOwners = nil if err := f.run(); err == nil { t.Fatal("a floor passed with no expected owner") } }) } // checkFloorGateStat is the fstat judgement on its own, so the owner rule // is pinned with real root and service uids a test process cannot create. func TestCheckFloorGateStat(t *testing.T) { const svc = 998 ok := floorGateStat{mode: syscall.S_IFREG | 0o644, uid: svc, nlink: 1, size: 8} owners := []uint32{0, svc} if err := checkFloorGateStat(ok, owners); err != nil { t.Fatalf("a good file was refused: %v", err) } asRoot := ok asRoot.uid = 0 if err := checkFloorGateStat(asRoot, owners); err != nil { t.Fatalf("a root-owned file (root's apply writes the floor) was refused: %v", err) } for name, mut := range map[string]func(*floorGateStat){ "another account": func(s *floorGateStat) { s.uid = 1000 }, "a FIFO": func(s *floorGateStat) { s.mode = syscall.S_IFIFO | 0o644 }, "a symlink": func(s *floorGateStat) { s.mode = syscall.S_IFLNK | 0o777 }, "a directory": func(s *floorGateStat) { s.mode = syscall.S_IFDIR | 0o755 }, "a device": func(s *floorGateStat) { s.mode = syscall.S_IFCHR | 0o644 }, "a socket": func(s *floorGateStat) { s.mode = syscall.S_IFSOCK | 0o644 }, "two links": func(s *floorGateStat) { s.nlink = 2 }, "no links": func(s *floorGateStat) { s.nlink = 0 }, "too big": func(s *floorGateStat) { s.size = floorGateMaxBytes + 1 }, "negative size": func(s *floorGateStat) { s.size = -1 }, } { s := ok mut(&s) if err := checkFloorGateStat(s, owners); err == nil { t.Errorf("%s passed", name) } } if err := checkFloorGateStat(ok, []uint32{0}); err == nil { t.Error("a service-owned file passed where only root is expected (the verdict)") } } // The command's exit code is what the shell reads: 0 only for a match, and // every refusal, failure or misuse the same non-zero as a mismatch. func TestFloorGateMainExitCodes(t *testing.T) { f := newFloorGateFixture(t) f.write(t) run := func(args ...string) int { var b strings.Builder return floorGateMain(args, f.floorOwners, f.verdictOwners, &b) } if code := run(f.floor, f.verdict); code != 0 { t.Fatalf("a match exited %d", code) } mismatch := newFloorGateFixture(t) mismatch.verdictContent = "v0.29.0\n" mismatch.write(t) codeMismatch := run(mismatch.floor, mismatch.verdict) if codeMismatch == 0 { t.Fatal("a mismatch exited 0") } for name, args := range map[string][]string{ "no arguments": nil, "one argument": {f.floor}, "three arguments": {f.floor, f.verdict, f.verdict}, "a relative floor": {"data/.moves/floor.min-release", f.verdict}, "a relative verdict": {f.floor, "carlos.prev.allowed"}, "a missing verdict": {f.floor, filepath.Join(f.opt, "absent")}, } { if code := run(args...); code != codeMismatch { t.Errorf("%s exited %d, want the mismatch's %d", name, code, codeMismatch) } } } // MV-N317 is said whole by floorGateUnsafe, and says exactly what the old // fmt.Errorf("%s %w", path, errFloorGateUnsafe) said, with errors.Is intact. func TestFloorGateUnsafeSaysTheSameSentence(t *testing.T) { const path = "/opt/carlos/moves-floor" err := floorGateUnsafe(path) if got, want := err.Error(), fmt.Errorf("%s %w", path, errFloorGateUnsafe).Error(); got != want { t.Errorf("err = %q, want %q", got, want) } if !errors.Is(err, errFloorGateUnsafe) { t.Error("errors.Is(floorGateUnsafe(path), errFloorGateUnsafe) = false") } }