plan: claim Plan 3 rev 1, box-local guard and stop evidence
Libraries only: internal/hostguard (local record, context-aware flock, fresh-claim admission, activation, installation retirement), cgroup.OpenCleanup, internal/stopevidence (complete /proc fd scan, composite evidence), one tmpfiles line. Wiring is Plan 6.
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+2798
−0
docs/superpowers/plans/2026-10-05-host-lifecycle-claim-3-box-guard.md+2798 −0
CI passed — run details
diff --git a/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-3-box-guard.md b/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-3-box-guard.md| new file mode 100644 |
| index 000000000..90c016636 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-3-box-guard.md |
| @@ -0,0 +1,2798 @@ |
| +# Host Lifecycle Claim, Plan 3: box-local guard and stop evidence |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** provide the box-local libraries that fence host writes by installation and reconstruct stop evidence from the cgroup and open database descriptors. |
| + |
| +**Architecture:** |
| + |
| +- `internal/hostguard` owns the local record and an exclusive, context-aware flock. A writer captures its installation when selecting work, then rechecks the fresh claim and local record under the lock before writing. |
| +- `internal/cgroup.OpenCleanup` reopens an existing host cgroup without creating it or granting spawn authority. `internal/stopevidence` combines the activator's fence proof with fresh cgroup and `/proc` observations and explicit replicator closure. |
| +- This branch contains libraries, tests and one tmpfiles line only. No agent, adopt, config-sync, activator spawn or timer path calls the new code. Plan 6 owns those call sites, lifecycle use of `PrepareStrict`, and the process/privilege boundary at which evidence is collected. |
| +- Plan 4 composes installation retirement with artifact parking. Inventory paths are already tagged by installation stamp; this plan neither changes that inventory nor parks artifacts. |
| + |
| +**Tech Stack:** Go 1.26; standard library; existing `golang.org/x/sys/unix`; `internal/hostclaim`, `internal/release`, `internal/controlname`, `internal/cgroup`, `internal/activator`. No new dependencies. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`, rev 6.14. Read §2, C4, C6, §6.1, §6.3 and §6.4. Names below were checked against the `hostclaim` and `hoststorage` worktrees. The latter was removed during drafting; remaining activator/registry references were checked against this checkout. The supplied `plan3-codemap.md` was unavailable; the source files are the authority. |
| + |
| +**Depends on:** Plan 1, `feat/hostclaim-library`. Cut the implementation branch as `feat/hostclaim-boxguard` from that branch, not from Plan 2. The commands below are for the later implementation session; writing this plan does not create a branch or change source files. |
| + |
| +## Global Constraints |
| + |
| +- **C4:** “Admission reads the claim directly, never from a listing snapshot.” Every guarded operation reads under the flock. `With` requires the caller's operation/phase and held-config/held-release check; the captured stamp comes from work selection, never a replacement capture inside `With`. |
| +- **C6:** “A failed or malformed read of a claim, record or listing means wait, or refuse. It never means absent.” Only filesystem not-found becomes `ErrNoRecord`; only `Observed.Absent` means no claim. Decode unknown fields and trailing data as errors. |
| +- **Paths and schema:** `/run/carlos/hostguard/<host>.lock` by default, and `<DataDir>/hostguard/<host>.json`. Local record `v` is **1**; the claim library's schema remains **2**. Host names pass `controlname.ValidHostShape`. |
| +- **Installation identity:** `{incarnation, active_stamp, retired_stamps}`; `active_stamp == 0` means none. Retain the newest **32** retired stamps, oldest to newest. The active nonzero stamp never appears among retired stamps. `holder.stamp` is the installing CAS's resulting `gen`, not today's claim `Gen`, `holder.session` or `holder.generation` (the last is a string). |
| +- **Only activation and installation retirement change stamps.** An equal active stamp is an activation no-op. A retired or older stamp cannot reactivate. A different incarnation replaces the record only on authorized activation. `RetireInstallation` changes stamps only; Plan 4 parks all artifacts in the same higher-level operation. `retire-artifacts(S)` belongs wholly to Plan 4 and never changes stamps. |
| +- **Record durability:** same-directory temporary file, file fsync, rename, directory fsync. Captures use an unlocked read of the atomically replaced file. Failed rename removes the temporary file and preserves the previous record. An error after rename may mean the complete new record is visible; report that error and allow an idempotent retry. |
| +- **Lock:** exclusive `LOCK_EX|LOCK_NB`, polled every **10 ms** with cancellation. Open `O_RDWR|O_CREATE`, **0660**. Never unlink lock files: waiters must keep referring to the same inode. Tmpfiles creates the directory with **0770 carlos carlos**; do not use `carlos-sockets`. Root-created files must also be usable by `carlos`. |
| +- **Stop evidence (§6.1):** closed admission fence, drained spawn attempts, `populated 0`, no member processes, closed replicators, no DB/WAL/SHM descriptor holders. Missing cgroup, missing delegation, unreadable state or incomplete `/proc` visibility produces no evidence. An absent cgroup cannot establish that a legacy child did not escape containment. |
| +- **Platform boundary:** flock on Unix, refusing stub on `!unix` (including Windows); cleanup and `/proc` scanning on Linux, refusing stubs on `!linux`. Darwin and Windows must still build the whole repository. |
| +- **Gate (AGENTS.md):** `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .`, plus Darwin and Windows builds. Use the default Go cache. If it is read-only, use `GOCACHE=$TMPDIR/gocache` only with an on-disk `$TMPDIR`, never a path under `/tmp`. |
| + |
| +## Review Focus |
| + |
| +1. A callback panics or returns an error: the flock must still be released (Tasks 2–3). |
| +2. The claim changes after capture, including while waiting for the lock: admission must read the new claim (Tasks 2–3). |
| +3. UK→US→UK, or a stamp trimmed from the retired list: a stale writer must never become current again (Tasks 2–3). |
| +4. `/proc/<pid>/fd` returns EACCES for a live process: return `ErrIncomplete`, never an empty holder list (Task 5). |
| +5. Capture races an atomic rename, or rename fails: readers see a complete old or new record, and failed writes leave no partial file (Task 1). |
| + |
| +--- |
| + |
| +## File Structure |
| + |
| +| File | Responsibility | |
| +|---|---| |
| +| `internal/hostguard/record.go`, `record_test.go` | Strict local schema, capture, atomic writes and failure seam | |
| +| `internal/hostguard/owner_unix.go`, `owner_other.go` | Preserve service ownership on root-created records and locks | |
| +| `internal/hostguard/guard.go`, `guard_test.go` | Fresh claim adapter, admission, activation and retirement | |
| +| `internal/hostguard/flock_unix.go`, `flock_other.go` | Context-aware flock and shared-file ownership; refusing non-Unix stub | |
| +| `internal/hostguard/flock_unix_test.go`, `flock_other_test.go` | Cross-process exclusion, cancellation, fresh read and platform refusal | |
| +| `internal/cgroup/cleanup.go` | Cleanup errors | |
| +| `internal/cgroup/cleanup_linux.go`, `cleanup_other.go` | Reopen-only cgroup handle; no `Start` method | |
| +| `internal/cgroup/cleanup_linux_test.go`, `cleanup_other_test.go` | Cleanup, identity, parsing, deadline and platform tests | |
| +| `internal/stopevidence/fds.go`, `fds_linux.go`, `fds_other.go` | Descriptor holder scan and errors | |
| +| `internal/stopevidence/fds_linux_test.go`, `fds_other_test.go` | Fake proc tree, injected read failures and real descriptor witness | |
| +| `internal/stopevidence/evidence.go`, `evidence_linux_test.go` | Composite evidence and cleanup-only entry point | |
| +| `internal/host/bootstrap.go` | One tmpfiles line | |
| +| `internal/host/units_isolation_test.go` | Add the guard directory to the existing tmpfiles contract | |
| + |
| +--- |
| + |
| +### Task 1: The local record and capture |
| + |
| +**Files:** |
| +- Create: `internal/hostguard/record.go`, `internal/hostguard/record_test.go`, `internal/hostguard/owner_unix.go`, `internal/hostguard/owner_other.go` |
| + |
| +**Interfaces:** |
| +- Produces: `Captured`, `Capture(dataDir, host)`, strict internal `record`, record errors. |
| +- Consumes: `controlname.ValidHostShape`; produces the platform ownership helper needed by Task 2. |
| + |
| +- [ ] **Step 1: Create the implementation branch** |
| + |
| +```bash |
| +git switch -c feat/hostclaim-boxguard feat/hostclaim-library |
| +``` |
| + |
| +Expected: the new branch is based on the claim library. Do this in the implementation checkout, with no unrelated uncommitted work. |
| + |
| +- [ ] **Step 2: Write `record_test.go`** |
| + |
| +```go |
| +package hostguard |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "strings" |
| + "sync" |
| + "testing" |
| +) |
| + |
| +func TestCaptureStrict(t *testing.T) { |
| + dir := t.TempDir() |
| + host := "shop.example" |
| + if _, err := Capture(dir, host); !errors.Is(err, ErrNoRecord) { |
| + t.Fatal(err) |
| + } |
| + if _, err := Capture(dir, "../outside"); !errors.Is(err, ErrInvalidHost) { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(filepath.Join(dir, "hostguard"), 0770); err != nil { |
| + t.Fatal(err) |
| + } |
| + good := `{"v":1,"host":"shop.example","incarnation":"inc","active_stamp":3,"retired_stamps":[1,2]}` |
| + for _, tc := range []struct { |
| + name, body string |
| + valid bool |
| + }{ |
| + {"good", good, true}, |
| + {"unknown", strings.Replace(good, `"v":1`, `"v":1,"future":true`, 1), false}, |
| + {"trailing", good + `{}`, false}, |
| + {"trailing-garbage", good + `!`, false}, |
| + {"version", strings.Replace(good, `"v":1`, `"v":2`, 1), false}, |
| + {"host", strings.Replace(good, "shop.example", "other.example", 1), false}, |
| + {"incarnation", strings.Replace(good, `"inc"`, `""`, 1), false}, |
| + {"active-retired", strings.Replace(good, `[1,2]`, `[1,3]`, 1), false}, |
| + {"zero-retired", strings.Replace(good, `[1,2]`, `[0,2]`, 1), false}, |
| + {"duplicate", strings.Replace(good, `[1,2]`, `[1,1]`, 1), false}, |
| + {"unordered", strings.Replace(good, `[1,2]`, `[2,1]`, 1), false}, |
| + {"null", `null`, false}, |
| + {"truncated", good[:len(good)-1], false}, |
| + {"negative", strings.Replace(good, `"active_stamp":3`, `"active_stamp":-1`, 1), false}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + if err := os.WriteFile(filepath.Join(dir, "hostguard", host+".json"), []byte(tc.body), 0660); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := Capture(dir, host) |
| + if tc.valid { |
| + if err != nil || got != (Captured{Incarnation: "inc", Stamp: 3}) { |
| + t.Fatalf("%+v %v", got, err) |
| + } |
| + } else if !errors.Is(err, ErrMalformedRecord) { |
| + t.Fatal(err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRecordAtomicRename(t *testing.T) { |
| + if runtime.GOOS == "windows" { |
| + t.Skip("directory fsync and owner helper are Unix") |
| + } |
| + dir, host := t.TempDir(), "shop.example" |
| + r := record{V: 1, Host: host, Incarnation: "inc", ActiveStamp: 1, RetiredStamps: []uint64{}} |
| + if err := writeRecord(dir, r); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := errors.New("rename failed") |
| + r.ActiveStamp = 2 |
| + err := writeRecordUsing(dir, r, func(src, dst string) error { |
| + if filepath.Dir(src) != filepath.Dir(dst) { |
| + t.Fatal("cross-directory temporary file") |
| + } |
| + if b, e := os.ReadFile(src); e != nil || !strings.Contains(string(b), `"active_stamp":2`) { |
| + t.Fatalf("temporary file: %s %v", b, e) |
| + } |
| + return want |
| + }) |
| + if !errors.Is(err, want) { |
| + t.Fatal(err) |
| + } |
| + got, err := Capture(dir, host) |
| + if err != nil || got.Stamp != 1 { |
| + t.Fatalf("old record: %+v %v", got, err) |
| + } |
| + files, err := os.ReadDir(filepath.Join(dir, "hostguard")) |
| + if err != nil || len(files) != 1 || files[0].Name() != host+".json" { |
| + t.Fatalf("leftovers: %v %v", files, err) |
| + } |
| + |
| + var wg sync.WaitGroup |
| + errs := make(chan error, 1) |
| + done := make(chan struct{}) |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + for { |
| + select { |
| + case <-done: |
| + return |
| + default: |
| + } |
| + c, e := Capture(dir, host) |
| + if e != nil || c.Incarnation != "inc" || c.Stamp == 0 { |
| + select { |
| + case errs <- fmt.Errorf("torn capture: %+v %v", c, e): |
| + default: |
| + } |
| + return |
| + } |
| + } |
| + }() |
| + for i := uint64(2); i <= 40; i++ { |
| + r.ActiveStamp = i |
| + if err := writeRecord(dir, r); err != nil { |
| + close(done) |
| + wg.Wait() |
| + t.Fatal(err) |
| + } |
| + } |
| + close(done) |
| + wg.Wait() |
| + select { |
| + case err := <-errs: |
| + t.Fatal(err) |
| + default: |
| + } |
| +} |
| + |
| +func TestRecordRejectsOversizedHistoryAndReadError(t *testing.T) { |
| + r := record{V: 1, Host: "shop.example", Incarnation: "inc"} |
| + for i := uint64(1); i <= 33; i++ { |
| + r.RetiredStamps = append(r.RetiredStamps, i) |
| + } |
| + if !errors.Is(r.validate(r.Host), ErrMalformedRecord) { |
| + t.Fatal("accepted 33 stamps") |
| + } |
| + dir := t.TempDir() |
| + if err := os.MkdirAll(filepath.Join(dir, "hostguard", r.Host+".json"), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := Capture(dir, r.Host); err == nil || errors.Is(err, ErrNoRecord) { |
| + t.Fatalf("directory became absence: %v", err) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 3: Run the failing tests** |
| + |
| +Run: `go test ./internal/hostguard -run 'TestCaptureStrict|TestRecord' -count=1` |
| + |
| +Expected: build failure naming missing `Capture`, `record` and errors. |
| + |
| +- [ ] **Step 4: Write `record.go`** |
| + |
| +```go |
| +package hostguard |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| +) |
| + |
| +var ( |
| + ErrUnsupported = errors.New("hostguard: locking unsupported") |
| + ErrInvalidHost = errors.New("hostguard: invalid host") |
| + ErrNoRecord = errors.New("hostguard: no local record") |
| + ErrMalformedRecord = errors.New("hostguard: malformed local record") |
| +) |
| + |
| +type Captured struct { |
| + Incarnation string |
| + Stamp uint64 |
| +} |
| + |
| +type record struct { |
| + V int `json:"v"` |
| + Host string `json:"host"` |
| + Incarnation string `json:"incarnation"` |
| + ActiveStamp uint64 `json:"active_stamp"` |
| + RetiredStamps []uint64 `json:"retired_stamps"` |
| +} |
| + |
| +func (r record) validate(host string) error { |
| + if r.V != 1 || r.Host != host || !controlname.ValidHostShape(host) || r.Incarnation == "" || len(r.RetiredStamps) > 32 { |
| + return ErrMalformedRecord |
| + } |
| + var previous uint64 |
| + for _, s := range r.RetiredStamps { |
| + if s == 0 || s <= previous || (r.ActiveStamp != 0 && s >= r.ActiveStamp) { |
| + return ErrMalformedRecord |
| + } |
| + previous = s |
| + } |
| + return nil |
| +} |
| + |
| +func readRecord(dataDir, host string) (record, error) { |
| + if !controlname.ValidHostShape(host) { |
| + return record{}, ErrInvalidHost |
| + } |
| + b, err := os.ReadFile(filepath.Join(dataDir, "hostguard", host+".json")) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return record{}, ErrNoRecord |
| + } |
| + if err != nil { |
| + return record{}, err |
| + } |
| + d := json.NewDecoder(bytes.NewReader(b)) |
| + d.DisallowUnknownFields() |
| + var r record |
| + if err := d.Decode(&r); err != nil { |
| + return record{}, fmt.Errorf("%w: %v", ErrMalformedRecord, err) |
| + } |
| + if _, err := d.Token(); err != io.EOF { |
| + return record{}, ErrMalformedRecord |
| + } |
| + if err := r.validate(host); err != nil { |
| + return record{}, err |
| + } |
| + return r, nil |
| +} |
| + |
| +func Capture(dataDir, host string) (Captured, error) { |
| + r, err := readRecord(dataDir, host) |
| + if err != nil { |
| + return Captured{}, err |
| + } |
| + return Captured{Incarnation: r.Incarnation, Stamp: r.ActiveStamp}, nil |
| +} |
| + |
| +func writeRecord(dataDir string, r record) error { return writeRecordUsing(dataDir, r, os.Rename) } |
| + |
| +func writeRecordUsing(dataDir string, r record, rename func(string, string) error) error { |
| + if err := r.validate(r.Host); err != nil { |
| + return err |
| + } |
| + b, err := json.Marshal(r) |
| + if err != nil { |
| + return err |
| + } |
| + dir := filepath.Join(dataDir, "hostguard") |
| + if err := os.MkdirAll(dir, 0770); err != nil { |
| + return err |
| + } |
| + d, err := os.Open(dir) |
| + if err != nil { |
| + return err |
| + } |
| + defer d.Close() |
| + if err := setFileOwner(d, dataDir); err != nil { |
| + return err |
| + } |
| + if err := d.Chmod(0770); err != nil { |
| + return err |
| + } |
| + // Make first creation durable in the data directory as well. |
| + parent, err := os.Open(dataDir) |
| + if err != nil { |
| + return err |
| + } |
| + err = parent.Sync() |
| + err = errors.Join(err, parent.Close()) |
| + if err != nil { |
| + return err |
| + } |
| + f, err := os.CreateTemp(dir, "."+r.Host+"-*") |
| + if err != nil { |
| + return err |
| + } |
| + tmp := f.Name() |
| + defer os.Remove(tmp) |
| + defer f.Close() |
| + if err := setFileOwner(f, dir); err != nil { |
| + return err |
| + } |
| + if err := f.Chmod(0660); err != nil { |
| + return err |
| + } |
| + if _, err := f.Write(append(b, '\n')); err != nil { |
| + return err |
| + } |
| + if err := f.Sync(); err != nil { |
| + return err |
| + } |
| + if err := f.Close(); err != nil { |
| + return err |
| + } |
| + if err := rename(tmp, filepath.Join(dir, r.Host+".json")); err != nil { |
| + return err |
| + } |
| + return d.Sync() |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Write the ownership helpers** |
| + |
| +`owner_unix.go`: |
| + |
| +```go |
| +//go:build unix |
| + |
| +package hostguard |
| + |
| +import ( |
| + "os" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func setFileOwner(f *os.File, parent string) error { |
| + if os.Geteuid() != 0 { |
| + return nil |
| + } |
| + d, err := os.Open(parent) |
| + if err != nil { |
| + return err |
| + } |
| + defer d.Close() |
| + var st unix.Stat_t |
| + if err := unix.Fstat(int(d.Fd()), &st); err != nil { |
| + return err |
| + } |
| + return f.Chown(int(st.Uid), int(st.Gid)) |
| +} |
| +``` |
| + |
| +`owner_other.go`: |
| + |
| +```go |
| +//go:build !unix |
| + |
| +package hostguard |
| + |
| +import "os" |
| + |
| +func setFileOwner(*os.File, string) error { return ErrUnsupported } |
| +``` |
| + |
| +- [ ] **Step 6: Run the green record tests** |
| + |
| +Run: `gofmt -w internal/hostguard && go test ./internal/hostguard -run 'TestCaptureStrict|TestRecord' -count=1` |
| + |
| +Expected: `ok` on Unix; no torn captures and no leftover temporary file. `Capture` deliberately returns stamp zero for an inactive record; `With` is where that capture is refused. |
| + |
| +--- |
| + |
| +### Task 2: Fresh admission, activation and installation retirement |
| + |
| +**Files:** |
| +- Create: `internal/hostguard/guard.go`, `guard_test.go`, `flock_unix.go`, `flock_other.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Task 1; `hostclaim.Read(ctx, release.Store, host)` and `hostclaim.Observed`. |
| +- Produces: `ClaimReader`, `StoreReader`, `Guard{DataDir, LockDir, Claims}`, `With`, `Activate`, `RetireInstallation`. |
| +- `check` and `participant` inspect the fresh claim without mutating it. A nil `check` refuses admission/retirement. `participant` must check the moves phase table for this specific box; membership in `Op.Participants` alone does not grant authority. |
| + |
| +- [ ] **Step 1: Write `guard_test.go`** |
| + |
| +```go |
| +//go:build unix |
| + |
| +package hostguard |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type readerFunc func(context.Context, string) (hostclaim.Observed, error) |
| + |
| +func (f readerFunc) Read(ctx context.Context, host string) (hostclaim.Observed, error) { |
| + return f(ctx, host) |
| +} |
| + |
| +type guardFixture struct { |
| + g Guard |
| + obs hostclaim.Observed |
| + err error |
| + reads int |
| +} |
| + |
| +func newGuardFixture(t *testing.T) *guardFixture { |
| + t.Helper() |
| + f := &guardFixture{obs: hostclaim.Observed{ |
| + Host: "shop.example", ETag: "one", |
| + Claim: &hostclaim.Claim{V: 2, Host: "shop.example", Gen: 1, |
| + Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 1}, |
| + Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "uk"}}, |
| + }} |
| + f.g = Guard{DataDir: t.TempDir(), LockDir: t.TempDir(), Claims: readerFunc(func(ctx context.Context, host string) (hostclaim.Observed, error) { |
| + f.reads++ |
| + return f.obs, f.err |
| + })} |
| + return f |
| +} |
| + |
| +func allow(*hostclaim.Claim) error { return nil } |
| + |
| +func TestWithRefusals(t *testing.T) { |
| + failure := errors.New("read or identity failure") |
| + for _, tc := range []struct { |
| + name string |
| + change func(*testing.T, *guardFixture, *Captured) |
| + check func(*hostclaim.Claim) error |
| + want error |
| + }{ |
| + {"read", func(t *testing.T, f *guardFixture, c *Captured) { f.err = failure }, allow, failure}, |
| + {"absent", func(t *testing.T, f *guardFixture, c *Captured) { f.obs.Absent = true }, allow, ErrNoClaim}, |
| + {"nil-claim", func(t *testing.T, f *guardFixture, c *Captured) { f.obs.Claim = nil }, allow, ErrMalformedClaim}, |
| + {"wrong-observed-host", func(t *testing.T, f *guardFixture, c *Captured) { f.obs.Host = "else.example" }, allow, ErrMalformedClaim}, |
| + {"wrong-claim-host", func(t *testing.T, f *guardFixture, c *Captured) { f.obs.Claim.Host = "else.example" }, allow, ErrMalformedClaim}, |
| + {"nil-holder", func(t *testing.T, f *guardFixture, c *Captured) { f.obs.Claim.Holder = nil }, allow, ErrNoHolder}, |
| + {"claim-incarnation", func(t *testing.T, f *guardFixture, c *Captured) { f.obs.Claim.Holder.Incarnation = "other" }, allow, ErrClaimIncarnation}, |
| + {"check", func(t *testing.T, f *guardFixture, c *Captured) {}, func(*hostclaim.Claim) error { return failure }, failure}, |
| + {"nil-check", func(t *testing.T, f *guardFixture, c *Captured) {}, nil, ErrCheckRequired}, |
| + {"missing-record", func(t *testing.T, f *guardFixture, c *Captured) { |
| + if err := os.Remove(filepath.Join(f.g.DataDir, "hostguard", f.obs.Host+".json")); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, allow, ErrNoRecord}, |
| + {"record-incarnation", func(t *testing.T, f *guardFixture, c *Captured) { |
| + r := record{V: 1, Host: f.obs.Host, Incarnation: "other", ActiveStamp: 1} |
| + if err := writeRecord(f.g.DataDir, r); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, allow, ErrRecordIncarnation}, |
| + {"inactive", func(t *testing.T, f *guardFixture, c *Captured) { |
| + if err := f.g.RetireInstallation(t.Context(), f.obs.Host, allow); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, allow, ErrInactive}, |
| + {"lower", func(t *testing.T, f *guardFixture, c *Captured) { c.Stamp = 0 }, allow, ErrStampMismatch}, |
| + {"higher", func(t *testing.T, f *guardFixture, c *Captured) { c.Stamp = 2 }, allow, ErrStampMismatch}, |
| + {"retired", func(t *testing.T, f *guardFixture, c *Captured) { |
| + f.obs.Claim.Holder.Stamp = 3 |
| + if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, allow, ErrStampMismatch}, |
| + {"malformed-record", func(t *testing.T, f *guardFixture, c *Captured) { |
| + if err := os.WriteFile(filepath.Join(f.g.DataDir, "hostguard", f.obs.Host+".json"), []byte(`{`), 0660); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, allow, ErrMalformedRecord}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f := newGuardFixture(t) |
| + if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + captured, err := Capture(f.g.DataDir, f.obs.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tc.change(t, f, &captured) |
| + called := false |
| + err = f.g.With(t.Context(), "shop.example", captured, tc.check, func() error { called = true; return nil }) |
| + if !errors.Is(err, tc.want) || called { |
| + t.Fatalf("err=%v called=%v want=%v", err, called, tc.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestActivationAndRetirement(t *testing.T) { |
| + f := newGuardFixture(t) |
| + ctx, host := t.Context(), f.obs.Host |
| + if err := f.g.Activate(ctx, host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := os.ReadFile(filepath.Join(f.g.DataDir, "hostguard", host+".json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.Activate(ctx, host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := os.ReadFile(filepath.Join(f.g.DataDir, "hostguard", host+".json")) |
| + if err != nil || string(before) != string(after) { |
| + t.Fatal("equal stamp changed record", err) |
| + } |
| + for s := uint64(2); s <= 40; s++ { |
| + f.obs.Claim.Holder.Stamp = s |
| + if err := f.g.Activate(ctx, host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + r, err := readRecord(f.g.DataDir, host) |
| + if err != nil || r.ActiveStamp != 40 || len(r.RetiredStamps) != 32 || r.RetiredStamps[0] != 8 || r.RetiredStamps[31] != 39 { |
| + t.Fatalf("%+v %v", r, err) |
| + } |
| + if err := f.g.RetireInstallation(ctx, host, allow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.RetireInstallation(ctx, host, allow); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, tc := range []struct { |
| + stamp uint64 |
| + want error |
| + }{{40, ErrRetiredStamp}, {1, ErrOldStamp}} { |
| + f.obs.Claim.Holder.Stamp = tc.stamp |
| + if err := f.g.Activate(ctx, host, "uk", nil); !errors.Is(err, tc.want) { |
| + t.Fatal(err) |
| + } |
| + } |
| + f.obs.Claim.Holder.Incarnation = "replacement" |
| + f.obs.Claim.Holder.Stamp = 50 |
| + if err := f.g.Activate(ctx, host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + r, err = readRecord(f.g.DataDir, host) |
| + if err != nil || r.Incarnation != "replacement" || r.ActiveStamp != 50 || len(r.RetiredStamps) != 0 { |
| + t.Fatalf("%+v %v", r, err) |
| + } |
| + if err := os.Remove(filepath.Join(f.g.DataDir, "hostguard", host+".json")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.Activate(ctx, host, "uk", nil); err != nil { |
| + t.Fatal("lost record recovery", err) |
| + } |
| +} |
| + |
| +func TestLifecycleRefusals(t *testing.T) { |
| + failure := errors.New("unavailable") |
| + for _, tc := range []struct { |
| + name string |
| + change func(*guardFixture) |
| + want error |
| + }{ |
| + {"read", func(f *guardFixture) { f.err = failure }, failure}, |
| + {"absent", func(f *guardFixture) { f.obs.Absent = true }, ErrNoClaim}, |
| + {"holder", func(f *guardFixture) { f.obs.Claim.Holder = nil }, ErrNoHolder}, |
| + {"reader", func(f *guardFixture) { f.g.Claims = nil }, ErrNoClaims}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f := newGuardFixture(t) |
| + tc.change(f) |
| + if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); !errors.Is(err, tc.want) { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.RetireInstallation(t.Context(), f.obs.Host, allow); !errors.Is(err, tc.want) { |
| + t.Fatal(err) |
| + } |
| + }) |
| + } |
| + f := newGuardFixture(t) |
| + ctx, host := t.Context(), f.obs.Host |
| + for _, box := range []string{"", "us"} { |
| + if err := f.g.Activate(ctx, host, box, nil); !errors.Is(err, ErrNotParticipant) { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := f.g.Activate(ctx, host, "us", func(*hostclaim.Claim) bool { return false }); !errors.Is(err, ErrNotParticipant) { |
| + t.Fatal(err) |
| + } |
| + f.obs.Claim.Op.Kind, f.obs.Claim.Op.Phase = hostclaim.KindMoving, "landing" |
| + participant := func(c *hostclaim.Claim) bool { return c.Op.Kind == hostclaim.KindMoving && c.Op.Phase == "landing" } |
| + if err := f.g.Activate(ctx, host, "us", participant); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.obs.Claim.Holder.Stamp = 0 |
| + if err := f.g.Activate(ctx, host, "uk", nil); !errors.Is(err, ErrMalformedClaim) { |
| + t.Fatal(err) |
| + } |
| + f.obs.Claim.Holder.Stamp = 1 |
| + if err := f.g.RetireInstallation(ctx, host, nil); !errors.Is(err, ErrCheckRequired) { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.RetireInstallation(ctx, host, func(*hostclaim.Claim) error { return failure }); !errors.Is(err, failure) { |
| + t.Fatal(err) |
| + } |
| + f.obs.Claim.Holder.Incarnation = "other" |
| + if err := f.g.RetireInstallation(ctx, host, allow); !errors.Is(err, ErrRecordIncarnation) { |
| + t.Fatal(err) |
| + } |
| + f.obs.Claim.Holder.Incarnation = "inc" |
| + if err := os.Remove(filepath.Join(f.g.DataDir, "hostguard", host+".json")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.RetireInstallation(ctx, host, allow); !errors.Is(err, ErrNoRecord) { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestLifecycleRecordReadFailures(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + directory bool |
| + want error |
| + }{ |
| + {"malformed", false, ErrMalformedRecord}, {"read-error", true, nil}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f := newGuardFixture(t) |
| + host := f.obs.Host |
| + if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(f.g.DataDir, "hostguard", host+".json") |
| + if tc.directory { |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(path, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else if err := os.WriteFile(path, []byte(`{`), 0660); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, operation := range []func() error{ |
| + func() error { return f.g.Activate(t.Context(), host, "uk", nil) }, |
| + func() error { return f.g.RetireInstallation(t.Context(), host, allow) }, |
| + } { |
| + err := operation() |
| + if err == nil || errors.Is(err, ErrNoRecord) || (tc.want != nil && !errors.Is(err, tc.want)) { |
| + t.Fatal("read failure acted", err) |
| + } |
| + } |
| + }) |
| + } |
| + f := newGuardFixture(t) |
| + f.obs.Claim.Holder.Stamp = 3 |
| + if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.obs.Claim.Holder.Stamp = 2 |
| + if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); !errors.Is(err, ErrOldStamp) { |
| + t.Fatal("lower than active stamp", err) |
| + } |
| + f.obs.Claim.Holder.Incarnation = "" |
| + if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); !errors.Is(err, ErrMalformedClaim) { |
| + t.Fatal("empty incarnation", err) |
| + } |
| +} |
| + |
| +func TestFenceStampRoundTrip(t *testing.T) { |
| + f := newGuardFixture(t) |
| + ctx, host := t.Context(), f.obs.Host |
| + if err := f.g.Activate(ctx, host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + old, err := Capture(f.g.DataDir, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.obs.Claim.Op = hostclaim.Op{Kind: hostclaim.KindMoving, ID: "away", Exec: "us"} |
| + f.obs.Claim.Holder.Stamp = 2 |
| + if err := f.g.RetireInstallation(ctx, host, func(c *hostclaim.Claim) error { |
| + if c.Op.ID != "away" || c.Op.Exec != "us" { |
| + return errors.New("wrong move") |
| + } |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.obs.Claim.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "uk"} |
| + f.obs.Claim.Holder.Stamp = 3 |
| + if err := f.g.Activate(ctx, host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + writes := 0 |
| + write := func() error { writes++; return nil } |
| + if err := f.g.With(ctx, host, old, allow, write); !errors.Is(err, ErrStampMismatch) { |
| + t.Fatal(err) |
| + } |
| + current, err := Capture(f.g.DataDir, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.With(ctx, host, current, allow, write); err != nil { |
| + t.Fatal(err) |
| + } |
| + if writes != 1 { |
| + t.Fatal(writes) |
| + } |
| + r, err := readRecord(f.g.DataDir, host) |
| + if err != nil || !reflect.DeepEqual(r.RetiredStamps, []uint64{1}) { |
| + t.Fatalf("%+v %v", r, err) |
| + } |
| +} |
| + |
| +func TestStoreReaderUsesHostclaimRead(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + r := StoreReader{Store: s} |
| + obs, err := r.Read(t.Context(), "shop.example") |
| + if err != nil || !obs.Absent || obs.Host != "shop.example" { |
| + t.Fatalf("%+v %v", obs, err) |
| + } |
| + if _, err := s.Put(t.Context(), "control/hosts/shop.example.json", []byte(`{"unknown":true}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := r.Read(t.Context(), "shop.example"); !errors.Is(err, hostclaim.ErrMalformed) { |
| + t.Fatal(err) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the failing tests** |
| + |
| +Run: `go test ./internal/hostguard -count=1` |
| + |
| +Expected: build failure naming the missing guard and platform helpers. |
| + |
| +- [ ] **Step 3: Write `flock_unix.go`** |
| + |
| +The runtime directory already exists from tmpfiles. The data directory is an operator-owned directory whose owner/group is the service account; new record directories inherit that ownership. A privileged writer explicitly preserves the parent owner/group because neither root's umask nor the non-setgid runtime directory guarantees shared access. Do not chown to the socket-consumer group. |
| + |
| +```go |
| +//go:build unix |
| + |
| +package hostguard |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "time" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func acquire(ctx context.Context, dir, host string) (func(), error) { |
| + if ctx == nil { |
| + return nil, ErrInvalidInput |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return nil, err |
| + } |
| + f, err := os.OpenFile(filepath.Join(dir, host+".lock"), os.O_RDWR|os.O_CREATE, 0660) |
| + if err != nil { |
| + return nil, err |
| + } |
| + fail := func(err error) (func(), error) { f.Close(); return nil, err } |
| + if err := setFileOwner(f, dir); err != nil { |
| + return fail(err) |
| + } |
| + if err := f.Chmod(0660); err != nil { |
| + return fail(err) |
| + } |
| + tick := time.NewTicker(10 * time.Millisecond) |
| + defer tick.Stop() |
| + for { |
| + if err := ctx.Err(); err != nil { |
| + return fail(err) |
| + } |
| + err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) |
| + if err == nil { |
| + unlock := func() { _ = unix.Flock(int(f.Fd()), unix.LOCK_UN); _ = f.Close() } |
| + if err := ctx.Err(); err != nil { |
| + unlock() |
| + return nil, err |
| + } |
| + return unlock, nil |
| + } |
| + if !errors.Is(err, unix.EWOULDBLOCK) && !errors.Is(err, unix.EAGAIN) && !errors.Is(err, unix.EINTR) { |
| + return fail(err) |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return fail(ctx.Err()) |
| + case <-tick.C: |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Write `flock_other.go`** |
| + |
| +```go |
| +//go:build !unix |
| + |
| +package hostguard |
| + |
| +import "context" |
| + |
| +func acquire(context.Context, string, string) (func(), error) { return nil, ErrUnsupported } |
| +``` |
| + |
| +- [ ] **Step 5: Write `guard.go`** |
| + |
| +```go |
| +package hostguard |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +var ( |
| + ErrInvalidInput = errors.New("hostguard: invalid input") |
| + ErrNoClaims = errors.New("hostguard: no claim reader") |
| + ErrNoClaim = errors.New("hostguard: claim absent") |
| + ErrMalformedClaim = errors.New("hostguard: malformed claim observation") |
| + ErrNoHolder = errors.New("hostguard: claim has no holder") |
| + ErrClaimIncarnation = errors.New("hostguard: captured incarnation differs from claim") |
| + ErrRecordIncarnation = errors.New("hostguard: local incarnation differs") |
| + ErrInactive = errors.New("hostguard: installation inactive") |
| + ErrStampMismatch = errors.New("hostguard: captured stamp is not active") |
| + ErrCheckRequired = errors.New("hostguard: identity check required") |
| + ErrNotParticipant = errors.New("hostguard: box is not authorized to activate") |
| + ErrRetiredStamp = errors.New("hostguard: installation stamp retired") |
| + ErrOldStamp = errors.New("hostguard: installation stamp predates local history") |
| +) |
| + |
| +type ClaimReader interface { |
| + Read(context.Context, string) (hostclaim.Observed, error) |
| +} |
| + |
| +type StoreReader struct{ Store release.Store } |
| + |
| +func (r StoreReader) Read(ctx context.Context, host string) (hostclaim.Observed, error) { |
| + if r.Store == nil { |
| + return hostclaim.Observed{}, ErrNoClaims |
| + } |
| + return hostclaim.Read(ctx, r.Store, host) |
| +} |
| + |
| +type Guard struct { |
| + DataDir string |
| + LockDir string |
| + Claims ClaimReader |
| +} |
| + |
| +func (g Guard) locked(ctx context.Context, host string, fn func() error) error { |
| + if !controlname.ValidHostShape(host) { |
| + return ErrInvalidHost |
| + } |
| + if g.DataDir == "" || ctx == nil { |
| + return ErrInvalidInput |
| + } |
| + dir := g.LockDir |
| + if dir == "" { |
| + dir = "/run/carlos/hostguard" |
| + } |
| + unlock, err := acquire(ctx, dir, host) |
| + if err != nil { |
| + return err |
| + } |
| + defer unlock() |
| + return fn() |
| +} |
| + |
| +func (g Guard) fresh(ctx context.Context, host string) (*hostclaim.Claim, error) { |
| + if err := ctx.Err(); err != nil { |
| + return nil, err |
| + } |
| + if g.Claims == nil { |
| + return nil, ErrNoClaims |
| + } |
| + obs, err := g.Claims.Read(ctx, host) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return nil, err |
| + } |
| + if obs.Host != host { |
| + return nil, ErrMalformedClaim |
| + } |
| + if obs.Absent { |
| + return nil, ErrNoClaim |
| + } |
| + if obs.Claim == nil || obs.Claim.Host != host { |
| + return nil, ErrMalformedClaim |
| + } |
| + if obs.Claim.Holder == nil { |
| + return nil, ErrNoHolder |
| + } |
| + if obs.Claim.Holder.Incarnation == "" { |
| + return nil, ErrMalformedClaim |
| + } |
| + return obs.Claim, nil |
| +} |
| + |
| +func (g Guard) With(ctx context.Context, host string, captured Captured, check func(*hostclaim.Claim) error, fn func() error) error { |
| + return g.locked(ctx, host, func() error { |
| + c, err := g.fresh(ctx, host) |
| + if err != nil { |
| + return err |
| + } |
| + if c.Holder.Incarnation != captured.Incarnation { |
| + return ErrClaimIncarnation |
| + } |
| + if check == nil { |
| + return ErrCheckRequired |
| + } |
| + if err := check(c); err != nil { |
| + return err |
| + } |
| + r, err := readRecord(g.DataDir, host) |
| + if err != nil { |
| + return err |
| + } |
| + if r.Incarnation != captured.Incarnation { |
| + return ErrRecordIncarnation |
| + } |
| + if r.ActiveStamp == 0 { |
| + return ErrInactive |
| + } |
| + if captured.Stamp != r.ActiveStamp { |
| + return ErrStampMismatch |
| + } |
| + if fn == nil { |
| + return ErrInvalidInput |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + return fn() |
| + }) |
| +} |
| + |
| +func (r *record) retire() { |
| + if r.ActiveStamp == 0 { |
| + return |
| + } |
| + r.RetiredStamps = append(r.RetiredStamps, r.ActiveStamp) |
| + if len(r.RetiredStamps) > 32 { |
| + r.RetiredStamps = r.RetiredStamps[len(r.RetiredStamps)-32:] |
| + } |
| + r.ActiveStamp = 0 |
| +} |
| + |
| +func (g Guard) Activate(ctx context.Context, host, box string, participant func(*hostclaim.Claim) bool) error { |
| + return g.locked(ctx, host, func() error { |
| + c, err := g.fresh(ctx, host) |
| + if err != nil { |
| + return err |
| + } |
| + if box == "" || (c.Op.Exec != box && (participant == nil || !participant(c))) { |
| + return ErrNotParticipant |
| + } |
| + stamp := c.Holder.Stamp |
| + if stamp == 0 { |
| + return ErrMalformedClaim |
| + } |
| + r, err := readRecord(g.DataDir, host) |
| + if err != nil && !errors.Is(err, ErrNoRecord) { |
| + return err |
| + } |
| + if errors.Is(err, ErrNoRecord) || r.Incarnation != c.Holder.Incarnation { |
| + r = record{V: 1, Host: host, Incarnation: c.Holder.Incarnation, RetiredStamps: []uint64{}} |
| + } else { |
| + for _, s := range r.RetiredStamps { |
| + if stamp == s { |
| + return ErrRetiredStamp |
| + } |
| + } |
| + if stamp < r.ActiveStamp { |
| + return ErrOldStamp |
| + } |
| + // Keep refusing trimmed stamps even after retirement sets active to zero. |
| + if n := len(r.RetiredStamps); n != 0 && stamp < r.RetiredStamps[n-1] { |
| + return ErrOldStamp |
| + } |
| + if stamp == r.ActiveStamp { |
| + return nil |
| + } |
| + r.retire() |
| + } |
| + r.ActiveStamp = stamp |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + return writeRecord(g.DataDir, r) |
| + }) |
| +} |
| + |
| +func (g Guard) RetireInstallation(ctx context.Context, host string, check func(*hostclaim.Claim) error) error { |
| + return g.locked(ctx, host, func() error { |
| + c, err := g.fresh(ctx, host) |
| + if err != nil { |
| + return err |
| + } |
| + if check == nil { |
| + return ErrCheckRequired |
| + } |
| + if err := check(c); err != nil { |
| + return err |
| + } |
| + r, err := readRecord(g.DataDir, host) |
| + if err != nil { |
| + return err |
| + } |
| + if r.Incarnation != c.Holder.Incarnation { |
| + return ErrRecordIncarnation |
| + } |
| + if r.ActiveStamp == 0 { |
| + return nil |
| + } |
| + r.retire() |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + return writeRecord(g.DataDir, r) |
| + }) |
| +} |
| +``` |
| + |
| +`StoreReader` delegates strict claim decoding/validation to Plan 1. Test readers supply already-decoded claims; `fresh` additionally binds the observation to the requested host. No claim CAS is performed here. Retirement's callback must bind the intended operation, incarnation and installation; the library supplies local serialization, not distributed authorization. |
| + |
| +- [ ] **Step 6: Run the green tests** |
| + |
| +Run: `gofmt -w internal/hostguard && go test ./internal/hostguard -count=1 && go vet ./internal/hostguard` |
| + |
| +Expected: all record and guard tests pass, including the deterministic fence-stamp witness. No output from vet. |
| + |
| +--- |
| + |
| +### Task 3: Process exclusion, cancellation and lock lifetime |
| + |
| +**Files:** |
| +- Create: `internal/hostguard/flock_unix_test.go`, `flock_other_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Guard`, `Capture`, the unexported lock helper. |
| +- Produces: process-level regression coverage; no additional production interface. |
| + |
| +- [ ] **Step 1: Write `flock_unix_test.go`** |
| + |
| +```go |
| +//go:build unix |
| + |
| +package hostguard |
| + |
| +import ( |
| + "bufio" |
| + "context" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "runtime" |
| + "sync/atomic" |
| + "syscall" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func TestGuardProcess(t *testing.T) { |
| + if os.Getenv("CARLOS_HOSTGUARD_TEST_CHILD") == "1" { |
| + unlock, err := acquire(t.Context(), os.Getenv("CARLOS_HOSTGUARD_TEST_LOCKDIR"), "shop.example") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer unlock() |
| + fmt.Fprintln(os.Stdout, "locked") |
| + if _, err := io.Copy(io.Discard, os.Stdin); err != nil { |
| + t.Fatal(err) |
| + } |
| + return |
| + } |
| + f := newGuardFixture(t) |
| + host := f.obs.Host |
| + if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + captured, err := Capture(f.g.DataDir, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) |
| + defer cancel() |
| + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestGuardProcess$", "-test.count=1") |
| + cmd.Dir = t.TempDir() |
| + cmd.Env = append(os.Environ(), "CARLOS_HOSTGUARD_TEST_CHILD=1", "CARLOS_HOSTGUARD_TEST_LOCKDIR="+f.g.LockDir) |
| + in, err := cmd.StdinPipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + out, err := cmd.StdoutPipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd.Stderr = os.Stderr |
| + if err := cmd.Start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + waited := false |
| + defer func() { |
| + in.Close() |
| + if !waited { |
| + cancel() |
| + _ = cmd.Wait() |
| + } |
| + }() |
| + line, err := bufio.NewReader(out).ReadString('\n') |
| + if err != nil || line != "locked\n" { |
| + t.Fatalf("child readiness: %q %v", line, err) |
| + } |
| + short, stop := context.WithTimeout(t.Context(), 40*time.Millisecond) |
| + defer stop() |
| + called, reads := false, f.reads |
| + err = f.g.With(short, host, captured, allow, func() error { called = true; return nil }) |
| + if !errors.Is(err, context.DeadlineExceeded) || called || f.reads != reads { |
| + t.Fatalf("lock admitted: %v %v %d", err, called, f.reads) |
| + } |
| + if err := in.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + err = cmd.Wait() |
| + waited = true |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.With(ctx, host, captured, allow, func() error { called = true; return nil }); err != nil || !called { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestGuardReadsAfterLock(t *testing.T) { |
| + f := newGuardFixture(t) |
| + host := f.obs.Host |
| + if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + captured, err := Capture(f.g.DataDir, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + unlock, err := acquire(t.Context(), f.g.LockDir, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer func() { |
| + if unlock != nil { |
| + unlock() |
| + } |
| + }() |
| + var current atomic.Pointer[hostclaim.Claim] |
| + current.Store(f.obs.Claim) |
| + reads := make(chan struct{}, 1) |
| + f.g.Claims = readerFunc(func(context.Context, string) (hostclaim.Observed, error) { |
| + reads <- struct{}{} |
| + return hostclaim.Observed{Host: host, Claim: current.Load(), ETag: "fresh"}, nil |
| + }) |
| + ctx, cancel := context.WithTimeout(t.Context(), time.Second) |
| + defer cancel() |
| + result := make(chan error, 1) |
| + go func() { |
| + result <- f.g.With(ctx, host, captured, allow, func() error { return errors.New("callback ran") }) |
| + }() |
| + select { |
| + case <-reads: |
| + t.Fatal("claim read before lock") |
| + case <-time.After(30 * time.Millisecond): |
| + } |
| + next := *f.obs.Claim |
| + holder := *next.Holder |
| + holder.Incarnation = "changed" |
| + next.Holder = &holder |
| + current.Store(&next) |
| + unlock() |
| + unlock = nil |
| + if err := <-result; !errors.Is(err, ErrClaimIncarnation) { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestGuardReleasesAfterErrorAndPanic(t *testing.T) { |
| + f := newGuardFixture(t) |
| + host := f.obs.Host |
| + if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + captured, err := Capture(f.g.DataDir, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + failure := errors.New("callback failed") |
| + if err := f.g.With(t.Context(), host, captured, allow, func() error { return failure }); !errors.Is(err, failure) { |
| + t.Fatal(err) |
| + } |
| + func() { |
| + defer func() { |
| + if recover() != "panic witness" { |
| + t.Error("panic not propagated") |
| + } |
| + }() |
| + _ = f.g.With(t.Context(), host, captured, allow, func() error { panic("panic witness") }) |
| + }() |
| + ctx, cancel := context.WithTimeout(t.Context(), time.Second) |
| + defer cancel() |
| + if err := f.g.With(ctx, host, captured, allow, func() error { return nil }); err != nil { |
| + t.Fatal("leaked flock", err) |
| + } |
| + if err := f.g.With(ctx, host, captured, allow, nil); !errors.Is(err, ErrInvalidInput) { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.Activate(nil, host, "uk", nil); !errors.Is(err, ErrInvalidInput) { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.Activate(ctx, "../outside", "uk", nil); !errors.Is(err, ErrInvalidHost) { |
| + t.Fatal(err) |
| + } |
| + cancel() |
| + if err := f.g.With(ctx, host, captured, allow, func() error { t.Fatal("cancelled callback"); return nil }); !errors.Is(err, context.Canceled) { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestGuardForeignOwnerProcess(t *testing.T) { |
| + if base := os.Getenv("CARLOS_HOSTGUARD_FOREIGN_OWNER"); base != "" { |
| + f := newGuardFixture(t) |
| + f.g.DataDir, f.g.LockDir = filepath.Join(base, "data"), filepath.Join(base, "locks") |
| + captured, err := Capture(f.g.DataDir, f.obs.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.With(t.Context(), f.obs.Host, captured, allow, func() error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.obs.Claim.Holder.Stamp = 2 |
| + if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + return |
| + } |
| + if runtime.GOOS != "linux" || os.Geteuid() != 0 { |
| + t.Skip("cross-UID descriptor-based witness requires Linux root") |
| + } |
| + base := t.TempDir() |
| + if err := os.Chmod(base, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + const uid, gid = 65534, 65534 |
| + f := newGuardFixture(t) |
| + f.g.DataDir, f.g.LockDir = filepath.Join(base, "data"), filepath.Join(base, "locks") |
| + for _, dir := range []string{f.g.DataDir, f.g.LockDir} { |
| + if err := os.Mkdir(dir, 0770); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chown(dir, uid, gid); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chmod(dir, 0770); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, path := range []string{filepath.Join(f.g.LockDir, f.obs.Host+".lock"), filepath.Join(f.g.DataDir, "hostguard"), filepath.Join(f.g.DataDir, "hostguard", f.obs.Host+".json")} { |
| + var st unix.Stat_t |
| + if err := unix.Stat(path, &st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st.Uid != uid || st.Gid != gid { |
| + t.Fatalf("root-owned artifact: %s uid=%d gid=%d", path, st.Uid, st.Gid) |
| + } |
| + } |
| + // The Go build directory may be root-only. Copy this test binary into |
| + // our disposable tree, then pass directory/executable descriptors so |
| + // root-private TMPDIR ancestors do not affect the child. |
| + src, err := os.Open(os.Args[0]) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer src.Close() |
| + binary := filepath.Join(base, "guard-test") |
| + dst, err := os.OpenFile(binary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0755) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _, copyErr := io.Copy(dst, src) |
| + if err := errors.Join(copyErr, dst.Close()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chmod(binary, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + baseFD, err := os.Open(base) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer baseFD.Close() |
| + binaryFD, err := os.Open(binary) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer binaryFD.Close() |
| + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) |
| + defer cancel() |
| + cmd := exec.CommandContext(ctx, "/proc/self/fd/4", "-test.run=^TestGuardForeignOwnerProcess$", "-test.count=1") |
| + cmd.Dir = "/" |
| + cmd.ExtraFiles = []*os.File{baseFD, binaryFD} |
| + cmd.Env = []string{"CARLOS_HOSTGUARD_FOREIGN_OWNER=/proc/self/fd/3", "TMPDIR=/proc/self/fd/3/data"} |
| + cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uid, Gid: gid}} |
| + if out, err := cmd.CombinedOutput(); err != nil { |
| + t.Fatalf("service-user access: %v %s", err, out) |
| + } |
| + captured, err := Capture(f.g.DataDir, f.obs.Host) |
| + if err != nil || captured.Stamp != 2 { |
| + t.Fatal("service-user update failed", captured, err) |
| + } |
| +} |
| + |
| +func TestGuardSharedFileModes(t *testing.T) { |
| + f := newGuardFixture(t) |
| + if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, pair := range [][2]string{ |
| + {filepath.Join(f.g.LockDir, f.obs.Host+".lock"), f.g.LockDir}, |
| + {filepath.Join(f.g.DataDir, "hostguard", f.obs.Host+".json"), f.g.DataDir}, |
| + } { |
| + info, err := os.Stat(pair[0]) |
| + if err != nil || info.Mode().Perm() != 0660 { |
| + t.Fatalf("mode: %v %v", info, err) |
| + } |
| + var file, parent unix.Stat_t |
| + if err := unix.Stat(pair[0], &file); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := unix.Stat(pair[1], &parent); err != nil { |
| + t.Fatal(err) |
| + } |
| + if file.Uid != parent.Uid || file.Gid != parent.Gid { |
| + t.Fatal("owner/group not preserved") |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Write `flock_other_test.go`** |
| + |
| +```go |
| +//go:build !unix |
| + |
| +package hostguard |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| +) |
| + |
| +func TestUnsupportedLock(t *testing.T) { |
| + g := Guard{DataDir: t.TempDir(), LockDir: t.TempDir()} |
| + if err := g.Activate(context.Background(), "shop.example", "box", nil); !errors.Is(err, ErrUnsupported) { |
| + t.Fatal(err) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 3: Check the tests detect the failures, then restore Task 2's exact code** |
| + |
| +For the red witness, temporarily replace `acquire`'s successful flock call with `err := error(nil)`; run `go test ./internal/hostguard -run '^TestGuardProcess$' -count=1`. Expected: failure because the parent enters while the child holds the lock. Restore `err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB)` immediately. Separately remove `defer unlock()` from `Guard.locked`, run `go test ./internal/hostguard -run '^TestGuardReleasesAfterErrorAndPanic$' -count=1 -timeout=5s`, and expect a failure/timeout. Restore the defer before proceeding. These are local test mutations, never commits. |
| + |
| +- [ ] **Step 4: Run the green process and race tests** |
| + |
| +Run: `go test -race ./internal/hostguard -count=1 -timeout=60s` |
| + |
| +Expected: `ok`; the child exits, a blocked caller returns `context.DeadlineExceeded`, no callback runs before admission, and there are no race reports. |
| + |
| +--- |
| + |
| +### Task 4: Reopen-only cgroup cleanup |
| + |
| +**Files:** |
| +- Create: `internal/cgroup/cleanup.go`, `cleanup_linux.go`, `cleanup_other.go`, `cleanup_linux_test.go`, `cleanup_other_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `(*Manager).OpenCleanup(host) (*Cleanup, error)`, `Populated`, `Members`, `KillAndEmpty(ctx)`, `Close`; `ErrNoCgroup`, `ErrNoDelegation`. |
| +- Consumes: the existing `ExecName`, `strictOpen`, `strictID`, `strictPopulation`, `strictWrite`, and the private pinned identity checks on `Prepared`. |
| +- The private `pin` field is named, never embedded; `Cleanup` has no `Start` method. It does not create directories, change limits, join a cgroup, or reserve an empty group for spawning. `Close` releases descriptors only, including on a failed cleanup; it never certifies a stop or removes a path. |
| + |
| +- [ ] **Step 1: Write `cleanup_linux_test.go`** |
| + |
| +```go |
| +//go:build linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func cleanupFixture(t *testing.T) (*Manager, string, string) { |
| + t.Helper() |
| + host, root := "cleanup.example", t.TempDir() |
| + dir := filepath.Join(root, ExecName(host)) |
| + if err := os.Mkdir(dir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + for name, body := range map[string]string{"cgroup.events": "populated 1\n", "cgroup.procs": "123\n456\n", "cgroup.kill": ""} { |
| + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return &Manager{root: root}, host, dir |
| +} |
| + |
| +func fixtureCleanup(t *testing.T, m *Manager, host string) *Cleanup { |
| + t.Helper() |
| + c, err := m.openCleanup(host, func(*os.File) error { return nil }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { _ = c.Close() }) |
| + return c |
| +} |
| + |
| +func TestCleanupReopensAndDrains(t *testing.T) { |
| + m, host, dir := cleanupFixture(t) |
| + c := fixtureCleanup(t, m, host) |
| + if _, exists := reflect.TypeOf(c).MethodByName("Start"); exists { |
| + t.Fatal("cleanup grants spawn") |
| + } |
| + if populated, err := c.Populated(); err != nil || !populated { |
| + t.Fatal(populated, err) |
| + } |
| + if members, err := c.Members(); err != nil || !reflect.DeepEqual(members, []int{123, 456}) { |
| + t.Fatal(members, err) |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond) |
| + defer cancel() |
| + if err := c.KillAndEmpty(ctx); !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatal(err) |
| + } |
| + if b, err := os.ReadFile(filepath.Join(dir, "cgroup.kill")); err != nil || string(b) != "1" { |
| + t.Fatalf("kill: %q %v", b, err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, "cgroup.events"), []byte("populated 0\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx2, cancel2 := context.WithTimeout(t.Context(), time.Second) |
| + defer cancel2() |
| + // Population alone is insufficient: the members still prevent success. |
| + ctx3, cancel3 := context.WithTimeout(t.Context(), 30*time.Millisecond) |
| + defer cancel3() |
| + if err := c.KillAndEmpty(ctx3); !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, "cgroup.procs"), nil, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := c.KillAndEmpty(ctx2); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := c.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := c.Populated(); !errors.Is(err, ErrStrictState) { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(dir); err != nil { |
| + t.Fatal("close removed group", err) |
| + } |
| +} |
| + |
| +func TestCleanupRefusals(t *testing.T) { |
| + var absent *Manager |
| + if _, err := absent.OpenCleanup("cleanup.example"); !errors.Is(err, ErrNoDelegation) { |
| + t.Fatal(err) |
| + } |
| + if _, err := (&Manager{}).OpenCleanup("cleanup.example"); !errors.Is(err, ErrNoDelegation) { |
| + t.Fatal(err) |
| + } |
| + m, host, dir := cleanupFixture(t) |
| + if _, err := m.OpenCleanup(host); !errors.Is(err, ErrStrictUnsupported) { |
| + t.Fatal("ordinary filesystem admitted", err) |
| + } |
| + if _, err := m.openCleanup("../outside", func(*os.File) error { return nil }); !errors.Is(err, ErrStrictRefused) { |
| + t.Fatal(err) |
| + } |
| + if _, err := m.openCleanup("missing.example", func(*os.File) error { return nil }); !errors.Is(err, ErrNoCgroup) { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(filepath.Join(m.root, ExecName("missing.example"))); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("created missing group", err) |
| + } |
| + c := fixtureCleanup(t, m, host) |
| + for _, body := range []string{"", "populated 2\n", "populated 0\npopulated 0\n", "broken\n"} { |
| + if err := os.WriteFile(filepath.Join(dir, "cgroup.events"), []byte(body), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := c.Populated(); err == nil { |
| + t.Fatal("malformed events accepted") |
| + } |
| + } |
| + for _, body := range []string{"0\n", "-1\n", "oops\n", "9999999999999999999999999\n"} { |
| + if err := os.WriteFile(filepath.Join(dir, "cgroup.procs"), []byte(body), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := c.Members(); err == nil { |
| + t.Fatal("malformed members accepted") |
| + } |
| + } |
| + if err := c.KillAndEmpty(context.Background()); !errors.Is(err, ErrStrictDeadline) { |
| + t.Fatal(err) |
| + } |
| + if err := c.KillAndEmpty(nil); !errors.Is(err, ErrStrictDeadline) { |
| + t.Fatal(err) |
| + } |
| + if err := os.Rename(dir, dir+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(dir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := c.Populated(); err == nil { |
| + t.Fatal("replaced identity accepted") |
| + } |
| + if _, err := c.Members(); err == nil { |
| + t.Fatal("replaced membership accepted") |
| + } |
| + if err := c.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestCleanupDeadlineWhileLocked(t *testing.T) { |
| + m, host, _ := cleanupFixture(t) |
| + c := fixtureCleanup(t, m, host) |
| + c.state.pin.mu.Lock() |
| + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond) |
| + defer cancel() |
| + done := make(chan error, 1) |
| + go func() { done <- c.KillAndEmpty(ctx) }() |
| + select { |
| + case err := <-done: |
| + c.state.pin.mu.Unlock() |
| + if !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatal(err) |
| + } |
| + case <-time.After(time.Second): |
| + c.state.pin.mu.Unlock() |
| + <-done |
| + t.Fatal("cleanup exceeded its deadline waiting for a handle lock") |
| + } |
| +} |
| + |
| +func TestCleanupRejectsMissingControlsAndSymlinks(t *testing.T) { |
| + for _, name := range []string{"cgroup.events", "cgroup.procs", "cgroup.kill"} { |
| + t.Run(name, func(t *testing.T) { |
| + m, host, dir := cleanupFixture(t) |
| + if err := os.Remove(filepath.Join(dir, name)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c, err := m.openCleanup(host, func(*os.File) error { return nil }); err == nil || c != nil || errors.Is(err, ErrNoCgroup) { |
| + t.Fatalf("missing control: %v %v", c, err) |
| + } |
| + if err := os.Symlink(filepath.Join(dir, "elsewhere"), filepath.Join(dir, name)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c, err := m.openCleanup(host, func(*os.File) error { return nil }); err == nil || c != nil { |
| + t.Fatalf("symlink control: %v %v", c, err) |
| + } |
| + }) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the failing tests** |
| + |
| +Run: `go test ./internal/cgroup -run '^TestCleanup' -count=1` |
| + |
| +Expected: build failure naming `Cleanup`, `OpenCleanup` and the new errors. |
| + |
| +- [ ] **Step 3: Write `cleanup.go`** |
| + |
| +```go |
| +package cgroup |
| + |
| +import "errors" |
| + |
| +var ( |
| + ErrNoCgroup = errors.New("cgroup: existing host cgroup absent") |
| + ErrNoDelegation = errors.New("cgroup: no delegated manager") |
| +) |
| +``` |
| + |
| +- [ ] **Step 4: Write `cleanup_linux.go`** |
| + |
| +```go |
| +//go:build linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "bufio" |
| + "context" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +type Cleanup struct{ state *cleanupState } |
| +type cleanupState struct { |
| + pin *Prepared |
| + procs *os.File |
| +} |
| + |
| +func cleanupFilesystem(root *os.File) error { |
| + var st unix.Statfs_t |
| + if err := unix.Fstatfs(int(root.Fd()), &st); err != nil { |
| + return err |
| + } |
| + if uint64(st.Type) != uint64(unix.CGROUP2_SUPER_MAGIC) { |
| + return ErrStrictUnsupported |
| + } |
| + return nil |
| +} |
| + |
| +func (m *Manager) OpenCleanup(host string) (*Cleanup, error) { |
| + return m.openCleanup(host, cleanupFilesystem) |
| +} |
| + |
| +func (m *Manager) openCleanup(host string, checkFS func(*os.File) error) (out *Cleanup, err error) { |
| + if m == nil || m.root == "" { |
| + return nil, ErrNoDelegation |
| + } |
| + if !controlname.ValidHostShape(host) { |
| + return nil, ErrStrictRefused |
| + } |
| + root, err := os.OpenFile(m.root, os.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0) |
| + if err != nil { |
| + return nil, err |
| + } |
| + p := &Prepared{strictPreparedState: &strictPreparedState{root: root, host: host}} |
| + c := &Cleanup{state: &cleanupState{pin: p}} |
| + defer func() { |
| + if err != nil { |
| + if c.state.procs != nil { |
| + c.state.procs.Close() |
| + } |
| + p.closeFiles() |
| + } |
| + }() |
| + if err = checkFS(root); err != nil { |
| + return nil, err |
| + } |
| + p.dir, err = strictOpen(root, ExecName(host), unix.O_RDONLY|unix.O_DIRECTORY) |
| + if errors.Is(err, unix.ENOENT) { |
| + return nil, ErrNoCgroup |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + p.identity, err = strictID(p.dir) |
| + if err != nil { |
| + return nil, err |
| + } |
| + p.events, err = strictOpen(p.dir, "cgroup.events", unix.O_RDONLY) |
| + if err != nil { |
| + return nil, err |
| + } |
| + p.kill, err = strictOpen(p.dir, "cgroup.kill", unix.O_WRONLY) |
| + if err != nil { |
| + return nil, err |
| + } |
| + c.state.procs, err = strictOpen(p.dir, "cgroup.procs", unix.O_RDONLY) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if err = p.identityOK(); err != nil { |
| + return nil, err |
| + } |
| + return c, nil |
| +} |
| + |
| +func (c *Cleanup) Populated() (bool, error) { |
| + if c == nil || c.state == nil { |
| + return false, ErrStrictState |
| + } |
| + p := c.state.pin |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + return c.populationLocked() |
| +} |
| + |
| +func (c *Cleanup) populationLocked() (bool, error) { |
| + p := c.state.pin |
| + if err := p.identityOK(); err != nil { |
| + return false, err |
| + } |
| + v, err := strictPopulation(p.events) |
| + if err != nil { |
| + return false, err |
| + } |
| + if err := p.identityOK(); err != nil { |
| + return false, err |
| + } |
| + return v, nil |
| +} |
| + |
| +func (c *Cleanup) Members() ([]int, error) { |
| + if c == nil || c.state == nil { |
| + return nil, ErrStrictState |
| + } |
| + p := c.state.pin |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + return c.membersLocked() |
| +} |
| + |
| +func (c *Cleanup) membersLocked() ([]int, error) { |
| + p := c.state.pin |
| + if err := p.identityOK(); err != nil { |
| + return nil, err |
| + } |
| + if _, err := c.state.procs.Seek(0, 0); err != nil { |
| + return nil, err |
| + } |
| + // cgroup.procs may exceed strictRead's 4096-byte bound. Scan every line. |
| + scan := bufio.NewScanner(c.state.procs) |
| + var members []int |
| + for scan.Scan() { |
| + raw := strings.TrimSpace(scan.Text()) |
| + pid, err := strconv.Atoi(raw) |
| + if err != nil || pid <= 0 { |
| + return nil, fmt.Errorf("%w: invalid cgroup member", ErrStrictRefused) |
| + } |
| + members = append(members, pid) |
| + } |
| + if err := scan.Err(); err != nil { |
| + return nil, err |
| + } |
| + if err := p.identityOK(); err != nil { |
| + return nil, err |
| + } |
| + return members, nil |
| +} |
| + |
| +func (c *Cleanup) KillAndEmpty(ctx context.Context) error { |
| + if c == nil || c.state == nil { |
| + return ErrStrictState |
| + } |
| + if ctx == nil { |
| + return ErrStrictDeadline |
| + } |
| + deadline, ok := ctx.Deadline() |
| + if !ok { |
| + return ErrStrictDeadline |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + if !time.Now().Before(deadline) { |
| + return context.DeadlineExceeded |
| + } |
| + p := c.state.pin |
| + lockTick := time.NewTicker(time.Millisecond) |
| + defer lockTick.Stop() |
| + for !p.mu.TryLock() { |
| + select { |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + case <-lockTick.C: |
| + } |
| + } |
| + defer p.mu.Unlock() |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + if err := p.identityOK(); err != nil { |
| + return err |
| + } |
| + if err := strictWrite(p.kill, "1"); err != nil { |
| + return err |
| + } |
| + tick := time.NewTicker(5 * time.Millisecond) |
| + defer tick.Stop() |
| + for { |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + populated, err := c.populationLocked() |
| + if err != nil { |
| + return err |
| + } |
| + members, err := c.membersLocked() |
| + if err != nil { |
| + return err |
| + } |
| + if !populated && len(members) == 0 { |
| + return ctx.Err() |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + case <-tick.C: |
| + } |
| + } |
| +} |
| + |
| +func (c *Cleanup) Close() error { |
| + if c == nil || c.state == nil { |
| + return ErrStrictState |
| + } |
| + p := c.state.pin |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if p.closed { |
| + return nil |
| + } |
| + p.closed = true |
| + var errs []error |
| + for _, f := range []*os.File{c.state.procs, p.events, p.kill, p.dir, p.root} { |
| + if f != nil { |
| + errs = append(errs, f.Close()) |
| + } |
| + } |
| + return errors.Join(errs...) |
| +} |
| +``` |
| + |
| +The private `Prepared` is used only for descriptor identity and serialization helpers. Do not call its `Start`, `PrepareStrict`, `KillAndEmpty` or reservation-aware `Close` from cleanup. Opening for cleanup does not require the calling process to live inside the delegated subtree; that permits Plan 6 to select a privileged evidence collector. Its configured `Manager` must still identify the correct delegated cgroup-v2 root. |
| + |
| +- [ ] **Step 5: Write `cleanup_other.go` and `cleanup_other_test.go`** |
| + |
| +`cleanup_other.go`: |
| + |
| +```go |
| +//go:build !linux |
| + |
| +package cgroup |
| + |
| +import "context" |
| + |
| +type Cleanup struct{} |
| + |
| +func (m *Manager) OpenCleanup(string) (*Cleanup, error) { |
| + if m == nil || m.root == "" { |
| + return nil, ErrNoDelegation |
| + } |
| + return nil, ErrStrictUnsupported |
| +} |
| +func (*Cleanup) Populated() (bool, error) { return false, ErrStrictUnsupported } |
| +func (*Cleanup) Members() ([]int, error) { return nil, ErrStrictUnsupported } |
| +func (*Cleanup) KillAndEmpty(context.Context) error { return ErrStrictUnsupported } |
| +func (*Cleanup) Close() error { return ErrStrictUnsupported } |
| +``` |
| + |
| +`cleanup_other_test.go`: |
| + |
| +```go |
| +//go:build !linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| +) |
| + |
| +func TestCleanupUnsupported(t *testing.T) { |
| + if _, err := (&Manager{root: t.TempDir()}).OpenCleanup("shop.example"); !errors.Is(err, ErrStrictUnsupported) { |
| + t.Fatal(err) |
| + } |
| + var m *Manager |
| + if _, err := m.OpenCleanup("shop.example"); !errors.Is(err, ErrNoDelegation) { |
| + t.Fatal(err) |
| + } |
| + c := &Cleanup{} |
| + if _, err := c.Populated(); !errors.Is(err, ErrStrictUnsupported) { |
| + t.Fatal(err) |
| + } |
| + if _, err := c.Members(); !errors.Is(err, ErrStrictUnsupported) { |
| + t.Fatal(err) |
| + } |
| + if err := c.KillAndEmpty(context.Background()); !errors.Is(err, ErrStrictUnsupported) { |
| + t.Fatal(err) |
| + } |
| + if err := c.Close(); !errors.Is(err, ErrStrictUnsupported) { |
| + t.Fatal(err) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 6: Run the cgroup tests** |
| + |
| +Run: `gofmt -w internal/cgroup/cleanup*.go && go test -race ./internal/cgroup -count=1` |
| + |
| +Expected: cleanup and existing strict-containment tests pass. Filesystem fixtures test parsing and handle state; they bypass only the filesystem-type check through a private parameter. Public `OpenCleanup` must refuse those fixtures. Existing delegated-kernel tests may skip on a machine without their explicitly configured sandbox; a skip is not a live containment result. No production cgroup is created or killed by these tests. |
| + |
| +--- |
| + |
| +### Task 5: Complete database descriptor scans |
| + |
| +**Files:** |
| +- Create: `internal/stopevidence/fds.go`, `fds_linux.go`, `fds_other.go`, `fds_linux_test.go`, `fds_other_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `FDHolders(procRoot string, paths []string) ([]int, error)`, `ErrIncomplete`, `ErrUnsupported`, `ErrInvalidInput`. |
| +- Input paths are absolute. Match cleaned paths, including Linux's ` (deleted)` suffix; return sorted, unique PIDs. An error returns no partial holder list. |
| +- `fdHolders` is the context-aware internal form used by Task 6. Its per-call filesystem seam makes EACCES tests deterministic even when the test runner is root. |
| + |
| +- [ ] **Step 1: Write `fds_linux_test.go`** |
| + |
| +```go |
| +//go:build linux |
| + |
| +package stopevidence |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "strconv" |
| + "syscall" |
| + "testing" |
| +) |
| + |
| +func procFixture(t *testing.T) (string, string) { |
| + t.Helper() |
| + root, db := t.TempDir(), filepath.Join(t.TempDir(), "data.db") |
| + for pid, targets := range map[string][]string{ |
| + "20": {db, db + "-wal", db + "-shm"}, |
| + "3": {db + " (deleted)"}, |
| + "90": {db + ".other"}, |
| + } { |
| + dir := filepath.Join(root, pid, "fd") |
| + if err := os.MkdirAll(dir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + for n, target := range targets { |
| + if err := os.Symlink(target, filepath.Join(dir, strconv.Itoa(n))); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + return root, db |
| +} |
| + |
| +func TestFDHolders(t *testing.T) { |
| + root, db := procFixture(t) |
| + got, err := FDHolders(root, []string{filepath.Join(filepath.Dir(db), ".", "data.db"), db + "-wal", db + "-shm"}) |
| + if err != nil || !reflect.DeepEqual(got, []int{3, 20}) { |
| + t.Fatal(got, err) |
| + } |
| + if _, err := FDHolders(root, []string{"relative.db"}); !errors.Is(err, ErrInvalidInput) { |
| + t.Fatal(err) |
| + } |
| + if _, err := FDHolders(root, nil); !errors.Is(err, ErrInvalidInput) { |
| + t.Fatal(err) |
| + } |
| + if got, err := FDHolders(filepath.Join(root, "missing"), []string{db}); !errors.Is(err, ErrIncomplete) || got != nil { |
| + t.Fatal(got, err) |
| + } |
| +} |
| + |
| +func TestFDScanRefusesIncompleteReads(t *testing.T) { |
| + root, db := procFixture(t) |
| + for _, where := range []string{"root", "fd-directory", "readlink"} { |
| + t.Run(where, func(t *testing.T) { |
| + ops := realFDOps() |
| + readDir, readlink := ops.readDir, ops.readlink |
| + ops.readDir = func(p string) ([]os.DirEntry, error) { |
| + if (where == "root" && p == root) || (where == "fd-directory" && p == filepath.Join(root, "20", "fd")) { |
| + return nil, syscall.EACCES |
| + } |
| + return readDir(p) |
| + } |
| + ops.readlink = func(p string) (string, error) { |
| + if where == "readlink" && p == filepath.Join(root, "20", "fd", "0") { |
| + return "", syscall.EACCES |
| + } |
| + return readlink(p) |
| + } |
| + got, err := fdHolders(t.Context(), root, []string{db}, ops) |
| + if !errors.Is(err, ErrIncomplete) || !errors.Is(err, syscall.EACCES) || got != nil { |
| + t.Fatalf("partial success: %v %v", got, err) |
| + } |
| + }) |
| + } |
| + ops := realFDOps() |
| + readDir := ops.readDir |
| + ops.readDir = func(p string) ([]os.DirEntry, error) { |
| + if p == filepath.Join(root, "20", "fd") { |
| + return nil, os.ErrNotExist |
| + } |
| + return readDir(p) |
| + } |
| + if _, err := fdHolders(t.Context(), root, []string{db}, ops); !errors.Is(err, ErrIncomplete) { |
| + t.Fatal("live PID with missing fd directory", err) |
| + } |
| + stat := ops.stat |
| + ops.stat = func(p string) (os.FileInfo, error) { |
| + if p == filepath.Join(root, "20") { |
| + return nil, os.ErrNotExist |
| + } |
| + return stat(p) |
| + } |
| + got, err := fdHolders(t.Context(), root, []string{db}, ops) |
| + if err != nil || !reflect.DeepEqual(got, []int{3}) { |
| + t.Fatal("vanished process", got, err) |
| + } |
| + ops = realFDOps() |
| + readlink := ops.readlink |
| + ops.readlink = func(p string) (string, error) { |
| + if p == filepath.Join(root, "20", "fd", "0") { |
| + return "", os.ErrNotExist |
| + } |
| + return readlink(p) |
| + } |
| + if got, err := fdHolders(t.Context(), root, []string{db}, ops); err != nil || !reflect.DeepEqual(got, []int{3}) { |
| + t.Fatal("closed fd", got, err) |
| + } |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + cancel() |
| + if _, err := fdHolders(ctx, root, []string{db}, realFDOps()); !errors.Is(err, context.Canceled) { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestFDHoldersRealProc(t *testing.T) { |
| + f, err := os.CreateTemp(t.TempDir(), "open-db-") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer f.Close() |
| + pid := os.Getpid() |
| + // Project the real /proc entry into an otherwise empty scan root. This |
| + // proves real descriptors work without requiring access to other UIDs. |
| + root := t.TempDir() |
| + if err := os.Symlink(filepath.Join("/proc", strconv.Itoa(pid)), filepath.Join(root, strconv.Itoa(pid))); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := FDHolders(root, []string{f.Name()}) |
| + if err != nil || !reflect.DeepEqual(got, []int{pid}) { |
| + t.Fatal(got, err) |
| + } |
| + got, err = FDHolders("/proc", []string{f.Name()}) |
| + if errors.Is(err, ErrIncomplete) { |
| + t.Log("whole-machine scan correctly refused; real self descriptor witness passed") |
| + return |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, holder := range got { |
| + if holder == pid { |
| + return |
| + } |
| + } |
| + t.Fatal("real /proc scan missed own descriptor", got) |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the failing tests** |
| + |
| +Run: `go test ./internal/stopevidence -run '^TestFD' -count=1` |
| + |
| +Expected: build failure naming `FDHolders`, `fdHolders` and the new errors. |
| + |
| +- [ ] **Step 3: Write `fds.go`** |
| + |
| +```go |
| +package stopevidence |
| + |
| +import "errors" |
| + |
| +var ( |
| + ErrIncomplete = errors.New("stopevidence: incomplete descriptor scan") |
| + ErrUnsupported = errors.New("stopevidence: unsupported platform") |
| + ErrInvalidInput = errors.New("stopevidence: invalid input") |
| +) |
| +``` |
| + |
| +- [ ] **Step 4: Write `fds_linux.go`** |
| + |
| +```go |
| +//go:build linux |
| + |
| +package stopevidence |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strconv" |
| + "strings" |
| +) |
| + |
| +type fdOps struct { |
| + readDir func(string) ([]os.DirEntry, error) |
| + readlink func(string) (string, error) |
| + stat func(string) (os.FileInfo, error) |
| +} |
| + |
| +func realFDOps() fdOps { return fdOps{os.ReadDir, os.Readlink, os.Stat} } |
| + |
| +func FDHolders(procRoot string, paths []string) ([]int, error) { |
| + return fdHolders(context.Background(), procRoot, paths, realFDOps()) |
| +} |
| + |
| +func fdHolders(ctx context.Context, procRoot string, paths []string, ops fdOps) ([]int, error) { |
| + if ctx == nil || !filepath.IsAbs(procRoot) || len(paths) == 0 { |
| + return nil, ErrInvalidInput |
| + } |
| + wanted := make(map[string]bool, len(paths)) |
| + for _, path := range paths { |
| + if !filepath.IsAbs(path) { |
| + return nil, ErrInvalidInput |
| + } |
| + wanted[filepath.Clean(path)] = true |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return nil, err |
| + } |
| + entries, err := ops.readDir(procRoot) |
| + if err != nil { |
| + return nil, fmt.Errorf("%w: %w", ErrIncomplete, err) |
| + } |
| + var holders []int |
| + for _, entry := range entries { |
| + if err := ctx.Err(); err != nil { |
| + return nil, err |
| + } |
| + pid, err := strconv.Atoi(entry.Name()) |
| + if err != nil || pid <= 0 { |
| + continue |
| + } |
| + pidDir := filepath.Join(procRoot, entry.Name()) |
| + fds, err := ops.readDir(filepath.Join(pidDir, "fd")) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + if _, gone := ops.stat(pidDir); errors.Is(gone, os.ErrNotExist) { |
| + continue |
| + } |
| + } |
| + return nil, fmt.Errorf("%w: pid %d: %w", ErrIncomplete, pid, err) |
| + } |
| + found := false |
| + for _, fd := range fds { |
| + if err := ctx.Err(); err != nil { |
| + return nil, err |
| + } |
| + target, err := ops.readlink(filepath.Join(pidDir, "fd", fd.Name())) |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue |
| + } |
| + if err != nil { |
| + return nil, fmt.Errorf("%w: pid %d fd %s: %w", ErrIncomplete, pid, fd.Name(), err) |
| + } |
| + if !filepath.IsAbs(target) { |
| + continue |
| + } // sockets, pipes and anon_inode |
| + if wanted[filepath.Clean(target)] || wanted[filepath.Clean(strings.TrimSuffix(target, " (deleted)"))] { |
| + found = true |
| + } |
| + } |
| + if found { |
| + holders = append(holders, pid) |
| + } |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return nil, err |
| + } |
| + sort.Ints(holders) |
| + return holders, nil |
| +} |
| + |
| +func scanFDs(ctx context.Context, root string, paths []string) ([]int, error) { |
| + return fdHolders(ctx, root, paths, realFDOps()) |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Write `fds_other.go` and `fds_other_test.go`** |
| + |
| +`fds_other.go`: |
| + |
| +```go |
| +//go:build !linux |
| + |
| +package stopevidence |
| + |
| +import "context" |
| + |
| +func FDHolders(string, []string) ([]int, error) { return nil, ErrUnsupported } |
| +func scanFDs(context.Context, string, []string) ([]int, error) { return nil, ErrUnsupported } |
| +``` |
| + |
| +`fds_other_test.go`: |
| + |
| +```go |
| +//go:build !linux |
| + |
| +package stopevidence |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| +) |
| + |
| +func TestFDHoldersUnsupported(t *testing.T) { |
| + if got, err := FDHolders("/proc", []string{"/data/a.db"}); got != nil || !errors.Is(err, ErrUnsupported) { |
| + t.Fatal(got, err) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 6: Run the green tests** |
| + |
| +Run: `gofmt -w internal/stopevidence && go test -race ./internal/stopevidence -run '^TestFD' -count=1` |
| + |
| +Expected: `ok`, including the real descriptor witness. An unreadable real `/proc` produces `ErrIncomplete`; tests never reinterpret that result as “no holders”. Privilege, proc mount visibility and a stable closed fence are Plan 6's collector preconditions. Path matching does not resolve arbitrary hard-link aliases; Plan 6 must enumerate the actual DB paths used by the host from its inventory, and strict containment supplies the independent complete-process-tree condition. |
| + |
| +--- |
| + |
| +### Task 6: Assemble stop evidence without granting execution authority |
| + |
| +**Files:** |
| +- Create: `internal/stopevidence/evidence.go`, `evidence_linux_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `Inputs`, `Evidence`, `Collect(ctx, in) (Evidence, error)`, `Cleanup(ctx, c *cgroup.Cleanup) error`. |
| +- `Inputs.Host` is a string; `Generation` is the numeric generation passed to `FenceAndStop`, not the string `Holder.Generation`. |
| +- `DBPaths` names the base database files. `Collect` expands each into DB, `-wal` and `-shm`, deduplicates and records all checked paths. It refuses an empty list. Plan 6 supplies every database from the host inventory. |
| +- `Fence` is the real `*activator.StopEvidence`. Current dependencies permit `stopevidence → activator → cgroup`; neither activator nor cgroup imports stopevidence. Keep this direction. If Plan 6 needs the reverse import, replace the field with a minimal `interface { Matches(string, uint64) bool }` there, with typed-nil handling and unchanged proof tests. No such change is needed now. |
| + |
| +- [ ] **Step 1: Write `evidence_linux_test.go`** |
| + |
| +These tests obtain a real fence token by waking and stopping the activator through public APIs. A `StopEvidence{}` literal is not valid evidence; its fields are private and `Matches` correctly refuses it. The fake backend below runs no process and performs no network I/O. |
| + |
| +```go |
| +//go:build linux |
| + |
| +package stopevidence |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +type evidenceProc struct{ exited atomic.Bool } |
| + |
| +func (p *evidenceProc) Stop(context.Context) error { p.exited.Store(true); return nil } |
| +func (p *evidenceProc) Exited() bool { return p.exited.Load() } |
| + |
| +type evidenceRep struct{} |
| + |
| +func (evidenceRep) Err() error { return nil } |
| +func (evidenceRep) Close(context.Context) error { return nil } |
| + |
| +type evidenceLease struct{} |
| + |
| +func (evidenceLease) Release(context.Context) error { return nil } |
| +func (evidenceLease) Replicate(context.Context, string, time.Duration) (activator.Rep, error) { |
| + return evidenceRep{}, nil |
| +} |
| + |
| +type evidenceBackend struct{} |
| + |
| +func (evidenceBackend) Acquire(context.Context, registry.Route) (activator.Lease, error) { |
| + return evidenceLease{}, nil |
| +} |
| +func (evidenceBackend) Restore(context.Context, registry.Route) (bool, error) { return false, nil } |
| +func (evidenceBackend) Seed(context.Context, registry.Route) error { return nil } |
| +func (evidenceBackend) Start(context.Context, registry.Route) (activator.Proc, string, error) { |
| + return &evidenceProc{}, "test-build", nil |
| +} |
| +func (evidenceBackend) NextDue(context.Context, registry.Route) (int64, error) { return 0, nil } |
| +func (evidenceBackend) HasSidecar(registry.Route) bool { return false } |
| +func (evidenceBackend) StartSidecar(context.Context, registry.Route) (activator.Proc, error) { |
| + return nil, errors.New("unexpected sidecar") |
| +} |
| +func (evidenceBackend) StartWorker(context.Context, registry.Route, map[string]string) (activator.Proc, string, error) { |
| + return nil, "", errors.New("unexpected worker") |
| +} |
| + |
| +func fenceFixture(t *testing.T, host string, generation uint64) *activator.StopEvidence { |
| + t.Helper() |
| + dir := t.TempDir() |
| + reg, err := registry.Open(filepath.Join(dir, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + a := activator.New(activator.Config{Bucket: "test", Idle: time.Hour}, evidenceBackend{}, reg) |
| + t.Cleanup(func() { |
| + ctx, cancel := context.WithTimeout(context.Background(), time.Second) |
| + defer cancel() |
| + a.Shutdown(ctx) |
| + }) |
| + rt := registry.Route{Host: host, Kind: "instance", Hibernate: true, Socket: filepath.Join(dir, "app.sock"), DBPath: filepath.Join(dir, "data.db")} |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) |
| + defer cancel() |
| + called := false |
| + w := httptest.NewRecorder() |
| + r := httptest.NewRequest(http.MethodGet, "http://"+host+"/", nil).WithContext(ctx) |
| + if !a.ServeHTTP(w, r, rt, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true; w.WriteHeader(http.StatusNoContent) })) || !called { |
| + t.Fatalf("wake failed: %d %s", w.Code, w.Body.String()) |
| + } |
| + proof, err := a.FenceAndStop(ctx, host, generation) |
| + if err != nil || !proof.Matches(host, generation) { |
| + t.Fatal("fence proof", err) |
| + } |
| + return proof |
| +} |
| + |
| +type evidenceGroup struct { |
| + populated bool |
| + members []int |
| + populationErr, membersErr error |
| +} |
| + |
| +func (g *evidenceGroup) Populated() (bool, error) { return g.populated, g.populationErr } |
| +func (g *evidenceGroup) Members() ([]int, error) { return g.members, g.membersErr } |
| + |
| +func TestCollectRequiresEveryCondition(t *testing.T) { |
| + host := "shop.example" |
| + proof := fenceFixture(t, host, 7) |
| + failure := errors.New("read failed") |
| + for _, tc := range []struct { |
| + name string |
| + change func(*testing.T, *Inputs, *evidenceGroup) |
| + want error |
| + }{ |
| + {"success", func(t *testing.T, in *Inputs, g *evidenceGroup) {}, nil}, |
| + {"no-fence", func(t *testing.T, in *Inputs, g *evidenceGroup) { in.Fence = nil }, ErrFence}, |
| + {"empty-fence", func(t *testing.T, in *Inputs, g *evidenceGroup) { in.Fence = &activator.StopEvidence{} }, ErrFence}, |
| + {"wrong-host", func(t *testing.T, in *Inputs, g *evidenceGroup) { in.Host = "other.example" }, ErrFence}, |
| + {"wrong-generation", func(t *testing.T, in *Inputs, g *evidenceGroup) { in.Generation = 8 }, ErrFence}, |
| + {"no-generation", func(t *testing.T, in *Inputs, g *evidenceGroup) { in.Generation = 0 }, ErrInvalidInput}, |
| + {"no-group", func(t *testing.T, in *Inputs, g *evidenceGroup) { in.Cgroup = nil }, ErrNoCgroupEvidence}, |
| + {"population", func(t *testing.T, in *Inputs, g *evidenceGroup) { g.populated = true }, ErrPopulated}, |
| + {"members", func(t *testing.T, in *Inputs, g *evidenceGroup) { g.members = []int{55} }, ErrMembers}, |
| + {"population-error", func(t *testing.T, in *Inputs, g *evidenceGroup) { g.populationErr = failure }, failure}, |
| + {"members-error", func(t *testing.T, in *Inputs, g *evidenceGroup) { g.membersErr = failure }, failure}, |
| + {"replicator-open", func(t *testing.T, in *Inputs, g *evidenceGroup) { in.ReplicatorsClosed = false }, ErrReplicatorsOpen}, |
| + {"no-paths", func(t *testing.T, in *Inputs, g *evidenceGroup) { in.DBPaths = nil }, ErrInvalidInput}, |
| + {"relative-path", func(t *testing.T, in *Inputs, g *evidenceGroup) { in.DBPaths = []string{"data.db"} }, ErrInvalidInput}, |
| + {"proc-read-error", func(t *testing.T, in *Inputs, g *evidenceGroup) { in.ProcRoot = filepath.Join(in.ProcRoot, "missing") }, ErrIncomplete}, |
| + {"db-holder", func(t *testing.T, in *Inputs, g *evidenceGroup) { addHolder(t, in, "") }, ErrFDHolders}, |
| + {"wal-holder", func(t *testing.T, in *Inputs, g *evidenceGroup) { addHolder(t, in, "-wal") }, ErrFDHolders}, |
| + {"shm-holder", func(t *testing.T, in *Inputs, g *evidenceGroup) { addHolder(t, in, "-shm") }, ErrFDHolders}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + g := &evidenceGroup{} |
| + in := Inputs{Host: host, Generation: 7, Fence: proof, Cgroup: g, DBPaths: []string{filepath.Join(t.TempDir(), "data.db")}, ProcRoot: t.TempDir(), ReplicatorsClosed: true} |
| + tc.change(t, &in, g) |
| + e, err := Collect(t.Context(), in) |
| + if tc.want != nil { |
| + if !errors.Is(err, tc.want) || !reflect.DeepEqual(e, Evidence{}) { |
| + t.Fatalf("evidence on failure: %+v %v", e, err) |
| + } |
| + return |
| + } |
| + if err != nil || e.Host != host || e.Generation != 7 || !e.FenceClosed || !e.SpawnAttemptsDrained || !e.CgroupUnpopulated || !e.CgroupMembersEmpty || !e.ReplicatorsClosed || !e.FDScanComplete || e.CheckedAt.IsZero() || len(e.CheckedPaths) != 3 { |
| + t.Fatalf("incomplete evidence: %+v %v", e, err) |
| + } |
| + g.populated = true |
| + if _, err := Collect(t.Context(), in); !errors.Is(err, ErrPopulated) { |
| + t.Fatal("cached evidence", err) |
| + } |
| + }) |
| + } |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + cancel() |
| + if e, err := Collect(ctx, Inputs{}); !errors.Is(err, context.Canceled) || !reflect.DeepEqual(e, Evidence{}) { |
| + t.Fatal(e, err) |
| + } |
| + if err := Cleanup(t.Context(), nil); !errors.Is(err, ErrInvalidInput) { |
| + t.Fatal(err) |
| + } |
| + if err := Cleanup(t.Context(), &cgroup.Cleanup{}); !errors.Is(err, cgroup.ErrStrictState) { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func addHolder(t *testing.T, in *Inputs, suffix string) { |
| + t.Helper() |
| + dir := filepath.Join(in.ProcRoot, "55", "fd") |
| + if err := os.MkdirAll(dir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(in.DBPaths[0]+suffix, filepath.Join(dir, "4")); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the failing tests** |
| + |
| +Run: `go test ./internal/stopevidence -run '^TestCollect' -count=1` |
| + |
| +Expected: build failure naming `Inputs`, `Collect`, `Evidence` and evidence errors. |
| + |
| +- [ ] **Step 3: Write `evidence.go`** |
| + |
| +```go |
| +package stopevidence |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "path/filepath" |
| + "sort" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/controlname" |
| +) |
| + |
| +var ( |
| + ErrFence = errors.New("stopevidence: matching stop fence required") |
| + ErrNoCgroupEvidence = errors.New("stopevidence: cgroup observation required") |
| + ErrPopulated = errors.New("stopevidence: cgroup populated") |
| + ErrMembers = errors.New("stopevidence: cgroup has members") |
| + ErrReplicatorsOpen = errors.New("stopevidence: replicators not closed") |
| + ErrFDHolders = errors.New("stopevidence: database descriptors still held") |
| +) |
| + |
| +type Inputs struct { |
| + Host string |
| + Generation uint64 |
| + Fence *activator.StopEvidence |
| + Cgroup interface { |
| + Populated() (bool, error) |
| + Members() ([]int, error) |
| + } |
| + DBPaths []string |
| + ProcRoot string |
| + ReplicatorsClosed bool |
| +} |
| + |
| +type Evidence struct { |
| + Host string |
| + Generation uint64 |
| + CheckedAt time.Time |
| + FenceClosed bool |
| + SpawnAttemptsDrained bool |
| + CgroupUnpopulated bool |
| + CgroupMembersEmpty bool |
| + ReplicatorsClosed bool |
| + FDScanComplete bool |
| + CheckedPaths []string |
| + ProcRoot string |
| +} |
| + |
| +func Collect(ctx context.Context, in Inputs) (Evidence, error) { |
| + if ctx == nil { |
| + return Evidence{}, ErrInvalidInput |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return Evidence{}, err |
| + } |
| + if !controlname.ValidHostShape(in.Host) || in.Generation == 0 { |
| + return Evidence{}, ErrInvalidInput |
| + } |
| + if in.Fence == nil || !in.Fence.Matches(in.Host, in.Generation) { |
| + return Evidence{}, ErrFence |
| + } |
| + if in.Cgroup == nil { |
| + return Evidence{}, ErrNoCgroupEvidence |
| + } |
| + if !in.ReplicatorsClosed { |
| + return Evidence{}, ErrReplicatorsOpen |
| + } |
| + if len(in.DBPaths) == 0 { |
| + return Evidence{}, ErrInvalidInput |
| + } |
| + seen := map[string]bool{} |
| + for _, db := range in.DBPaths { |
| + if !filepath.IsAbs(db) { |
| + return Evidence{}, ErrInvalidInput |
| + } |
| + db = filepath.Clean(db) |
| + seen[db], seen[db+"-wal"], seen[db+"-shm"] = true, true, true |
| + } |
| + paths := make([]string, 0, len(seen)) |
| + for path := range seen { |
| + paths = append(paths, path) |
| + } |
| + sort.Strings(paths) |
| + populated, err := in.Cgroup.Populated() |
| + if err != nil { |
| + return Evidence{}, err |
| + } |
| + if populated { |
| + return Evidence{}, ErrPopulated |
| + } |
| + members, err := in.Cgroup.Members() |
| + if err != nil { |
| + return Evidence{}, err |
| + } |
| + if len(members) != 0 { |
| + return Evidence{}, ErrMembers |
| + } |
| + root := in.ProcRoot |
| + if root == "" { |
| + root = "/proc" |
| + } |
| + holders, err := scanFDs(ctx, root, paths) |
| + if err != nil { |
| + return Evidence{}, err |
| + } |
| + if len(holders) != 0 { |
| + return Evidence{}, ErrFDHolders |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return Evidence{}, err |
| + } |
| + return Evidence{ |
| + Host: in.Host, Generation: in.Generation, CheckedAt: time.Now().UTC(), |
| + FenceClosed: true, SpawnAttemptsDrained: true, |
| + CgroupUnpopulated: true, CgroupMembersEmpty: true, |
| + ReplicatorsClosed: true, FDScanComplete: true, |
| + CheckedPaths: paths, ProcRoot: root, |
| + }, nil |
| +} |
| + |
| +func Cleanup(ctx context.Context, c *cgroup.Cleanup) error { |
| + if c == nil { |
| + return ErrInvalidInput |
| + } |
| + return c.KillAndEmpty(ctx) |
| +} |
| +``` |
| + |
| +Every unsuccessful call returns `Evidence{}`. This value is a local observation, not a serialized authorization certificate. `FenceAndStop` supplies closed-admission/drained-spawn proof; the cgroup, membership and FD scan are reconstructed on every call. A retained activator token by itself cannot certify a process tree or a restarted agent. Plan 6 must keep the matching fence closed through collection and use, because `StopEvidence.Matches` does not notice a later `LiftFence`. It must also account for legacy escaped children and every replicator, including any outside activator ownership. No error path manufactures an activator proof. |
| + |
| +- [ ] **Step 4: Run the evidence tests** |
| + |
| +Run: `gofmt -w internal/stopevidence && go test -race ./internal/stopevidence ./internal/cgroup ./internal/activator -count=1` |
| + |
| +Expected: all tests pass. The successful collector test uses a genuine `FenceAndStop` result; independent population, membership, descriptor and replica failures each prevent evidence. The second collection after changing cgroup state fails, proving evidence is not cached. |
| + |
| +--- |
| + |
| +### Task 7: Provision the shared lock directory |
| + |
| +**Files:** |
| +- Modify: `internal/host/bootstrap.go`, `internal/host/units_isolation_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `/run/carlos/hostguard`, mode `0770`, owner/group `serviceUser` (`carlos`). |
| +- Consumes: existing `tmpfilesConf` and `TestTmpfilesCreatesTheInstanceTrees`. |
| + |
| +- [ ] **Step 1: Add one expected line to the existing test** |
| + |
| +In `TestTmpfilesCreatesTheInstanceTrees`, add this exact string to the `want` slice, immediately after the `/run/carlos-control` expectation. Imports remain unchanged (`strings` and `testing` are already imported). |
| + |
| +```go |
| + "d /run/carlos/hostguard 0770 carlos carlos -", |
| +``` |
| + |
| +- [ ] **Step 2: Run the failing test** |
| + |
| +Run: `go test ./internal/host -run '^TestTmpfilesCreatesTheInstanceTrees$' -count=1` |
| + |
| +Expected: failure saying `tmpfilesConf lacks "d /run/carlos/hostguard 0770 carlos carlos -"`. |
| + |
| +- [ ] **Step 3: Add the one tmpfiles line** |
| + |
| +In `tmpfilesConf`, insert this exact concatenated line immediately after the existing `/run/carlos/apps` line: |
| + |
| +```go |
| + "d /run/carlos/hostguard 0770 " + serviceUser + " " + serviceUser + " -\n" + |
| +``` |
| + |
| +Root can open the service-owned directory. The ownership helper in Task 2 ensures that a lock first created by root also remains usable by `carlos`. Existing socket-consumer group grants do not extend to host guards. |
| + |
| +- [ ] **Step 4: Run the green test** |
| + |
| +Run: `gofmt -w internal/host/bootstrap.go internal/host/units_isolation_test.go && go test ./internal/host -run '^TestTmpfilesCreatesTheInstanceTrees$' -count=1` |
| + |
| +Expected: `ok`. This is the only production configuration change; no runtime path calls a guard or collector yet. |
| + |
| +--- |
| + |
| +### Task 8: The repository gate |
| + |
| +**Files:** no new files. Fix only defects in the preceding tasks; do not widen the production wiring scope. |
| + |
| +**Interfaces:** verifies the complete branch on Linux and build compatibility for workstation targets. |
| + |
| +- [ ] **Step 1: Run the focused checks** |
| + |
| +```bash |
| +go test -race ./internal/hostguard ./internal/stopevidence ./internal/cgroup -count=1 |
| +go test ./internal/host -run '^TestTmpfilesCreatesTheInstanceTrees$' -count=1 |
| +``` |
| + |
| +Expected: all packages report `ok`. The flock test re-execs the test binary, never `carlos`; no operator credentials or production endpoints are used. |
| + |
| +- [ ] **Step 2: Run the full gate with the default cache** |
| + |
| +```bash |
| +go build ./... |
| +go vet ./... |
| +go test ./... -count=1 |
| +gofmt -l . |
| +GOOS=darwin go build ./... |
| +GOOS=windows go build ./... |
| +GOOS=linux GOARCH=arm64 go build ./... |
| +``` |
| + |
| +Expected: all commands exit zero; build and vet are silent; tests pass; `gofmt -l .` prints nothing. Cross-builds include the new refusing stubs. If the default cache is read-only, first verify `$TMPDIR` is on disk and outside `/tmp`, then export `GOCACHE=$TMPDIR/gocache`. Do not create a per-task cache when the default is usable. A sandbox-only failure is recorded as unverified, not as a passing check. |
| + |
| +- [ ] **Step 3: Review scope and authority** |
| + |
| +```bash |
| +git diff --check |
| +git diff --stat |
| +rg -n 'hostguard|stopevidence|OpenCleanup' internal/activator internal/host internal/appconfig |
| +``` |
| + |
| +Expected: `git diff --check` is silent. The only existing production file changed is `internal/host/bootstrap.go`, by one tmpfiles line. The search may match the tmpfiles line and its test; there are no guard/collector call sites in production paths. Config sync lives in `internal/appconfig/syncexec.go`. Review the final diff with the repository's authority-review model policy; use a supported equivalent explicitly if the named model is unavailable. |
| + |
| +- [ ] **Step 4: Record the gate and review result for the implementation branch** |
| + |
| +Keep all implementation iterations on `feat/hostclaim-boxguard`. The later landing follows AGENTS.md: one squashed change commit on the described amadan branch, green CI on that exact tip, and `amadan branch merge` with `-expect`. This planning task makes no commit, push, merge or infrastructure change. |
| + |
| +--- |
| + |
| +## Rollout note |
| + |
| +Plan 3 adds inert libraries and the shared runtime directory. It does not change any current writer, spawn path, timer, agent loop, adopt service or config-sync service. Installing the tmpfiles entry does not enable the claim protocol. Plan 6 wires capture at work selection and guarded admission at every host-specific write, changes every lifecycle spawn to `PrepareStrict`, and chooses the evidence collector's placement and privileges. In particular, an agent running as `carlos` usually cannot read other UIDs' `/proc/*/fd`; this library returns `ErrIncomplete` and produces no evidence in that situation. No claim is revoked on that basis. Plans 5–7 remain behind `CARLOS_HOST_CLAIM` until Plan 8 establishes the protocol floor, as in Plan 1's roadmap. |
| + |
| +## Self-review |
| + |
| +| Binding ruling or requirement | Covered here | |
| +|---|---| |
| +| R1, libraries/tests plus one tmpfiles line; no production callers | Architecture, Tasks 1–7, Task 8 scope check, rollout | |
| +| R2, branch from Plan 1 | Task 1: `feat/hostclaim-boxguard` from `feat/hostclaim-library` | |
| +| R3, local schema v1, strict decoding, valid host, atomic write | Task 1: unknown/trailing/malformed fields refused; same-directory temp, file fsync, rename, directory fsync; failed-rename seam | |
| +| R3, exclusive shared flock, cancellation, 0660, non-Unix refusal | Tasks 2–3; root preserves service ownership, never unlinks lock inode | |
| +| R3, exact claim adapter and fresh read inside lock | Task 2 `StoreReader`, `Guard.fresh`; Task 3 blocked-lock observation test | |
| +| R3, capture at selection and every `With` refusal | Tasks 1–3: distinct absence/incarnation/inactive/stamp errors and callback checks | |
| +| R3, initialization/new installation, authorized participant | Task 2 activation, lost-record recovery, same-incarnation no-op, replacement incarnation, retired/old stamp refusals | |
| +| R3, installation retirement without artifact parking | Task 2; no retire-artifacts API exists | |
| +| R3, fence-stamp witness and history retention | Task 2 deterministic UK→US→UK test; 40 activations test keeps newest 32 and refuses trimmed stamp reuse | |
| +| R3, process exclusion/deadline/panic/error | Task 3 re-exec test and callback lifetime tests | |
| +| R4, existing cgroup reopen, membership, kill/drain, close, no spawn | Task 4; private pinned handle, no `Start`, no `Mkdir`, no empty-group requirement | |
| +| R4, missing group vs no delegation | Task 4 distinct `ErrNoCgroup`/`ErrNoDelegation`; no successful empty fallback | |
| +| R4, reuse strict populated parser | Task 4 calls `strictPopulation`; membership scans the entire file without the events reader's 4096-byte cap | |
| +| R4, complete `/proc` scan, process disappearance, EACCES | Task 5; injected errors, fake links and real open-file witness | |
| +| R4, collect all conditions, exact fence binding, zero evidence on error | Task 6 concrete activator proof, independent cgroup/member/FD/replicator checks, repeated system reads | |
| +| R4, cleanup entry point | Task 6 delegates only to `Cleanup.KillAndEmpty` | |
| +| R5, collector placement and privileges deferred | Task 5, Task 6 and rollout; incomplete visibility refuses | |
| +| R6, platform stubs and cross-builds | Tasks 2, 4, 5 and 8 | |
| +| R7, one tmpfiles line and existing test | Task 7 | |
| +| R8, Go cache | Global Constraints and Task 8 | |
| +| §2, holder stamp/incarnation/op.exec | Tasks 1–2; no changes to claim generation, sessions or writer admissions | |
| +| C4, incarnation/operation/held-input freshness | Task 2 required callback under flock; Plan 6 supplies each concrete operation/phase/held-identity check | |
| +| C6, failed reads never authorize action | Tasks 1, 2, 4, 5, 6 and refusal tests | |
| +| §6.1, closed fence and drained spawn attempts | Task 6 requires matching `FenceAndStop` evidence; no token synthesis from an empty cgroup | |
| +| §6.1, complete process tree, membership, replicas, DB/WAL/SHM | Tasks 4–6 | |
| +| §6.1, no missing-cgroup shortcut or delegation fallback | Task 4; collector cannot succeed without a cgroup observation | |
| +| §6.1, legacy best-effort children and restart reconstruction | Tasks 4–6 provide reopen and system-state checks; Plan 6 owns legacy cleanup orchestration and re-establishing fence proof after restart | |
| +| §6.1, strict containment and held-input probes | Existing `PrepareStrict` remains unchanged; Plan 6 wires every spawn and supplies the held-input helpers called `RenderHeld`/`MaterializeHeld` in the spec | |
| +| §6.3, inventory and tagged artifacts | Verified `hostclaim.InventoryEntry.Paths` is `map[string]uint64`; read-only dependency here; Plan 4 owns parking/journal/acknowledgement | |
| +| §6.4, all host-specific writers and shared process lock | Tasks 2–3 provide the guard; Plan 6 wraps config/env/token/symlink/timer writers | |
| +| §6.4, two retirements and active/retired invariant | Tasks 1–2 validate disjointness; only activation/installation retirement move stamps; Plan 4 never changes stamps during retire-artifacts | |
| +| §6.4, ordered installations and paused writer | Tasks 2–3 compare captured stamps, retain latest retired maximum, test successful round trip and blocked fresh read | |
| + |
| +Deliberately left to **Plan 4**: inventory discovery/update and per-artifact stamp tags; flat and nested layout retirement; `RENAME_NOREPLACE`, parking journals, fsync of parked directories and acknowledgements; composition of stamp retirement with all-artifact parking. Deliberately left to **Plan 6**: guard integration at work selection and every write; concrete operation/held-input checks; lifecycle strict spawns; probe rendering/materialization; re-establishing admission fences after restart; maintaining fence closure through collection and claim revocation; evidence collector privileges and complete proc visibility; cleanup of legacy escaped processes and all replicators. No artifact operation, claim CAS or authority transfer is hidden in these libraries. |
| + |
| +Plan-author verification: all complete Go blocks passed `gofmt` parsing. The exact code blocks were compiled through an anonymous-memory Go overlay using the real Plan 1 package, without creating implementation files. Hostguard and stopevidence tests, plus the new cgroup cleanup tests, passed with the race detector; the three packages also built for Darwin and Windows. The Linux-root ownership-transfer test was skipped on this unprivileged runner. Vet and the full repository gate remain implementation-branch checks; Go's vet runner cannot chdir into overlay-only package directories, so the overlay test binaries were compiled with `-vet=off`. The default cache was read-only, so verification used the existing on-disk `$TMPDIR/gocache`. A separate `gpt-6-astra` review (the available authority-review model; Opus was unavailable) found the cleanup mutex deadline and root-test permission issues; both were corrected and the follow-up review found no remaining blocker. |