carlos / platform Public

hostguard: cross-process exclusion, cancellation and lock lifetime tests (claim Plan 3 Task 3)

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 4c12aec5f696a869c770443e7cb934de87f8a554 parent 992dd4f
2 files changed, +358 −0
  • internal/hostguard/flock_other_test.go +19 −0
  • internal/hostguard/flock_unix_test.go +339 −0
diff --git a/internal/hostguard/flock_other_test.go b/internal/hostguard/flock_other_test.go
new file mode 100644
index 000000000..32ae37926
--- /dev/null
+++ b/internal/hostguard/flock_other_test.go
@@ -0,0 +1,19 @@
+//go:build !unix
+
+package hostguard
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+func TestUnsupportedLock(t *testing.T) {
+ if _, err := Capture(t.TempDir(), "shop.example"); !errors.Is(err, ErrUnsupported) {
+ t.Fatal(err)
+ }
+ 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)
+ }
+}
diff --git a/internal/hostguard/flock_unix_test.go b/internal/hostguard/flock_unix_test.go
new file mode 100644
index 000000000..e8b5deff5
--- /dev/null
+++ b/internal/hostguard/flock_unix_test.go
@@ -0,0 +1,339 @@
+//go:build unix
+
+package hostguard
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sync/atomic"
+ "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 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 _, path := range []string{
+ filepath.Join(f.g.LockDir, f.obs.Host+".lock"),
+ filepath.Join(f.g.DataDir, "hostguard", f.obs.Host+".json"),
+ } {
+ info, err := os.Stat(path)
+ if err != nil || info.Mode().Perm() != 0660 {
+ t.Fatalf("mode: %v %v", info, err)
+ }
+ }
+}
+
+func TestGuardLockSymlinkRefused(t *testing.T) {
+ for _, directory := range []bool{false, true} {
+ t.Run(fmt.Sprint(directory), func(t *testing.T) {
+ f := newGuardFixture(t)
+ outside := t.TempDir()
+ target := filepath.Join(outside, f.obs.Host+".lock")
+ if err := os.WriteFile(target, []byte("untouched"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ defer unchangedFile(t, target)()
+ link, destination := filepath.Join(f.g.LockDir, f.obs.Host+".lock"), target
+ if directory {
+ if err := os.Remove(f.g.LockDir); err != nil {
+ t.Fatal(err)
+ }
+ link, destination = f.g.LockDir, outside
+ }
+ if err := os.Symlink(destination, link); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err == nil || f.reads != 0 {
+ t.Fatalf("symlink admitted: %v reads=%d", err, f.reads)
+ }
+ })
+ }
+}
+
+func TestGuardLockRequiresRegularFile(t *testing.T) {
+ f := newGuardFixture(t)
+ path := filepath.Join(f.g.LockDir, f.obs.Host+".lock")
+ if err := unix.Mkfifo(path, 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); !errors.Is(err, ErrUnsafeFile) || f.reads != 0 {
+ t.Fatalf("non-regular lock admitted: %v reads=%d", err, f.reads)
+ }
+ info, err := os.Lstat(path)
+ if err != nil || info.Mode().Perm() != 0600 {
+ t.Fatalf("mode changed before type check: %v %v", info, err)
+ }
+}
+
+func TestGuardDirectoriesRequired(t *testing.T) {
+ for _, which := range []string{"lock", "record"} {
+ t.Run(which, func(t *testing.T) {
+ f := newGuardFixture(t)
+ path := f.g.LockDir
+ if which == "record" {
+ path = filepath.Join(f.g.DataDir, "hostguard")
+ }
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); !errors.Is(err, ErrNotProvisioned) {
+ t.Fatal(err)
+ }
+ if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("directory recreated", err)
+ }
+ })
+ }
+}
+
+func TestGuardRecordSymlinkRefused(t *testing.T) {
+ f := newGuardFixture(t)
+ host := f.obs.Host
+ outside := filepath.Join(t.TempDir(), "record")
+ if err := os.WriteFile(outside, []byte(`{"v":1,"host":"shop.example","incarnation":"inc","active_stamp":1,"retired_stamps":[]}`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ defer unchangedFile(t, outside)()
+ if err := os.Symlink(outside, filepath.Join(f.g.DataDir, "hostguard", host+".json")); 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) },
+ func() error {
+ return f.g.With(t.Context(), host, Captured{Incarnation: "inc", Stamp: 1}, allow, func() error {
+ t.Fatal("symlink admitted writer")
+ return nil
+ })
+ },
+ } {
+ if err := operation(); err == nil || errors.Is(err, ErrNoRecord) {
+ t.Fatal("record symlink not refused", err)
+ }
+ }
+}
+
+func TestGuardDirectorySwap(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)
+ }
+ original := filepath.Join(f.g.DataDir, "hostguard")
+ pinned := filepath.Join(f.g.DataDir, "pinned")
+ outside := t.TempDir()
+ target := filepath.Join(outside, host+".json")
+ if err := os.WriteFile(target, []byte(`{"v":1,"host":"shop.example","incarnation":"inc","active_stamp":99,"retired_stamps":[]}`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ defer unchangedFile(t, target)()
+ f.obs.Claim.Holder.Stamp = 2
+ f.g.Claims = readerFunc(func(context.Context, string) (hostclaim.Observed, error) {
+ // Guard.locked has already opened its record dirfd before the fresh read.
+ if err := os.Rename(original, pinned); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outside, original); err != nil {
+ t.Fatal(err)
+ }
+ return f.obs, nil
+ })
+ if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil {
+ t.Fatal("pinned update failed", err)
+ }
+ fd, err := openDirectory(pinned)
+ if err != nil {
+ t.Fatal(err)
+ }
+ d := &recordDir{fd: fd}
+ defer d.close()
+ r, err := d.read(host)
+ if err != nil || r.ActiveStamp != 2 {
+ t.Fatalf("pinned record: %+v %v", r, err)
+ }
+ entries, err := os.ReadDir(outside)
+ if err != nil || len(entries) != 1 || entries[0].Name() != host+".json" {
+ t.Fatalf("write escaped pinned directory: %v %v", entries, err)
+ }
+ entries, err = os.ReadDir(pinned)
+ if err != nil || len(entries) != 1 || entries[0].Name() != host+".json" {
+ t.Fatalf("temporary file left behind: %v %v", entries, err)
+ }
+ if _, err := Capture(f.g.DataDir, host); err == nil {
+ t.Fatal("new capture followed replacement directory symlink")
+ }
+}