carlos / platform Public

hostguard, stopevidence, cgroup: the box-local host guard and stop evidence (claim Plan 3)

internal/hostguard: the strict per-host local record (incarnation, active
and retired installation stamps), a context-aware flock shared by every
host-state writer, fresh-claim admission, activation and installation
retirement; descriptor-relative I/O with no ownership changes.
cgroup.OpenCleanup: reopen-only cleanup with no spawn authority.
internal/stopevidence: complete /proc and per-task fd scans (EACCES is
incomplete, never empty) and composite stop evidence. tmpfiles provisions
/run/carlos/hostguard and /data/hostguard. Libraries only; nothing calls
them yet.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from feat/hostclaim-boxguard into main.
Co-Authored-By: claude <claude~paul@keymail.dev>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev b37f73e1a5a7ee11191ee2b90592cbe46d56cd6a parent 4d13aec
24 files changed, +2909 −0
  • internal/cgroup/cleanup.go +8 −0
  • internal/cgroup/cleanup_linux.go +236 −0
  • internal/cgroup/cleanup_linux_test.go +188 −0
  • internal/cgroup/cleanup_other.go +18 −0
  • internal/cgroup/cleanup_other_test.go +32 −0
  • internal/host/bootstrap.go +2 −0
  • internal/host/units_isolation_test.go +2 −0
  • internal/hostguard/flock_other.go +7 −0
  • internal/hostguard/flock_other_test.go +19 −0
  • internal/hostguard/flock_unix.go +76 −0
  • internal/hostguard/flock_unix_test.go +339 −0
  • internal/hostguard/guard.go +229 −0
  • internal/hostguard/guard_test.go +503 −0
  • internal/hostguard/record.go +109 −0
  • internal/hostguard/record_other.go +11 −0
  • internal/hostguard/record_test.go +256 −0
  • internal/hostguard/record_unix.go +154 −0
  • internal/stopevidence/evidence.go +128 −0
  • internal/stopevidence/evidence_linux_test.go +170 −0
  • internal/stopevidence/fds.go +9 −0
  • internal/stopevidence/fds_linux.go +124 −0
  • internal/stopevidence/fds_linux_test.go +267 −0
  • internal/stopevidence/fds_other.go +8 −0
  • internal/stopevidence/fds_other_test.go +14 −0

CI passed — run details

