carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux
2
3package host
4
5import (
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
27type floorGateFixture struct {
28 data, opt string
29 floor, verdict string
30 floorOwners []uint32
31 verdictOwners []uint32
32 floorContent string
33 verdictContent string
34}
35
36func 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
57func (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
67func (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.
73func (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
86func 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
94func 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
116func 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
135func 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
152func 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
167func 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
179func 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
191func 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
207func 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
218func 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
229func 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.
261func 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
291func 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.
322func 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.
359func 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.
392func 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