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>
24 files changed,
+2909
−0
internal/cgroup/cleanup.go+8 −0internal/cgroup/cleanup_linux.go+236 −0internal/cgroup/cleanup_linux_test.go+188 −0internal/cgroup/cleanup_other.go+18 −0internal/cgroup/cleanup_other_test.go+32 −0internal/host/bootstrap.go+2 −0internal/host/units_isolation_test.go+2 −0internal/hostguard/flock_other.go+7 −0internal/hostguard/flock_other_test.go+19 −0internal/hostguard/flock_unix.go+76 −0internal/hostguard/flock_unix_test.go+339 −0internal/hostguard/guard.go+229 −0internal/hostguard/guard_test.go+503 −0internal/hostguard/record.go+109 −0internal/hostguard/record_other.go+11 −0internal/hostguard/record_test.go+256 −0internal/hostguard/record_unix.go+154 −0internal/stopevidence/evidence.go+128 −0internal/stopevidence/evidence_linux_test.go+170 −0internal/stopevidence/fds.go+9 −0internal/stopevidence/fds_linux.go+124 −0internal/stopevidence/fds_linux_test.go+267 −0internal/stopevidence/fds_other.go+8 −0internal/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) |
| + } |
| +} |