//go:build unix package hostguard import ( "context" "errors" "testing" "time" "github.com/carlosframework/platform/internal/hostclaim" ) func fenceFixture(t *testing.T) (*guardFixture, FenceTarget, Captured) { t.Helper() f := newGuardFixture(t) f.obs.Claim.Holder.Session = 7 f.obs.Claim.Op.Kind = hostclaim.KindLanding f.obs.Claim.Op.ID = "operation-1" f.obs.Claim.Op.Phase = "probing" 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) } target := FenceTarget{FenceID: "fence-1", Host: f.obs.Host, Incarnation: "inc", Stamp: 1, Session: 7, Op: "operation-1", Phase: "probing", Box: "uk", Enrollment: "enrollment-1", BootID: "boot-1", ControllerID: "controller-1", ClaimETag: "one", DirectoryRevision: "directory-9"} return f, target, captured } func TestBeginFenceDeniesOnlyExactCurrentAuthority(t *testing.T) { f, target, captured := fenceFixture(t) bad := target bad.ClaimETag = "stale" if _, err := f.g.BeginFence(t.Context(), bad); !errors.Is(err, ErrFenceTarget) { t.Fatalf("stale begin = %v", err) } if _, err := f.g.BeginFence(t.Context(), target); err != nil { t.Fatal(err) } if _, err := f.g.BeginFence(t.Context(), target); err != nil { // exact replay t.Fatal(err) } retry := target retry.FenceID = "new-id-must-not-replace-durable-id" got, err := f.g.BeginFence(t.Context(), retry) if err != nil || got != target { t.Fatalf("begin retry = %+v, %v; want original %+v", got, err, target) } called := false err = f.g.With(t.Context(), target.Host, captured, allow, func() error { called = true; return nil }) if !errors.Is(err, ErrFenced) || called { t.Fatalf("guard under fence = %v, called=%v", err, called) } f.obs.Claim.Op.Phase = "next-phase" if err := f.g.With(t.Context(), target.Host, captured, allow, func() error { called = true; return nil }); !errors.Is(err, ErrFenced) { t.Fatalf("phase change bypassed fence: %v", err) } // A later writer session is a different authority tuple. The old deny // remains durable for cleanup but cannot deny the successor session. f.obs.Claim.Holder.Session = 8 if err := f.g.With(t.Context(), target.Host, captured, allow, func() error { called = true; return nil }); err != nil || !called { t.Fatalf("new session guard = %v, called=%v", err, called) } } func TestBeginFenceCannotOverwriteUnsettledTarget(t *testing.T) { f, first, _ := fenceFixture(t) if _, err := f.g.BeginFence(t.Context(), first); err != nil { t.Fatal(err) } second := first second.FenceID = "fence-2" second.Phase = "next-phase" second.ClaimETag = "two" second.DirectoryRevision = "directory-10" f.obs.ETag = "two" f.obs.Claim.Op.Phase = "next-phase" if _, err := f.g.BeginFence(t.Context(), second); !errors.Is(err, ErrFenceActive) { t.Fatalf("unsettled overwrite = %v", err) } at := time.Date(2026, 10, 6, 14, 30, 0, 0, time.UTC) if err := f.g.CompleteFence(t.Context(), first, []byte("first receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil { t.Fatal(err) } if _, err := f.g.BeginFence(t.Context(), second); err != nil { t.Fatalf("completed predecessor blocked successor fence: %v", err) } if err := f.g.CleanupWith(t.Context(), first, func(context.Context, FenceReceipt) error { return nil }, func() error { return nil }); err != nil { t.Fatal(err) } r, err := readRecord(f.g.DataDir, first.Host) if err != nil || r.ActiveFence == nil || *r.ActiveFence != second || len(r.FenceJournal) != 1 { t.Fatalf("predecessor cleanup damaged successor fence: %+v, %v", r, err) } } func TestCompleteFenceVerificationFailureAndImmutableReplay(t *testing.T) { f, target, captured := fenceFixture(t) if _, err := f.g.BeginFence(t.Context(), target); err != nil { t.Fatal(err) } failure := errors.New("root rejected evidence") at := time.Date(2026, 10, 6, 14, 0, 0, 0, time.UTC) if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return failure }); !errors.Is(err, failure) { t.Fatalf("failed verification = %v", err) } r, err := readRecord(f.g.DataDir, target.Host) if err != nil || len(r.FenceJournal) != 0 || r.ActiveFence == nil { t.Fatalf("failed verification persisted evidence: %+v, %v", r, err) } verify := func(ctx context.Context, got FenceTarget, _ []byte, _ time.Time) error { // Verification is outside the flock: reacquiring it completes rather // than deadlocking. The installed deny is visible to the nested guard. return f.g.With(ctx, got.Host, captured, allow, func() error { return nil }) } if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), at, verify); !errors.Is(err, ErrFenced) { t.Fatalf("nested verifier = %v", err) } verify = func(context.Context, FenceTarget, []byte, time.Time) error { return nil } if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), at, verify); err != nil { t.Fatal(err) } if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), at, verify); err != nil { t.Fatalf("exact replay = %v", err) } if err := f.g.CompleteFence(t.Context(), target, []byte("changed"), at, verify); !errors.Is(err, ErrReceiptChanged) { t.Fatalf("changed replay = %v", err) } } func TestCleanupUsesJournalDuringControlOutage(t *testing.T) { f, target, _ := fenceFixture(t) if _, err := f.g.BeginFence(t.Context(), target); err != nil { t.Fatal(err) } at := time.Date(2026, 10, 6, 15, 0, 0, 0, time.UTC) if err := f.g.CompleteFence(t.Context(), target, []byte{0, 1, 2, 255}, at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil { t.Fatal(err) } f.err = errors.New("control store unavailable") verified, cleaned := false, false err := f.g.CleanupWith(t.Context(), target, func(_ context.Context, receipt FenceReceipt) error { verified = string(receipt.Bytes) == string([]byte{0, 1, 2, 255}) && receipt.EvidenceAt.Equal(at) return nil }, func() error { cleaned = true; return nil }) if err != nil || !verified || !cleaned { t.Fatalf("cleanup = %v verified=%v cleaned=%v", err, verified, cleaned) } r, err := readRecord(f.g.DataDir, target.Host) if err != nil || r.ActiveFence == nil || len(r.FenceJournal) != 1 { t.Fatalf("cleaned record = %+v, %v", r, err) } f.err = nil called := false if err := f.g.With(t.Context(), target.Host, hostguardCaptured(target), allow, func() error { called = true; return nil }); !errors.Is(err, ErrFenced) || called { t.Fatalf("cleanup reopened fenced session: %v called=%v", err, called) } } func TestCleanupOldFencePreservesSuccessor(t *testing.T) { f, target, _ := fenceFixture(t) if _, err := f.g.BeginFence(t.Context(), target); err != nil { t.Fatal(err) } at := time.Date(2026, 10, 6, 16, 0, 0, 0, time.UTC) if err := f.g.CompleteFence(t.Context(), target, []byte("root receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil { t.Fatal(err) } f.obs.ETag = "two" f.obs.Claim.Holder.Incarnation = "successor" f.obs.Claim.Holder.Stamp = 2 f.obs.Claim.Holder.Session = 8 f.obs.Claim.Op = hostclaim.Op{Kind: hostclaim.KindServing, ID: "operation-2", Phase: "serving", Exec: "uk"} if err := f.g.Activate(t.Context(), target.Host, "uk", nil); err != nil { t.Fatal(err) } if err := f.g.CleanupWith(t.Context(), target, func(context.Context, FenceReceipt) error { return nil }, func() error { return nil }); err != nil { t.Fatal(err) } r, err := readRecord(f.g.DataDir, target.Host) if err != nil || r.Incarnation != "successor" || r.ActiveStamp != 2 || r.ActiveFence == nil || len(r.FenceJournal) != 1 { t.Fatalf("successor after cleanup = %+v, %v", r, err) } } func hostguardCaptured(target FenceTarget) Captured { return Captured{Incarnation: target.Incarnation, Stamp: target.Stamp} } func TestBeginFenceResumesAfterUnrelatedClaimCAS(t *testing.T) { f, target, _ := fenceFixture(t) target.RosterRevision = 9 if _, err := f.g.BeginFence(t.Context(), target); err != nil { t.Fatal(err) } f.obs.ETag = "bookkeeping" if _, err := f.g.BeginFence(t.Context(), target); !errors.Is(err, ErrFenceTarget) { t.Fatalf("stale request: %v", err) } fresh := target fresh.ClaimETag = f.obs.ETag fresh.FenceID = "retry" // Roster binding B2: unrelated roster writes between the request and its // retry move the receipt numeral but not the binding. Retry authority // ignores the numeral, and both lookups return the persisted target with // its ORIGINAL numeral. fresh.RosterRevision = target.RosterRevision + 4 got, err := f.g.BeginFence(t.Context(), fresh) if err != nil || got != target || got.RosterRevision != 9 { t.Fatalf("resume=%+v %v", got, err) } existing, err := f.g.ExistingFence(t.Context(), fresh) if err != nil || existing != target || existing.RosterRevision != 9 { t.Fatalf("existing=%+v %v", existing, err) } // Exact stored-target comparisons stay exact: the retry's numeral names no // persisted fence. if err := f.g.ResumeFence(t.Context(), fresh); !errors.Is(err, ErrFenceTarget) { t.Fatalf("resume with a changed numeral: %v", err) } at := time.Unix(1, 0).UTC() if err := f.g.CompleteFence(t.Context(), got, []byte("original receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil { t.Fatal(err) } receipt, err := f.g.FenceReceiptFor(t.Context(), target) if err != nil || string(receipt.Bytes) != "original receipt" || receipt.Target != target { t.Fatalf("receipt=%+v %v", receipt, err) } if _, err := f.g.FenceReceiptFor(t.Context(), fresh); !errors.Is(err, ErrNoFenceReceipt) { t.Fatalf("receipt for a changed numeral: %v", err) } for _, change := range []func(*FenceTarget){func(v *FenceTarget) { v.Session++ }, func(v *FenceTarget) { v.Op = "another" }, func(v *FenceTarget) { v.ControllerID = "another" }, func(v *FenceTarget) { v.DirectoryRevision = "another-binding" }} { g, old, _ := fenceFixture(t) old.RosterRevision = 9 if _, err := g.g.BeginFence(t.Context(), old); err != nil { t.Fatal(err) } fresh := old change(&fresh) fresh.RosterRevision++ if _, err := g.g.BeginFence(t.Context(), fresh); err == nil { t.Fatal("changed authority resumed") } if _, err := g.g.ExistingFence(t.Context(), fresh); !errors.Is(err, ErrFenceTarget) { t.Fatalf("changed authority found: %v", err) } } }