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 park
4
5import (
6 "context"
7 "encoding/json"
8 "errors"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "syscall"
13 "testing"
14 "time"
15
16 "golang.org/x/sys/unix"
17
18 "github.com/carlosframework/platform/internal/hostclaim"
19 "github.com/carlosframework/platform/internal/hostguard"
20 "github.com/carlosframework/platform/internal/release"
21)
22
23type localFixture struct {
24 l Local
25 host string
26 captured hostguard.Captured
27}
28
29func localFixtureFor(t *testing.T, paths map[string]uint64) localFixture {
30 t.Helper()
31 return localFixtureForOp(t, paths, hostclaim.Op{Kind: hostclaim.KindParking, ID: "delete", Exec: "box"})
32}
33
34// localFixtureForOp is localFixtureFor under the claim op given; the
35// park-local chore is that op's.
36func localFixtureForOp(t *testing.T, paths map[string]uint64, op hostclaim.Op) localFixture {
37 t.Helper()
38 host := "shop.bab.oncarlos.com"
39 data, locks := t.TempDir(), t.TempDir()
40 for _, name := range []string{"hostguard", ".parked"} {
41 if err := os.Mkdir(filepath.Join(data, name), 0770); err != nil {
42 t.Fatal(err)
43 }
44 }
45 s := release.NewDirStore(t.TempDir())
46 at := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)
47 inc, err := hostclaim.Incarnation("bab", "jam", host, at)
48 if err != nil {
49 t.Fatal(err)
50 }
51 c := &hostclaim.Claim{V: 2, Host: host, Gen: 5,
52 Holder: &hostclaim.Holder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", CreatedAt: at, Incarnation: inc, Location: "instances/" + host + "/" + inc, Stamp: 5},
53 Op: op,
54 Inventory: map[string]hostclaim.InventoryEntry{"box": {Layout: "flat", Paths: paths}},
55 Obligations: []hostclaim.Obligation{{ID: "local", Op: op.ID, Kind: "park-local", Box: "box", Owner: "box:box", Retirement: "retirement"}}}
56 if err := c.Validate(); err != nil {
57 t.Fatal(err)
58 }
59 b, err := json.Marshal(c)
60 if err != nil {
61 t.Fatal(err)
62 }
63 key, err := hostclaim.Key(host)
64 if err != nil {
65 t.Fatal(err)
66 }
67 if _, err := s.Put(t.Context(), key, b, ""); err != nil {
68 t.Fatal(err)
69 }
70 g := hostguard.Guard{DataDir: data, LockDir: locks, Claims: hostguard.StoreReader{Store: s}}
71 if err := g.Activate(t.Context(), host, "box", nil); err != nil {
72 t.Fatal(err)
73 }
74 capture, err := hostguard.Capture(data, host)
75 if err != nil {
76 t.Fatal(err)
77 }
78 return localFixture{l: Local{Authorize: func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return nil }, Guard: g, Claims: s, DataDir: data, Box: "box"}, host: host, captured: capture}
79}
80
81func (f localFixture) write(t *testing.T, name, body string) {
82 t.Helper()
83 p := filepath.Join(f.l.DataDir, name)
84 if err := os.MkdirAll(filepath.Dir(p), 0770); err != nil {
85 t.Fatal(err)
86 }
87 if err := os.WriteFile(p, []byte(body), 0660); err != nil {
88 t.Fatal(err)
89 }
90}
91
92func (f localFixture) parked(name string) string {
93 return filepath.Join(f.l.DataDir, ".parked", f.captured.Incarnation, f.host, "retirement", "artifacts", name)
94}
95
96func TestLocalArtifactStampAndInstallationRetirement(t *testing.T) {
97 for _, mode := range []string{"artifacts", "installation"} {
98 t.Run(mode, func(t *testing.T) {
99 f := localFixtureFor(t, map[string]uint64{"old.db": 3, "new.db": 5, "nested/state": 3})
100 for _, p := range []string{"old.db", "new.db", "nested/state"} {
101 f.write(t, p, p)
102 }
103 var err error
104 if mode == "artifacts" {
105 err = f.l.RetireArtifacts(t.Context(), f.host, f.captured, "local", "delete", 3)
106 } else {
107 err = f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete")
108 }
109 if err != nil {
110 t.Fatal(err)
111 }
112 for _, p := range []string{"old.db", "nested/state"} {
113 if b, err := os.ReadFile(f.parked(p)); err != nil || string(b) != p {
114 t.Fatalf("parked %s: %q %v", p, b, err)
115 }
116 }
117 got, err := hostguard.Capture(f.l.DataDir, f.host)
118 if err != nil {
119 t.Fatal(err)
120 }
121 if mode == "artifacts" {
122 if got != f.captured {
123 t.Fatal("artifact retirement changed stamp")
124 }
125 if _, err := os.Stat(filepath.Join(f.l.DataDir, "new.db")); err != nil {
126 t.Fatal(err)
127 }
128 } else if got.Stamp != 0 {
129 t.Fatal("installation still active")
130 }
131 obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
132 if err != nil {
133 t.Fatal(err)
134 }
135 if len(obs.Claim.Obligations) != 0 {
136 t.Fatal("not acknowledged")
137 }
138 want := 0
139 if mode == "artifacts" {
140 want = 1
141 }
142 if len(obs.Claim.Inventory["box"].Paths) != want {
143 t.Fatal("inventory mismatch")
144 }
145 if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil {
146 t.Fatal("completion retry", err)
147 }
148 })
149 }
150}
151
152func TestLocalResumeAfterEveryRenameHoldsGuard(t *testing.T) {
153 for _, stop := range []string{"a", "b"} {
154 t.Run(stop, func(t *testing.T) {
155 f := localFixtureFor(t, map[string]uint64{"a": 5, "b": 5})
156 f.write(t, "a", "a")
157 f.write(t, "b", "b")
158 crash := errors.New("crash after rename")
159 f.l.afterRename = func(p string) error {
160 if p != stop {
161 return nil
162 }
163 ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond)
164 defer cancel()
165 if err := f.l.Guard.With(ctx, f.host, f.captured, func(*hostclaim.Claim) error { return nil }, func() error { t.Fatal("lock released inside retirement"); return nil }); !errors.Is(err, context.DeadlineExceeded) {
166 t.Fatalf("competing writer %v", err)
167 }
168 return crash
169 }
170 if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, crash) {
171 t.Fatal(err)
172 }
173 current, err := hostguard.Capture(f.l.DataDir, f.host)
174 if err != nil || current.Stamp != 0 {
175 t.Fatalf("not retired before rename: %+v %v", current, err)
176 }
177 obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
178 if err != nil || len(obs.Claim.Obligations) != 1 {
179 t.Fatal("early completion", err)
180 }
181 f.l.afterRename = nil
182 if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil {
183 t.Fatal(err)
184 }
185 for _, p := range []string{"a", "b"} {
186 if b, err := os.ReadFile(f.parked(p)); err != nil || string(b) != p {
187 t.Fatalf("lost %s", p)
188 }
189 }
190 })
191 }
192}
193
194func TestLocalRestrictiveUmask(t *testing.T) {
195 // Umask is process-wide: this test and its fixture setup must not run in parallel.
196 f := localFixtureFor(t, map[string]uint64{"nested/a": 5, "nested/b": 5})
197 f.write(t, "nested/a", "a")
198 f.write(t, "nested/b", "b")
199 group := os.Getegid()
200 if os.Geteuid() == 0 {
201 group = 65534
202 }
203 for _, path := range []string{f.l.DataDir, filepath.Join(f.l.DataDir, ".parked"), filepath.Join(f.l.DataDir, "nested")} {
204 dir, err := os.Open(path)
205 if err != nil {
206 t.Fatal(err)
207 }
208 if os.Geteuid() == 0 {
209 if err := dir.Chown(0, group); err != nil {
210 dir.Close()
211 t.Fatal(err)
212 }
213 }
214 if err := dir.Chmod(os.ModeSetgid | 0770); err != nil {
215 dir.Close()
216 t.Fatal(err)
217 }
218 dir.Close()
219 }
220 oldUmask := syscall.Umask(0o077)
221 defer syscall.Umask(oldUmask)
222 crash := errors.New("crash under restrictive umask")
223 f.l.afterRename = func(string) error { return crash }
224 if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, crash) {
225 t.Fatal(err)
226 }
227 base := filepath.Join(f.l.DataDir, ".parked", f.captured.Incarnation)
228 event := filepath.Join(base, f.host, "retirement")
229 for _, path := range []string{base, filepath.Join(base, f.host), event, filepath.Join(event, "artifacts"), filepath.Join(event, "artifacts", "nested")} {
230 var st unix.Stat_t
231 if err := unix.Lstat(path, &st); err != nil {
232 t.Fatal(err)
233 }
234 if st.Mode&unix.S_IFMT != unix.S_IFDIR || st.Mode&07777 != 02770 || st.Gid != uint32(group) {
235 t.Fatalf("archive directory %s: mode=%#o gid=%d", path, st.Mode, st.Gid)
236 }
237 }
238 if os.Geteuid() == 0 {
239 body, err := os.ReadFile(filepath.Join(event, ".journal.json"))
240 if err != nil {
241 t.Fatal(err)
242 }
243 dir, err := os.Open(f.l.DataDir)
244 if err != nil {
245 t.Fatal(err)
246 }
247 defer dir.Close()
248 exe, err := os.Executable()
249 if err != nil {
250 t.Fatal(err)
251 }
252 run := func(refuse bool) {
253 t.Helper()
254 cmd := exec.Command(exe, "-test.run=^TestLocalRestrictiveUmaskWorker$")
255 cmd.ExtraFiles = []*os.File{dir}
256 cmd.Env = append(os.Environ(), "PARK_UMASK_WORKER=1", "PARK_UMASK_JOURNAL="+string(body))
257 if refuse {
258 cmd.Env = append(cmd.Env, "PARK_UMASK_REFUSE=1")
259 }
260 if out, err := cmd.CombinedOutput(); err != nil {
261 t.Fatalf("group worker: %v\n%s", err, out)
262 }
263 }
264 // A worker must refuse, not chmod, directories owned by another UID.
265 for _, mode := range []os.FileMode{os.ModeSetgid | 0750, 0770} {
266 path := filepath.Join(event, "artifacts")
267 if err := os.Chmod(path, mode); err != nil {
268 t.Fatal(err)
269 }
270 run(true)
271 st, err := os.Stat(path)
272 if err != nil || st.Mode()&(os.ModeSetgid|0777) != mode {
273 t.Fatal("foreign directory permissions changed", err)
274 }
275 if err := os.Chmod(path, os.ModeSetgid|0770); err != nil {
276 t.Fatal(err)
277 }
278 }
279 run(false)
280 } else {
281 t.Log("mode and group assertions passed; cross-user subprocess requires root")
282 }
283 f.l.afterRename = nil
284 if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil {
285 t.Fatal(err)
286 }
287 for _, name := range []string{"a", "b"} {
288 if body, err := os.ReadFile(f.parked("nested/" + name)); err != nil || string(body) != name {
289 t.Fatalf("resumed artifact %s: %q %v", name, body, err)
290 }
291 }
292 obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
293 if err != nil || len(obs.Claim.Obligations) != 0 {
294 t.Fatal("resumed retirement not acknowledged", err)
295 }
296}
297
298func TestLocalRestrictiveUmaskWorker(t *testing.T) {
299 if os.Getenv("PARK_UMASK_WORKER") != "1" {
300 t.Skip("subprocess helper")
301 }
302 var want localJournal
303 if err := json.Unmarshal([]byte(os.Getenv("PARK_UMASK_JOURNAL")), &want); err != nil {
304 t.Fatal(err)
305 }
306 // Enter the fixture through an inherited descriptor; its temp ancestors may be private.
307 if err := unix.Fchdir(3); err != nil {
308 t.Fatal(err)
309 }
310 for _, change := range []func() error{
311 func() error { return syscall.Setgroups([]int{65534}) },
312 func() error { return syscall.Setgid(65534) },
313 func() error { return syscall.Setuid(65534) },
314 } {
315 if err := change(); err != nil {
316 t.Fatal(err)
317 }
318 }
319 oldUmask := syscall.Umask(0o077)
320 defer syscall.Umask(oldUmask)
321 err := (Local{DataDir: "."}).parkLocal(t.Context(), &want)
322 if os.Getenv("PARK_UMASK_REFUSE") == "1" {
323 if !errors.Is(err, ErrLocalInventory) {
324 t.Fatalf("foreign directory without required mode accepted: %v", err)
325 }
326 return
327 }
328 if err != nil {
329 t.Fatal(err)
330 }
331 if _, err := os.Stat("nested/b"); !errors.Is(err, os.ErrNotExist) {
332 t.Fatal("second process did not retire remaining artifact", err)
333 }
334}
335
336func TestLocalRefusesSymlinksAndOverlaps(t *testing.T) {
337 for _, place := range []string{"source", "ancestor", "park-root", "destination", "overlap", "missing-inventory"} {
338 t.Run(place, func(t *testing.T) {
339 paths := map[string]uint64{"sub/a": 5}
340 if place == "overlap" {
341 paths["sub"] = 3
342 }
343 f := localFixtureFor(t, paths)
344 f.write(t, "sub/a", "source")
345 outside := t.TempDir()
346 sentinel := filepath.Join(outside, "a")
347 if err := os.WriteFile(sentinel, []byte("sentinel"), 0600); err != nil {
348 t.Fatal(err)
349 }
350 switch place {
351 case "source":
352 if err := os.Remove(filepath.Join(f.l.DataDir, "sub/a")); err != nil {
353 t.Fatal(err)
354 }
355 if err := os.Symlink(sentinel, filepath.Join(f.l.DataDir, "sub/a")); err != nil {
356 t.Fatal(err)
357 }
358 case "ancestor":
359 if err := os.Rename(filepath.Join(f.l.DataDir, "sub"), filepath.Join(f.l.DataDir, "safe")); err != nil {
360 t.Fatal(err)
361 }
362 if err := os.Symlink(outside, filepath.Join(f.l.DataDir, "sub")); err != nil {
363 t.Fatal(err)
364 }
365 case "park-root":
366 if err := os.Remove(filepath.Join(f.l.DataDir, ".parked")); err != nil {
367 t.Fatal(err)
368 }
369 if err := os.Symlink(outside, filepath.Join(f.l.DataDir, ".parked")); err != nil {
370 t.Fatal(err)
371 }
372 case "destination":
373 if err := os.MkdirAll(filepath.Dir(f.parked("sub/a")), 0770); err != nil {
374 t.Fatal(err)
375 }
376 if err := os.Symlink(sentinel, f.parked("sub/a")); err != nil {
377 t.Fatal(err)
378 }
379 case "missing-inventory":
380 obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
381 if err != nil {
382 t.Fatal(err)
383 }
384 if _, err := hostclaim.Update(t.Context(), f.l.Claims, obs, func(c *hostclaim.Claim) error { delete(c.Inventory, "box"); return nil }); err != nil {
385 t.Fatal(err)
386 }
387 }
388 if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err == nil {
389 t.Fatal("unsafe retirement accepted")
390 }
391 if b, err := os.ReadFile(sentinel); err != nil || string(b) != "sentinel" {
392 t.Fatal("symlink target changed")
393 }
394 obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
395 if err != nil || len(obs.Claim.Obligations) != 1 {
396 t.Fatal("acknowledged failure", err)
397 }
398 })
399 }
400}
401
402func TestLocalEmptyAndAbsentArtifactsAcknowledge(t *testing.T) {
403 for _, paths := range []map[string]uint64{{}, {"missing/file": 5}} {
404 f := localFixtureFor(t, paths)
405 if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil {
406 t.Fatal(err)
407 }
408 obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
409 if err != nil || len(obs.Claim.Obligations) != 0 {
410 t.Fatal("not complete", err)
411 }
412 }
413}
414
415func TestLocalOldRetirementCannotFenceSuccessor(t *testing.T) {
416 f := localFixtureFor(t, map[string]uint64{"a": 5})
417 f.write(t, "a", "old")
418 f.l.afterRename = func(string) error { return errors.New("crash") }
419 if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err == nil {
420 t.Fatal("expected crash")
421 }
422 obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
423 if err != nil {
424 t.Fatal(err)
425 }
426 if _, err := hostclaim.Update(t.Context(), f.l.Claims, obs, func(c *hostclaim.Claim) error {
427 c.Holder.Stamp = c.Gen + 1
428 c.Inventory["box"].Paths["a"] = c.Gen + 1
429 return nil
430 }); err != nil {
431 t.Fatal(err)
432 }
433 if err := f.l.Guard.Activate(t.Context(), f.host, "box", nil); err != nil {
434 t.Fatal(err)
435 }
436 f.write(t, "a", "successor")
437 f.l.afterRename = nil
438 if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, hostguard.ErrStampMismatch) {
439 t.Fatal(err)
440 }
441 if b, err := os.ReadFile(filepath.Join(f.l.DataDir, "a")); err != nil || string(b) != "successor" {
442 t.Fatal("successor damaged")
443 }
444}
445
446func TestLocalArtifactResumeRefusesReusedSource(t *testing.T) {
447 f := localFixtureFor(t, map[string]uint64{"a": 3})
448 f.write(t, "a", "predecessor")
449 f.l.afterRename = func(string) error { return errors.New("crash") }
450 if err := f.l.RetireArtifacts(t.Context(), f.host, f.captured, "local", "delete", 3); err == nil {
451 t.Fatal("expected crash")
452 }
453 f.write(t, "a", "replacement")
454 f.l.afterRename = nil
455 if err := f.l.RetireArtifacts(t.Context(), f.host, f.captured, "local", "delete", 3); !errors.Is(err, ErrLocalJournal) {
456 t.Fatal(err)
457 }
458 if b, err := os.ReadFile(filepath.Join(f.l.DataDir, "a")); err != nil || string(b) != "replacement" {
459 t.Fatal("replacement changed")
460 }
461 if b, err := os.ReadFile(f.parked("a")); err != nil || string(b) != "predecessor" {
462 t.Fatal("archive changed")
463 }
464 obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
465 if err != nil || len(obs.Claim.Obligations) != 1 || len(obs.Claim.Inventory["box"].Paths) != 1 {
466 t.Fatal("source reuse acknowledged", err)
467 }
468}
469
470func TestLocalMissingPathRecreatedAfterCrash(t *testing.T) {
471 for _, kind := range []string{"file", "directory", "leaf-symlink", "ancestor-symlink", "still-missing"} {
472 t.Run(kind, func(t *testing.T) {
473 missing := "a-missing"
474 if kind == "ancestor-symlink" {
475 missing += "/child"
476 }
477 f := localFixtureFor(t, map[string]uint64{missing: 5, "z-present": 5})
478 f.write(t, "z-present", "old")
479 crash := errors.New("crash after missing check and artifact rename")
480 f.l.afterRename = func(string) error { return crash }
481 if err := f.l.RetireArtifacts(t.Context(), f.host, f.captured, "local", "delete", 5); !errors.Is(err, crash) {
482 t.Fatal(err)
483 }
484 f.l.afterRename = nil
485 outside := t.TempDir()
486 target := filepath.Join(outside, "child")
487 if err := os.WriteFile(target, []byte("outside"), 0600); err != nil {
488 t.Fatal(err)
489 }
490 path := filepath.Join(f.l.DataDir, "a-missing")
491 switch kind {
492 case "file":
493 f.write(t, missing, "new")
494 case "directory":
495 if err := os.Mkdir(path, 0770); err != nil {
496 t.Fatal(err)
497 }
498 case "leaf-symlink":
499 if err := os.Symlink(target, path); err != nil {
500 t.Fatal(err)
501 }
502 case "ancestor-symlink":
503 if err := os.Symlink(outside, path); err != nil {
504 t.Fatal(err)
505 }
506 }
507 err := f.l.RetireArtifacts(t.Context(), f.host, f.captured, "local", "delete", 5)
508 if kind == "still-missing" {
509 if err != nil {
510 t.Fatal(err)
511 }
512 } else if err == nil {
513 t.Fatal("acknowledged a recreated missing path")
514 }
515 obs, readErr := hostclaim.Read(t.Context(), f.l.Claims, f.host)
516 if readErr != nil {
517 t.Fatal(readErr)
518 }
519 if kind == "still-missing" {
520 if len(obs.Claim.Obligations) != 0 || len(obs.Claim.Inventory["box"].Paths) != 0 {
521 t.Fatal("missing path did not acknowledge")
522 }
523 } else {
524 if len(obs.Claim.Obligations) != 1 || len(obs.Claim.Inventory["box"].Paths) != 2 {
525 t.Fatal("refusal changed inventory or chore")
526 }
527 if _, err := os.Lstat(path); err != nil {
528 t.Fatal("recreated artifact lost", err)
529 }
530 }
531 if got, err := hostguard.Capture(f.l.DataDir, f.host); err != nil || got != f.captured {
532 t.Fatal("artifact retirement changed installation", err)
533 }
534 if b, err := os.ReadFile(target); err != nil || string(b) != "outside" {
535 t.Fatal("followed planted symlink", err)
536 }
537 if b, err := os.ReadFile(f.parked("z-present")); err != nil || string(b) != "old" {
538 t.Fatal("lost parked artifact", err)
539 }
540 })
541 }
542}
543
544func TestLocalResolvedDirectoryAndDestinationConflict(t *testing.T) {
545 t.Run("nested directory", func(t *testing.T) {
546 f := localFixtureFor(t, map[string]uint64{"nested/instance": 5})
547 f.write(t, "nested/instance/data/db", "database")
548 if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil {
549 t.Fatal(err)
550 }
551 if b, err := os.ReadFile(f.parked("nested/instance/data/db")); err != nil || string(b) != "database" {
552 t.Fatal("nested tree lost", err)
553 }
554 })
555 t.Run("destination conflict", func(t *testing.T) {
556 f := localFixtureFor(t, map[string]uint64{"a": 5})
557 f.write(t, "a", "source")
558 if err := os.MkdirAll(filepath.Dir(f.parked("a")), 0770); err != nil {
559 t.Fatal(err)
560 }
561 if err := os.WriteFile(f.parked("a"), []byte("foreign"), 0660); err != nil {
562 t.Fatal(err)
563 }
564 if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, ErrLocalJournal) {
565 t.Fatal(err)
566 }
567 if b, err := os.ReadFile(f.parked("a")); err != nil || string(b) != "foreign" {
568 t.Fatal("destination overwritten")
569 }
570 if b, err := os.ReadFile(filepath.Join(f.l.DataDir, "a")); err != nil || string(b) != "source" {
571 t.Fatal("source lost")
572 }
573 })
574}
575
576func TestLocalAuthorizationAndInventoryEnrollment(t *testing.T) {
577 t.Run("authorization", func(t *testing.T) {
578 f := localFixtureFor(t, map[string]uint64{"a": 5})
579 f.write(t, "a", "data")
580 denied := errors.New("stop evidence unavailable")
581 f.l.Authorize = func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return denied }
582 if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, denied) {
583 t.Fatal(err)
584 }
585 got, err := hostguard.Capture(f.l.DataDir, f.host)
586 if err != nil || got != f.captured {
587 t.Fatal("unauthorized stamp mutation", err)
588 }
589 if _, err := os.Stat(filepath.Join(f.l.DataDir, "a")); err != nil {
590 t.Fatal(err)
591 }
592 })
593 t.Run("enrollment", func(t *testing.T) {
594 f := localFixtureFor(t, map[string]uint64{"old": 3})
595 allow := func(*hostclaim.Claim) error { return nil }
596 if err := f.l.RecordInventory(t.Context(), f.host, f.captured, "flat", []string{"new"}, allow); err != nil {
597 t.Fatal(err)
598 }
599 obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
600 if err != nil {
601 t.Fatal(err)
602 }
603 if obs.Claim.Inventory["box"].Paths["old"] != 3 || obs.Claim.Inventory["box"].Paths["new"] != 5 {
604 t.Fatal("inventory was replaced")
605 }
606 if _, err := os.Stat(filepath.Join(f.l.DataDir, "new")); !errors.Is(err, os.ErrNotExist) {
607 t.Fatal("enrollment materialized state")
608 }
609 for _, p := range []string{"old", "old/child", "new/child", "../escape", "hostguard/record", ".parked/file"} {
610 if err := f.l.RecordInventory(t.Context(), f.host, f.captured, "flat", []string{p}, allow); !errors.Is(err, ErrLocalInventory) {
611 t.Fatalf("%s: %v", p, err)
612 }
613 }
614 if err := f.l.RecordInventory(t.Context(), f.host, f.captured, "nested", nil, allow); !errors.Is(err, ErrLocalInventory) {
615 t.Fatal("changed populated layout", err)
616 }
617 })
618}
619
620func TestLocalCompletionRechecksAuthority(t *testing.T) {
621 f := localFixtureFor(t, map[string]uint64{"a": 5})
622 f.write(t, "a", "data")
623 denied := errors.New("fence lost before acknowledgement")
624 calls := 0
625 f.l.Authorize = func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error {
626 calls++
627 if calls > 1 {
628 return denied
629 }
630 return nil
631 }
632 if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, denied) {
633 t.Fatal(err)
634 }
635 obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
636 if err != nil || len(obs.Claim.Obligations) != 1 {
637 t.Fatal("completed without authority", err)
638 }
639 f.l.Authorize = func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return nil }
640 if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil {
641 t.Fatal(err)
642 }
643}
644
645func TestLocalUntouchedBoxAcknowledgement(t *testing.T) {
646 for _, kind := range []string{"absent-record", "active-record", "corrupt-record", "nonempty-inventory"} {
647 t.Run(kind, func(t *testing.T) {
648 paths := map[string]uint64{}
649 if kind == "nonempty-inventory" {
650 paths["a"] = 5
651 }
652 f := localFixtureFor(t, paths)
653 record := filepath.Join(f.l.DataDir, "hostguard", f.host+".json")
654 if kind == "corrupt-record" {
655 if err := os.WriteFile(record, []byte("{"), 0660); err != nil {
656 t.Fatal(err)
657 }
658 } else if kind != "active-record" {
659 if err := os.Remove(record); err != nil {
660 t.Fatal(err)
661 }
662 }
663 err := f.l.AcknowledgeEmpty(t.Context(), f.host, f.captured.Incarnation, "local", "delete")
664 if kind == "absent-record" {
665 if err != nil {
666 t.Fatal(err)
667 }
668 obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
669 if err != nil || len(obs.Claim.Obligations) != 0 {
670 t.Fatal("empty box not acknowledged", err)
671 }
672 if _, err := os.Stat(record); !errors.Is(err, os.ErrNotExist) {
673 t.Fatal("empty retirement created stamp", err)
674 }
675 } else if err == nil {
676 t.Fatal("unsafe empty acknowledgement")
677 }
678 })
679 }
680}
681
682// TestLocalRetirementUnderAMoveOpIsFindableByOp (moves spec §6.4, D6, K20):
683// a park-local whose op is a move's id, under each move kind that queues
684// one, lands under the engine's own layout,
685// .parked/<incarnation>/<host>/<retirement>/artifacts/, and its journal names
686// the move's op. That is park.Local's own contract; how F3 selects a
687// move's local retirements by op across both receipts (this journal and
688// the moves-owned rename's) is internal/host's
689// TestMoveLocalRetirementsAreFindableByOp.
690func TestLocalRetirementUnderAMoveOpIsFindableByOp(t *testing.T) {
691 const move = "mv_abcdefghijklmnopqrstuvwxyz"
692 for _, op := range []hostclaim.Op{
693 {Kind: hostclaim.KindLanding, ID: move, Phase: hostclaim.PhaseCommitted, Exec: "box"},
694 {Kind: hostclaim.KindRestoring, ID: move, Phase: hostclaim.PhaseClaimed, Exec: "box"},
695 } {
696 for _, mode := range []string{"artifacts", "installation"} {
697 t.Run(op.Kind+"/"+mode, func(t *testing.T) {
698 f := localFixtureForOp(t, map[string]uint64{"old.db": 3, "nested/state": 3}, op)
699 for _, p := range []string{"old.db", "nested/state"} {
700 f.write(t, p, p)
701 }
702 var err error
703 if mode == "artifacts" {
704 err = f.l.RetireArtifacts(t.Context(), f.host, f.captured, "local", move, 3)
705 } else {
706 err = f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", move)
707 }
708 if err != nil {
709 t.Fatal(err)
710 }
711 for _, p := range []string{"old.db", "nested/state"} {
712 if b, err := os.ReadFile(f.parked(p)); err != nil || string(b) != p {
713 t.Fatalf("not under the engine's layout: %s: %q %v", p, b, err)
714 }
715 }
716 b, err := os.ReadFile(filepath.Join(f.l.DataDir, ".parked", f.captured.Incarnation, f.host, "retirement", ".journal.json"))
717 if err != nil {
718 t.Fatal(err)
719 }
720 var j localJournal
721 if err := json.Unmarshal(b, &j); err != nil {
722 t.Fatal(err)
723 }
724 if j.Op != move || j.Host != f.host || j.Incarnation != f.captured.Incarnation || j.Retirement != "retirement" || j.Obligation != "local" || j.Mode != mode {
725 t.Fatalf("the journal does not name the move: %+v", j)
726 }
727 })
728 }
729 }
730}
731