diff --git a/internal/cgroup/cleanup.go b/internal/cgroup/cleanup.go
new file mode 100644
index 000000000..c48c9295e
--- /dev/null
+++ b/internal/cgroup/cleanup.go
@@ -0,0 +1,8 @@
+package cgroup
+
+import "errors"
+
+var (
+ ErrNoCgroup = errors.New("cgroup: existing host cgroup absent")
+ ErrNoDelegation = errors.New("cgroup: no delegated manager")
+)
diff --git a/internal/cgroup/cleanup_linux.go b/internal/cgroup/cleanup_linux.go
new file mode 100644
index 000000000..b5ceee2db
--- /dev/null
+++ b/internal/cgroup/cleanup_linux.go
@@ -0,0 +1,236 @@
+//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...)
+}
diff --git a/internal/cgroup/cleanup_linux_test.go b/internal/cgroup/cleanup_linux_test.go
new file mode 100644
index 000000000..8e774dcc8
--- /dev/null
+++ b/internal/cgroup/cleanup_linux_test.go
@@ -0,0 +1,188 @@
+//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)
+ }
+ })
+ }
+}
diff --git a/internal/cgroup/cleanup_other.go b/internal/cgroup/cleanup_other.go
new file mode 100644
index 000000000..6b7c33abf
--- /dev/null
+++ b/internal/cgroup/cleanup_other.go
@@ -0,0 +1,18 @@
+//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 }
diff --git a/internal/cgroup/cleanup_other_test.go b/internal/cgroup/cleanup_other_test.go
new file mode 100644
index 000000000..6c6ec9c85
--- /dev/null
+++ b/internal/cgroup/cleanup_other_test.go
@@ -0,0 +1,32 @@
+//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)
+ }
+}
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go
index b04bd7fde..5831faf10 100644
--- a/internal/host/bootstrap.go
+++ b/internal/host/bootstrap.go
@@ -60,6 +60,8 @@ const socketsGroup = "carlos-sockets"
// tenants. other=0 on both dirs keeps every unenrolled uid out.
const tmpfilesConf = "d /run/carlos 2750 " + serviceUser + " " + socketsGroup + " -\n" +
"d /run/carlos/apps 0750 " + serviceUser + " " + serviceUser + " -\n" +
+ "d /run/carlos/hostguard 2770 " + serviceUser + " " + serviceUser + " -\n" +
+ "d /data/hostguard 2770 " + serviceUser + " " + serviceUser + " -\n" +
// The isolated-exec trees (design §3.1, §3.2). Each protected view is
// a nosymfollow bind of the dotted raw directory beside it (the mount
// units); tmpfiles creates both every boot because /run is tmpfs and
diff --git a/internal/host/units_isolation_test.go b/internal/host/units_isolation_test.go
index 8ebeb9588..d612ff837 100644
--- a/internal/host/units_isolation_test.go
+++ b/internal/host/units_isolation_test.go
@@ -134,6 +134,8 @@ func TestTmpfilesCreatesTheInstanceTrees(t *testing.T) {
"d /run/carlos/.logs 0700 carlos carlos -",
"d /run/carlos/logs 0700 carlos carlos -",
"d /run/carlos-control 0750 root carlos -",
+ "d /run/carlos/hostguard 2770 carlos carlos -",
+ "d /data/hostguard 2770 carlos carlos -",
"f /etc/carlos/probe-secret 0640 root carlos - probe",
"f /dev/shm/carlos-probe 0644 root root - probe",
} {
diff --git a/internal/hostguard/flock_other.go b/internal/hostguard/flock_other.go
new file mode 100644
index 000000000..8e0502b5a
--- /dev/null
+++ b/internal/hostguard/flock_other.go
@@ -0,0 +1,7 @@
+//go:build !unix
+
+package hostguard
+
+import "context"
+
+func acquire(context.Context, string, string) (func(), error) { return nil, ErrUnsupported }
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.go b/internal/hostguard/flock_unix.go
new file mode 100644
index 000000000..9ba78ef87
--- /dev/null
+++ b/internal/hostguard/flock_unix.go
@@ -0,0 +1,76 @@
+//go:build unix
+
+package hostguard
+
+import (
+ "context"
+ "errors"
+ "os"
+ "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
+ }
+ if err := validateFileHost(host); err != nil {
+ return nil, err
+ }
+ dirFD, err := openDirectory(dir)
+ if err != nil {
+ return nil, err
+ }
+ defer unix.Close(dirFD)
+ flags := unix.O_RDWR | unix.O_CREAT | unix.O_NOFOLLOW | unix.O_CLOEXEC | unix.O_NONBLOCK
+ fd, err := unix.Openat(dirFD, host+".lock", flags|unix.O_EXCL, 0660)
+ created := err == nil
+ if errors.Is(err, unix.EEXIST) {
+ fd, err = unix.Openat(dirFD, host+".lock", flags, 0660)
+ }
+ if err != nil {
+ return nil, err
+ }
+ f := os.NewFile(uintptr(fd), host+".lock")
+ fail := func(err error) (func(), error) { f.Close(); return nil, err }
+ var st unix.Stat_t
+ if err := unix.Fstat(fd, &st); err != nil {
+ return fail(err)
+ }
+ if st.Mode&unix.S_IFMT != unix.S_IFREG {
+ return fail(ErrUnsafeFile)
+ }
+ if created || st.Mode&07777 != 0660 {
+ if err := unix.Fchmod(fd, 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:
+ }
+ }
+}
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")
+ }
+}
diff --git a/internal/hostguard/guard.go b/internal/hostguard/guard.go
new file mode 100644
index 000000000..09aac8f0b
--- /dev/null
+++ b/internal/hostguard/guard.go
@@ -0,0 +1,229 @@
+package hostguard
+
+import (
+ "context"
+ "errors"
+
+ "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
+ openRecords func(string) (*recordDir, error)
+}
+
+func (g Guard) locked(ctx context.Context, host string, fn func(*recordDir) error) error {
+ if err := validateFileHost(host); err != nil {
+ return err
+ }
+ 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()
+ open := g.openRecords
+ if open == nil {
+ open = openRecordDir
+ }
+ d, err := open(g.DataDir)
+ if err != nil {
+ return err
+ }
+ defer d.close()
+ return fn(d)
+}
+
+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(d *recordDir) 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 := d.read(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(d *recordDir) 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 := d.read(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 d.sync(host)
+ }
+ r.retire()
+ }
+ r.ActiveStamp = stamp
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return d.write(r)
+ })
+}
+
+func (g Guard) RetireInstallation(ctx context.Context, host string, check func(*hostclaim.Claim) error) error {
+ return g.locked(ctx, host, func(d *recordDir) 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 := d.read(host)
+ if err != nil {
+ return err
+ }
+ if r.Incarnation != c.Holder.Incarnation {
+ return ErrRecordIncarnation
+ }
+ if r.ActiveStamp == 0 {
+ return d.sync(host)
+ }
+ r.retire()
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return d.write(r)
+ })
+}
diff --git a/internal/hostguard/guard_test.go b/internal/hostguard/guard_test.go
new file mode 100644
index 000000000..02ab90304
--- /dev/null
+++ b/internal/hostguard/guard_test.go
@@ -0,0 +1,503 @@
+//go:build unix
+
+package hostguard
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/controlname"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/release"
+ "golang.org/x/sys/unix"
+)
+
+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: recordFixture(t), 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)
+ }
+}
+
+func TestLifecycleRetrySyncsRecord(t *testing.T) {
+ for _, retired := range []bool{false, true} {
+ name := "activate"
+ if retired {
+ name = "retire"
+ }
+ t.Run(name, func(t *testing.T) {
+ f := newGuardFixture(t)
+ host := f.obs.Host
+ operation := func() error { return f.g.Activate(t.Context(), host, "uk", nil) }
+ wantStamp := uint64(1)
+ if retired {
+ if err := operation(); err != nil {
+ t.Fatal(err)
+ }
+ operation = func() error { return f.g.RetireInstallation(t.Context(), host, allow) }
+ wantStamp = 0
+ }
+ failure := errors.New("injected fsync failure")
+ failKind := "directory"
+ var calls []string
+ f.g.openRecords = func(path string) (*recordDir, error) {
+ d, err := openRecordDir(path)
+ if err != nil {
+ return nil, err
+ }
+ d.fsync = func(fd int) error {
+ var st unix.Stat_t
+ if err := unix.Fstat(fd, &st); err != nil {
+ return err
+ }
+ kind := "file"
+ if st.Mode&unix.S_IFMT == unix.S_IFDIR {
+ kind = "directory"
+ }
+ calls = append(calls, kind)
+ if kind == failKind {
+ return failure
+ }
+ return unix.Fsync(fd)
+ }
+ return d, nil
+ }
+ if err := operation(); !errors.Is(err, failure) {
+ t.Fatalf("post-rename failure: %v", err)
+ }
+ if !reflect.DeepEqual(calls, []string{"file", "directory"}) {
+ t.Fatalf("initial syncs: %v", calls)
+ }
+ captured, err := Capture(f.g.DataDir, host)
+ if err != nil || captured.Stamp != wantStamp {
+ t.Fatalf("rename did not publish: %+v %v", captured, err)
+ }
+ defer unchangedFile(t, filepath.Join(f.g.DataDir, "hostguard", host+".json"))()
+ for _, kind := range []string{"file", "directory", ""} {
+ failKind, calls = kind, nil
+ err := operation()
+ if kind == "" {
+ if err != nil {
+ t.Fatal(err)
+ }
+ } else if !errors.Is(err, failure) {
+ t.Fatalf("retry acknowledged %s sync failure: %v", kind, err)
+ }
+ want := []string{"file", "directory"}
+ if kind == "file" {
+ want = []string{"file"}
+ }
+ if !reflect.DeepEqual(calls, want) {
+ t.Fatalf("retry syncs: %v want %v", calls, want)
+ }
+ }
+ })
+ }
+}
+
+func TestGuardHostnameLengthBoundary(t *testing.T) {
+ for _, length := range []int{221, 222, 250, 251, 252, 253, 254} {
+ t.Run(fmt.Sprint(length), func(t *testing.T) {
+ host := strings.Repeat(strings.Repeat("a", 63)+".", 3) + strings.Repeat("b", length-192)
+ if controlname.ValidHostShape(host) != (length <= 253) {
+ t.Fatalf("unexpected hostname validation for %d bytes", length)
+ }
+ f := newGuardFixture(t)
+ f.obs.Host, f.obs.Claim.Host = host, host
+ if length > 250 {
+ want := ErrHostTooLong
+ if length > 253 {
+ want = ErrInvalidHost
+ }
+ for name, operation := range map[string]func() error{
+ "capture": func() error { _, err := Capture(f.g.DataDir, host); return err },
+ "activate": func() error { return f.g.Activate(t.Context(), host, "uk", nil) },
+ "retire": func() error { return f.g.RetireInstallation(t.Context(), host, allow) },
+ "with": func() error {
+ return f.g.With(t.Context(), host, Captured{Incarnation: "inc", Stamp: 1}, allow, func() error { t.Fatal("callback ran"); return nil })
+ },
+ } {
+ if err := operation(); !errors.Is(err, want) {
+ t.Errorf("%s: got %v, want %v", name, err, want)
+ }
+ }
+ if f.reads != 0 {
+ t.Fatalf("read claim before rejecting hostname: %d", f.reads)
+ }
+ for _, dir := range []string{f.g.LockDir, filepath.Join(f.g.DataDir, "hostguard")} {
+ files, err := os.ReadDir(dir)
+ if err != nil || len(files) != 0 {
+ t.Fatalf("refusal created files: %v %v", files, err)
+ }
+ }
+ return
+ }
+ 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 || captured != (Captured{Incarnation: "inc", Stamp: 1}) {
+ t.Fatal(captured, err)
+ }
+ if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ called := false
+ if err := f.g.With(t.Context(), host, captured, allow, func() error { called = true; return nil }); err != nil || !called {
+ t.Fatal(called, err)
+ }
+ for i := 0; i < 2; i++ {
+ if err := f.g.RetireInstallation(t.Context(), host, allow); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if got, err := Capture(f.g.DataDir, host); err != nil || got.Stamp != 0 {
+ t.Fatal(got, err)
+ }
+ if err := f.g.With(t.Context(), host, captured, allow, func() error { t.Fatal("retired callback ran"); return nil }); !errors.Is(err, ErrInactive) {
+ t.Fatal(err)
+ }
+ f.obs.Claim.Holder.Stamp = 2
+ if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range []string{filepath.Join(f.g.DataDir, "hostguard", host+".json"), filepath.Join(f.g.LockDir, host+".lock")} {
+ if _, err := os.Stat(path); err != nil {
+ t.Fatal(err)
+ }
+ }
+ files, err := os.ReadDir(filepath.Join(f.g.DataDir, "hostguard"))
+ if err != nil || len(files) != 1 {
+ t.Fatalf("temporary files remain: %v %v", files, err)
+ }
+ })
+ }
+}
diff --git a/internal/hostguard/record.go b/internal/hostguard/record.go
new file mode 100644
index 000000000..bee23a619
--- /dev/null
+++ b/internal/hostguard/record.go
@@ -0,0 +1,109 @@
+package hostguard
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+
+ "github.com/carlosframework/platform/internal/controlname"
+)
+
+var (
+ ErrUnsupported = errors.New("hostguard: unsupported platform")
+ ErrNotProvisioned = errors.New("hostguard: directory not provisioned")
+ ErrUnsafeFile = errors.New("hostguard: expected regular file")
+ ErrInvalidHost = errors.New("hostguard: invalid host")
+ ErrHostTooLong = errors.New("hostguard: host too long for record or lock filename")
+ 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 validateFileHost(host string) error {
+ if !controlname.ValidHostShape(host) {
+ return ErrInvalidHost
+ }
+ if len(host)+len(".json") > 255 || len(host)+len(".lock") > 255 {
+ return ErrHostTooLong
+ }
+ return nil
+}
+
+func readRecord(dataDir, host string) (record, error) {
+ if err := validateFileHost(host); err != nil {
+ return record{}, err
+ }
+ d, err := openRecordDir(dataDir)
+ if err != nil {
+ return record{}, err
+ }
+ defer d.close()
+ return d.read(host)
+}
+
+func decodeRecord(b []byte, host string) (record, error) {
+ 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 {
+ if err := validateFileHost(r.Host); err != nil {
+ return err
+ }
+ if err := r.validate(r.Host); err != nil {
+ return err
+ }
+ d, err := openRecordDir(dataDir)
+ if err != nil {
+ return err
+ }
+ defer d.close()
+ return d.write(r)
+}
diff --git a/internal/hostguard/record_other.go b/internal/hostguard/record_other.go
new file mode 100644
index 000000000..f65922179
--- /dev/null
+++ b/internal/hostguard/record_other.go
@@ -0,0 +1,11 @@
+//go:build !unix
+
+package hostguard
+
+type recordDir struct{}
+
+func openRecordDir(string) (*recordDir, error) { return nil, ErrUnsupported }
+func (*recordDir) close() {}
+func (*recordDir) read(string) (record, error) { return record{}, ErrUnsupported }
+func (*recordDir) write(record) error { return ErrUnsupported }
+func (*recordDir) sync(string) error { return ErrUnsupported }
diff --git a/internal/hostguard/record_test.go b/internal/hostguard/record_test.go
new file mode 100644
index 000000000..4b1c8450f
--- /dev/null
+++ b/internal/hostguard/record_test.go
@@ -0,0 +1,256 @@
+//go:build unix
+
+package hostguard
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "golang.org/x/sys/unix"
+)
+
+func recordFixture(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ if err := os.Mkdir(filepath.Join(dir, "hostguard"), 0770); err != nil {
+ t.Fatal(err)
+ }
+ return dir
+}
+
+func TestCaptureStrict(t *testing.T) {
+ dir := recordFixture(t)
+ 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)
+ }
+ 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) {
+ dir, host := recordFixture(t), "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
+ d, err := openRecordDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.close()
+ err = d.writeUsing(r, ".rename-test", func(srcFD int, src string, dstFD int, dst string) error {
+ if srcFD != d.fd || dstFD != d.fd || src != ".rename-test" || dst != host+".json" {
+ t.Fatal("rename escaped pinned directory")
+ }
+ fd, e := unix.Openat(srcFD, src, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
+ if e != nil {
+ t.Fatal(e)
+ }
+ f := os.NewFile(uintptr(fd), src)
+ b, e := io.ReadAll(f)
+ f.Close()
+ if 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)
+ }
+}
+
+func unchangedFile(t *testing.T, path string) func() {
+ t.Helper()
+ before, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return func() {
+ t.Helper()
+ after, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := os.ReadFile(path)
+ if err != nil || string(got) != string(body) || before.Mode() != after.Mode() || !os.SameFile(before, after) {
+ t.Fatalf("target changed: before=%v after=%v body=%q err=%v", before, after, got, err)
+ }
+ }
+}
+
+func TestRecordNotProvisioned(t *testing.T) {
+ dir := t.TempDir()
+ r := record{V: 1, Host: "shop.example", Incarnation: "inc", ActiveStamp: 1}
+ if _, err := Capture(dir, r.Host); !errors.Is(err, ErrNotProvisioned) || errors.Is(err, ErrNoRecord) {
+ t.Fatal(err)
+ }
+ if err := writeRecord(dir, r); !errors.Is(err, ErrNotProvisioned) {
+ t.Fatal(err)
+ }
+ if _, err := os.Lstat(filepath.Join(dir, "hostguard")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("library created directory: %v", err)
+ }
+}
+
+func TestRecordSymlinksRefused(t *testing.T) {
+ for _, kind := range []string{"directory", "record", "temp"} {
+ t.Run(kind, func(t *testing.T) {
+ dir := recordFixture(t)
+ host := "shop.example"
+ r := record{V: 1, Host: host, Incarnation: "inc", ActiveStamp: 2}
+ outside := t.TempDir()
+ target := filepath.Join(outside, host+".json")
+ if err := os.WriteFile(target, []byte(`{"v":1,"host":"shop.example","incarnation":"inc","active_stamp":1,"retired_stamps":[]}`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ defer unchangedFile(t, target)()
+ guardDir := filepath.Join(dir, "hostguard")
+ link, destination := filepath.Join(guardDir, host+".json"), target
+ if kind == "directory" {
+ if err := os.Remove(guardDir); err != nil {
+ t.Fatal(err)
+ }
+ link, destination = guardDir, outside
+ } else if kind == "temp" {
+ link = filepath.Join(guardDir, ".fixed-temp")
+ }
+ if err := os.Symlink(destination, link); err != nil {
+ t.Fatal(err)
+ }
+ if kind == "temp" {
+ d, err := openRecordDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.close()
+ if err := d.writeUsing(r, ".fixed-temp", unix.Renameat); err == nil {
+ t.Fatal("accepted temporary symlink")
+ }
+ if _, err := d.read(host); !errors.Is(err, ErrNoRecord) {
+ t.Fatal("published record despite refused temporary file", err)
+ }
+ } else {
+ if _, err := Capture(dir, host); err == nil || errors.Is(err, ErrNoRecord) {
+ t.Fatal("capture followed symlink", err)
+ }
+ if err := writeRecord(dir, r); err == nil {
+ t.Fatal("write accepted symlink")
+ }
+ }
+ if got, err := os.Readlink(link); err != nil || got != destination {
+ t.Fatalf("symlink removed or replaced: %q %v", got, err)
+ }
+ })
+ }
+}
diff --git a/internal/hostguard/record_unix.go b/internal/hostguard/record_unix.go
new file mode 100644
index 000000000..ab71019c8
--- /dev/null
+++ b/internal/hostguard/record_unix.go
@@ -0,0 +1,154 @@
+//go:build unix
+
+package hostguard
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ "golang.org/x/sys/unix"
+)
+
+type recordDir struct {
+ fd int
+ fsync func(int) error
+}
+
+func openDirectory(path string) (int, error) {
+ fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
+ if errors.Is(err, unix.ENOENT) {
+ return -1, fmt.Errorf("%w: %s", ErrNotProvisioned, path)
+ }
+ if err != nil {
+ return -1, err
+ }
+ var st unix.Stat_t
+ if err := unix.Fstat(fd, &st); err != nil {
+ unix.Close(fd)
+ return -1, err
+ }
+ if st.Mode&unix.S_IFMT != unix.S_IFDIR {
+ unix.Close(fd)
+ return -1, fmt.Errorf("hostguard: not a directory: %s", path)
+ }
+ return fd, nil
+}
+
+func openRecordDir(dataDir string) (*recordDir, error) {
+ fd, err := openDirectory(filepath.Join(dataDir, "hostguard"))
+ if err != nil {
+ return nil, err
+ }
+ return &recordDir{fd: fd, fsync: unix.Fsync}, nil
+}
+
+func (d *recordDir) close() { _ = unix.Close(d.fd) }
+
+func (d *recordDir) openRecord(host string) (*os.File, error) {
+ if err := validateFileHost(host); err != nil {
+ return nil, err
+ }
+ // NONBLOCK prevents a planted FIFO from hanging before Fstat can refuse it.
+ fd, err := unix.Openat(d.fd, host+".json", unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0)
+ if errors.Is(err, unix.ENOENT) {
+ return nil, ErrNoRecord
+ }
+ if err != nil {
+ return nil, err
+ }
+ f := os.NewFile(uintptr(fd), host+".json")
+ var st unix.Stat_t
+ if err := unix.Fstat(fd, &st); err != nil {
+ f.Close()
+ return nil, err
+ }
+ if st.Mode&unix.S_IFMT != unix.S_IFREG {
+ f.Close()
+ return nil, ErrUnsafeFile
+ }
+ return f, nil
+}
+
+func (d *recordDir) read(host string) (record, error) {
+ f, err := d.openRecord(host)
+ if err != nil {
+ return record{}, err
+ }
+ defer f.Close()
+ b, err := io.ReadAll(f)
+ if err != nil {
+ return record{}, err
+ }
+ return decodeRecord(b, host)
+}
+
+func (d *recordDir) sync(host string) error {
+ f, err := d.openRecord(host)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ if err := d.fsync(int(f.Fd())); err != nil {
+ return err
+ }
+ return d.fsync(d.fd)
+}
+
+func (d *recordDir) write(r record) error {
+ var nonce [16]byte
+ if _, err := rand.Read(nonce[:]); err != nil {
+ return err
+ }
+ return d.writeUsing(r, ".tmp-"+hex.EncodeToString(nonce[:]), unix.Renameat)
+}
+
+func (d *recordDir) writeUsing(r record, tmp string, rename func(int, string, int, string) error) error {
+ if err := validateFileHost(r.Host); err != nil {
+ return err
+ }
+ if err := r.validate(r.Host); err != nil {
+ return err
+ }
+ if tmp == "" || tmp == "." || tmp == ".." || filepath.Base(tmp) != tmp || tmp == r.Host+".json" {
+ return ErrUnsafeFile
+ }
+ // Refuse an existing symlink or unreadable record. Renameat itself never
+ // follows the destination, including a symlink planted after this check.
+ if _, err := d.read(r.Host); err != nil && !errors.Is(err, ErrNoRecord) {
+ return err
+ }
+ b, err := json.Marshal(r)
+ if err != nil {
+ return err
+ }
+ fd, err := unix.Openat(d.fd, tmp, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0660)
+ if err != nil {
+ return err
+ }
+ // Only remove a name this call successfully created.
+ defer unix.Unlinkat(d.fd, tmp, 0)
+ f := os.NewFile(uintptr(fd), tmp)
+ defer f.Close()
+ if err := unix.Fchmod(fd, 0660); err != nil {
+ return err
+ }
+ if _, err := f.Write(append(b, '\n')); err != nil {
+ return err
+ }
+ if err := d.fsync(fd); err != nil {
+ return err
+ }
+ if err := f.Close(); err != nil {
+ return err
+ }
+ if err := rename(d.fd, tmp, d.fd, r.Host+".json"); err != nil {
+ return err
+ }
+ return d.fsync(d.fd)
+}
diff --git a/internal/stopevidence/evidence.go b/internal/stopevidence/evidence.go
new file mode 100644
index 000000000..4c26a13c8
--- /dev/null
+++ b/internal/stopevidence/evidence.go
@@ -0,0 +1,128 @@
+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)
+}
diff --git a/internal/stopevidence/evidence_linux_test.go b/internal/stopevidence/evidence_linux_test.go
new file mode 100644
index 000000000..dbe3b585d
--- /dev/null
+++ b/internal/stopevidence/evidence_linux_test.go
@@ -0,0 +1,170 @@
+//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", "task", "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)
+ }
+}
diff --git a/internal/stopevidence/fds.go b/internal/stopevidence/fds.go
new file mode 100644
index 000000000..6b6caf288
--- /dev/null
+++ b/internal/stopevidence/fds.go
@@ -0,0 +1,9 @@
+package stopevidence
+
+import "errors"
+
+var (
+ ErrIncomplete = errors.New("stopevidence: incomplete descriptor scan")
+ ErrUnsupported = errors.New("stopevidence: unsupported platform")
+ ErrInvalidInput = errors.New("stopevidence: invalid input")
+)
diff --git a/internal/stopevidence/fds_linux.go b/internal/stopevidence/fds_linux.go
new file mode 100644
index 000000000..ce7df03fd
--- /dev/null
+++ b/internal/stopevidence/fds_linux.go
@@ -0,0 +1,124 @@
+//go:build linux
+
+package stopevidence
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "syscall"
+)
+
+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())
+ tasks, err := readProcDir(filepath.Join(pidDir, "task"), pidDir, ops)
+ if err != nil {
+ return nil, fmt.Errorf("%w: pid %d: %w", ErrIncomplete, pid, err)
+ }
+ found := false
+ for _, task := range tasks {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ tid, err := strconv.Atoi(task.Name())
+ if err != nil || tid <= 0 {
+ continue
+ }
+ taskDir := filepath.Join(pidDir, "task", task.Name())
+ fdDir := filepath.Join(taskDir, "fd")
+ fds, err := readProcDir(fdDir, taskDir, ops)
+ if err != nil {
+ return nil, fmt.Errorf("%w: pid %d tid %d: %w", ErrIncomplete, pid, tid, err)
+ }
+ for _, fd := range fds {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ target, err := ops.readlink(filepath.Join(fdDir, fd.Name()))
+ if procGone(err) {
+ continue
+ }
+ if err != nil {
+ return nil, fmt.Errorf("%w: pid %d tid %d fd %s: %w", ErrIncomplete, pid, tid, fd.Name(), err)
+ }
+ if !filepath.IsAbs(target) {
+ continue
+ }
+ 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())
+}
+
+func procGone(err error) bool {
+ return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ESRCH)
+}
+
+func readProcDir(path, owner string, ops fdOps) ([]os.DirEntry, error) {
+ entries, err := ops.readDir(path)
+ if procGone(err) {
+ if _, ownerErr := ops.stat(owner); procGone(ownerErr) {
+ return nil, nil
+ } else if ownerErr != nil {
+ return nil, ownerErr
+ }
+ }
+ return entries, err
+}
diff --git a/internal/stopevidence/fds_linux_test.go b/internal/stopevidence/fds_linux_test.go
new file mode 100644
index 000000000..9bd1f183e
--- /dev/null
+++ b/internal/stopevidence/fds_linux_test.go
@@ -0,0 +1,267 @@
+//go:build linux
+
+package stopevidence
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "runtime"
+ "strconv"
+ "syscall"
+ "testing"
+
+ "golang.org/x/sys/unix"
+)
+
+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, "task", 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", "task", "20", "fd")) {
+ return nil, syscall.EACCES
+ }
+ return readDir(p)
+ }
+ ops.readlink = func(p string) (string, error) {
+ if where == "readlink" && p == filepath.Join(root, "20", "task", "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", "task", "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", "task", "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", "task", "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)
+}
+
+func TestFDHoldersTaskTables(t *testing.T) {
+ root, db := procFixture(t)
+ for _, tid := range []string{"91", "92"} {
+ dir := filepath.Join(root, "90", "task", tid, "fd")
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(db, filepath.Join(dir, "4")); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := FDHolders(root, []string{db})
+ if err != nil || !reflect.DeepEqual(got, []int{3, 20, 90}) {
+ t.Fatalf("task-only holders: %v %v", got, err)
+ }
+}
+
+func TestFDTaskScanErrors(t *testing.T) {
+ root, db := procFixture(t)
+ taskDir := filepath.Join(root, "90", "task", "91")
+ fdDir := filepath.Join(taskDir, "fd")
+ if err := os.MkdirAll(fdDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(db, filepath.Join(fdDir, "4")); err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range []string{filepath.Join(root, "90", "task"), fdDir, filepath.Join(fdDir, "4")} {
+ for _, failure := range []error{syscall.EACCES, syscall.ENOENT, syscall.ESRCH} {
+ t.Run(filepath.Base(path)+"/"+failure.Error(), func(t *testing.T) {
+ ops := realFDOps()
+ readDir, readlink, stat := ops.readDir, ops.readlink, ops.stat
+ ops.readDir = func(p string) ([]os.DirEntry, error) {
+ if p == path {
+ return nil, failure
+ }
+ return readDir(p)
+ }
+ ops.readlink = func(p string) (string, error) {
+ if p == path {
+ return "", failure
+ }
+ return readlink(p)
+ }
+ ops.stat = func(p string) (os.FileInfo, error) {
+ if p == taskDir || p == filepath.Join(root, "90") {
+ return nil, failure
+ }
+ return stat(p)
+ }
+ got, err := fdHolders(t.Context(), root, []string{db}, ops)
+ if failure == syscall.EACCES {
+ if got != nil || !errors.Is(err, ErrIncomplete) || !errors.Is(err, failure) {
+ t.Fatalf("partial success: %v %v", got, err)
+ }
+ } else if err != nil || !reflect.DeepEqual(got, []int{3, 20}) {
+ t.Fatalf("vanished task: %v %v", got, err)
+ }
+ })
+ }
+ }
+}
+
+func TestFDHoldersUnsharedThread(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "thread.db")
+ ready, release, done := make(chan error), make(chan struct{}), make(chan struct{})
+ leader := false
+ go func() {
+ runtime.LockOSThread()
+ defer close(done)
+ // The goroutine may land on the thread-group leader, whose table is
+ // /proc/<pid>/fd itself; the leader-invisibility check below only
+ // holds for a non-leader thread.
+ leader = unix.Gettid() == os.Getpid()
+ if err := unix.Unshare(unix.CLONE_FILES); err != nil {
+ ready <- err
+ return
+ }
+ fd, err := unix.Open(path, unix.O_RDWR|unix.O_CREAT|unix.O_CLOEXEC, 0600)
+ if err != nil {
+ ready <- err
+ return
+ }
+ defer unix.Close(fd)
+ ready <- nil
+ <-release
+ }()
+ defer func() { close(release); <-done }()
+ if err := <-ready; err != nil {
+ if errors.Is(err, unix.EPERM) || errors.Is(err, unix.EACCES) || errors.Is(err, unix.ENOSYS) {
+ t.Skipf("unshare unavailable: %v", err)
+ }
+ t.Fatal(err)
+ }
+ pid := strconv.Itoa(os.Getpid())
+ entries, err := os.ReadDir(filepath.Join("/proc", pid, "fd"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, entry := range entries {
+ target, err := os.Readlink(filepath.Join("/proc", pid, "fd", entry.Name()))
+ if err == nil && target == path && !leader {
+ t.Fatal("private descriptor visible in leader table")
+ }
+ }
+ root := t.TempDir()
+ if err := os.Symlink(filepath.Join("/proc", pid), filepath.Join(root, pid)); err != nil {
+ t.Fatal(err)
+ }
+ got, err := FDHolders(root, []string{path})
+ if err != nil || !reflect.DeepEqual(got, []int{os.Getpid()}) {
+ t.Fatalf("unshared thread holder: %v %v", got, err)
+ }
+}
diff --git a/internal/stopevidence/fds_other.go b/internal/stopevidence/fds_other.go
new file mode 100644
index 000000000..058baae7c
--- /dev/null
+++ b/internal/stopevidence/fds_other.go
@@ -0,0 +1,8 @@
+//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 }
diff --git a/internal/stopevidence/fds_other_test.go b/internal/stopevidence/fds_other_test.go
new file mode 100644
index 000000000..309a90b21
--- /dev/null
+++ b/internal/stopevidence/fds_other_test.go
@@ -0,0 +1,14 @@
+//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)
+ }
+}