carlos / platform Public

hostguard: the strict local record, capture and descriptor-relative atomic writes (claim Plan 3 Task 1)

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 8975c3076f4bc2a8b9058dd6870386005db9edea parent 47426cc
4 files changed, +488 −0
  • internal/hostguard/record.go +95 −0
  • internal/hostguard/record_other.go +10 −0
  • internal/hostguard/record_test.go +256 −0
  • internal/hostguard/record_unix.go +127 −0
diff --git a/internal/hostguard/record.go b/internal/hostguard/record.go
new file mode 100644
index 000000000..5c6d8d62a
--- /dev/null
+++ b/internal/hostguard/record.go
@@ -0,0 +1,95 @@
+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")
+ ErrNoRecord = errors.New("hostguard: no local record")
+ ErrMalformedRecord = errors.New("hostguard: malformed local record")
+)
+
+type Captured struct {
+ Incarnation string
+ Stamp uint64
+}
+
+type record struct {
+ V int `json:"v"`
+ Host string `json:"host"`
+ Incarnation string `json:"incarnation"`
+ ActiveStamp uint64 `json:"active_stamp"`
+ RetiredStamps []uint64 `json:"retired_stamps"`
+}
+
+func (r record) validate(host string) error {
+ if r.V != 1 || r.Host != host || !controlname.ValidHostShape(host) || r.Incarnation == "" || len(r.RetiredStamps) > 32 {
+ return ErrMalformedRecord
+ }
+ var previous uint64
+ for _, s := range r.RetiredStamps {
+ if s == 0 || s <= previous || (r.ActiveStamp != 0 && s >= r.ActiveStamp) {
+ return ErrMalformedRecord
+ }
+ previous = s
+ }
+ return nil
+}
+
+func readRecord(dataDir, host string) (record, error) {
+ if !controlname.ValidHostShape(host) {
+ return record{}, ErrInvalidHost
+ }
+ 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 := 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..c0d8d90f0
--- /dev/null
+++ b/internal/hostguard/record_other.go
@@ -0,0 +1,10 @@
+//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 }
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..322fdeadc
--- /dev/null
+++ b/internal/hostguard/record_unix.go
@@ -0,0 +1,127 @@
+//go:build unix
+
+package hostguard
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ "github.com/carlosframework/platform/internal/controlname"
+ "golang.org/x/sys/unix"
+)
+
+type recordDir struct{ fd int }
+
+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}, nil
+}
+
+func (d *recordDir) close() { _ = unix.Close(d.fd) }
+
+func (d *recordDir) read(host string) (record, error) {
+ if !controlname.ValidHostShape(host) {
+ return record{}, ErrInvalidHost
+ }
+ // 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 record{}, ErrNoRecord
+ }
+ if err != nil {
+ return record{}, err
+ }
+ f := os.NewFile(uintptr(fd), host+".json")
+ defer f.Close()
+ var st unix.Stat_t
+ if err := unix.Fstat(fd, &st); err != nil {
+ return record{}, err
+ }
+ if st.Mode&unix.S_IFMT != unix.S_IFREG {
+ return record{}, ErrUnsafeFile
+ }
+ b, err := io.ReadAll(f)
+ if err != nil {
+ return record{}, err
+ }
+ return decodeRecord(b, host)
+}
+
+func (d *recordDir) write(r record) error {
+ var nonce [16]byte
+ if _, err := rand.Read(nonce[:]); err != nil {
+ return err
+ }
+ return d.writeUsing(r, "."+r.Host+"-"+hex.EncodeToString(nonce[:]), unix.Renameat)
+}
+
+func (d *recordDir) writeUsing(r record, tmp string, rename func(int, string, int, string) error) error {
+ 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 := unix.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 unix.Fsync(d.fd)
+}