carlos / platform Public

park, hostguard: journaled local retirement under the host guard (claim Plan 4 Task 3)

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 0a4f963eacf65fcf79962374787509c7088095c2 parent 3f425dd
6 files changed, +1522 −0
  • internal/hostguard/retirement.go +95 −0
  • internal/hostguard/retirement_test.go +127 −0
  • internal/park/local.go +270 −0
  • internal/park/local_linux.go +348 −0
  • internal/park/local_linux_test.go +673 −0
  • internal/park/local_other.go +9 −0

CI passed — run details

diff --git a/internal/hostguard/retirement.go b/internal/hostguard/retirement.go
new file mode 100644
index 000000000..2c5403ad6
--- /dev/null
+++ b/internal/hostguard/retirement.go
@@ -0,0 +1,95 @@
+package hostguard
+
+import (
+ "context"
+ "errors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+)
+
+func (g Guard) RetireInstallationWith(ctx context.Context, host string, captured Captured, check func(*hostclaim.Claim) error, fn func() error) error {
+ return g.retireInstallationWith(ctx, host, captured, check, fn, nil)
+}
+
+// persist is injected by package tests to interrupt the record's durable write.
+func (g Guard) retireInstallationWith(ctx context.Context, host string, captured Captured, check func(*hostclaim.Claim) error, fn func() error, persist func(*recordDir, record) error) error {
+ if captured.Stamp == 0 || check == nil || fn == nil {
+ return ErrInvalidInput
+ }
+ 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 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 {
+ if r.ActiveStamp != captured.Stamp {
+ return ErrStampMismatch
+ }
+ r.retire()
+ if persist == nil {
+ persist = (*recordDir).write
+ }
+ } else {
+ n := len(r.RetiredStamps)
+ if n == 0 || r.RetiredStamps[n-1] != captured.Stamp {
+ return ErrStampMismatch
+ }
+ if persist == nil {
+ persist = func(d *recordDir, _ record) error { return d.sync(host) }
+ }
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ // An inactive record can be visible after rename without being durable.
+ // Sync it on retries before the callback may move any artifact.
+ if err := persist(d, r); err != nil {
+ return err
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return fn()
+ })
+}
+
+func (g Guard) WithNoInstallation(ctx context.Context, host, incarnation string, check func(*hostclaim.Claim) error, fn func() error) error {
+ if incarnation == "" || check == nil || fn == nil {
+ return ErrInvalidInput
+ }
+ return g.locked(ctx, host, func(d *recordDir) error {
+ c, err := g.fresh(ctx, host)
+ if err != nil {
+ return err
+ }
+ if c.Holder.Incarnation != incarnation {
+ return ErrClaimIncarnation
+ }
+ if err := check(c); err != nil {
+ return err
+ }
+ _, err = d.read(host)
+ if !errors.Is(err, ErrNoRecord) {
+ if err != nil {
+ return err
+ }
+ return ErrStampMismatch
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return fn()
+ })
+}
diff --git a/internal/hostguard/retirement_test.go b/internal/hostguard/retirement_test.go
new file mode 100644
index 000000000..34388221a
--- /dev/null
+++ b/internal/hostguard/retirement_test.go
@@ -0,0 +1,127 @@
+//go:build unix
+
+package hostguard
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+
+ "golang.org/x/sys/unix"
+)
+
+func TestRetireInstallationWithSyncsInactiveRecord(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("injected retirement sync 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 {
+ kind := "file"
+ if fd == d.fd {
+ kind = "directory"
+ }
+ calls = append(calls, kind)
+ if kind == failKind {
+ return failure
+ }
+ return unix.Fsync(fd)
+ }
+ return d, nil
+ }
+ callbacks := 0
+ retire := func() error {
+ return f.g.RetireInstallationWith(t.Context(), host, captured, allow, func() error {
+ callbacks++
+ return nil
+ })
+ }
+ if err := retire(); !errors.Is(err, failure) || callbacks != 0 {
+ t.Fatalf("initial failure: %v, callbacks=%d", err, callbacks)
+ }
+ path := filepath.Join(f.g.DataDir, "hostguard", host+".json")
+ // Hold the inode so replacement writes cannot reuse its number.
+ original, err := os.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer original.Close()
+ defer unchangedFile(t, path)()
+ for _, kind := range []string{"file", "directory", ""} {
+ failKind, calls = kind, nil
+ err := retire()
+ if kind == "" {
+ if err != nil || callbacks != 1 {
+ t.Fatalf("durable retry: %v, callbacks=%d", err, callbacks)
+ }
+ } else if !errors.Is(err, failure) || callbacks != 0 {
+ t.Fatalf("%s failure: %v, callbacks=%d", kind, err, callbacks)
+ }
+ want := []string{"file", "directory"}
+ if kind == "file" {
+ want = []string{"file"}
+ }
+ if !reflect.DeepEqual(calls, want) {
+ t.Fatalf("syncs=%v, want=%v", calls, want)
+ }
+ }
+}
+
+func TestRetireInstallationWithRepairsDurability(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)
+ }
+ crash := errors.New("crash after record rename before directory fsync")
+ callbacks, writes := 0, 0
+ callback := func() error { callbacks++; return nil }
+ persist := func(d *recordDir, r record) error {
+ writes++
+ if r.ActiveStamp != 0 || len(r.RetiredStamps) != 1 || r.RetiredStamps[0] != captured.Stamp {
+ t.Fatal("incorrect inactive record", r)
+ }
+ return d.writeUsing(r, ".retire-crash", func(a int, old string, b int, new string) error {
+ if err := unix.Renameat(a, old, b, new); err != nil {
+ return err
+ }
+ return crash
+ })
+ }
+ for attempt := 0; attempt < 2; attempt++ {
+ if err := f.g.retireInstallationWith(t.Context(), f.obs.Host, captured, allow, callback, persist); !errors.Is(err, crash) {
+ t.Fatalf("attempt %d: %v", attempt, err)
+ }
+ if callbacks != 0 || writes != attempt+1 {
+ t.Fatal("callback ran without durable record", callbacks, writes)
+ }
+ visible, err := Capture(f.g.DataDir, f.obs.Host)
+ if err != nil || visible.Stamp != 0 {
+ t.Fatal("did not reach rename boundary", visible, err)
+ }
+ }
+ // Exercise the public entry point with its real writer after both failures.
+ if err := f.g.RetireInstallationWith(t.Context(), f.obs.Host, captured, allow, callback); err != nil {
+ t.Fatal(err)
+ }
+ if callbacks != 1 {
+ t.Fatal("durable retry did not invoke callback")
+ }
+}
diff --git a/internal/park/local.go b/internal/park/local.go
new file mode 100644
index 000000000..c5945eeae
--- /dev/null
+++ b/internal/park/local.go
@@ -0,0 +1,270 @@
+package park
+
+import (
+ "context"
+ "errors"
+ "path"
+ "sort"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+var (
+ ErrLocalIdentity = errors.New("park: local retirement identity mismatch")
+ ErrLocalInventory = errors.New("park: missing or unsafe inventory")
+ ErrLocalJournal = errors.New("park: invalid or conflicting local journal")
+ ErrLocalUnsupported = errors.New("park: local retirement unsupported")
+ errLocalComplete = errors.New("park: local obligation already completed")
+)
+
+type Local struct {
+ Authorize func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error
+ Guard hostguard.Guard
+ Claims release.Store
+ DataDir, Box string
+ afterRename func(string) error
+}
+
+type localItem struct {
+ Path string
+ Stamp uint64
+ Dev, Ino uint64
+ Type uint32
+ Missing, Done bool
+}
+
+type localJournal struct {
+ V int
+ Host, Incarnation, Op, Obligation, Retirement, Mode string
+ Installation, ArtifactStamp uint64
+ Items []localItem
+}
+
+func localComponent(s string) bool {
+ return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00")
+}
+
+func localPath(s string) bool {
+ if s == "" || path.IsAbs(s) || path.Clean(s) != s || strings.ContainsAny(s, "\\\x00") {
+ return false
+ }
+ for _, p := range strings.Split(s, "/") {
+ if !localComponent(p) {
+ return false
+ }
+ }
+ return s != "hostguard" && !strings.HasPrefix(s, "hostguard/") && s != ".parked" && !strings.HasPrefix(s, ".parked/")
+}
+
+func (l Local) chore(c *hostclaim.Claim, captured hostguard.Captured, id, op string) (hostclaim.Obligation, error) {
+ if c == nil {
+ return hostclaim.Obligation{}, ErrLocalIdentity
+ }
+ var found *hostclaim.Obligation
+ for i := range c.Obligations {
+ if c.Obligations[i].ID == id && c.Obligations[i].Op == op {
+ found = &c.Obligations[i]
+ break
+ }
+ }
+ if found == nil {
+ return hostclaim.Obligation{}, errLocalComplete
+ }
+ if c.Holder == nil || c.Holder.Incarnation != captured.Incarnation || found.Kind != "park-local" || found.Box != l.Box || found.Owner != "box:"+l.Box || !localComponent(found.Retirement) {
+ return hostclaim.Obligation{}, ErrLocalIdentity
+ }
+ if _, ok := c.Inventory[l.Box]; !ok {
+ return hostclaim.Obligation{}, ErrLocalInventory
+ }
+ return *found, nil
+}
+
+func (l Local) RetireArtifacts(ctx context.Context, host string, captured hostguard.Captured, id, op string, stamp uint64) error {
+ if stamp == 0 {
+ return ErrLocalIdentity
+ }
+ return l.retire(ctx, host, captured, id, op, "artifacts", stamp)
+}
+
+func (l Local) RetireInstallation(ctx context.Context, host string, captured hostguard.Captured, id, op string) error {
+ return l.retire(ctx, host, captured, id, op, "installation", 0)
+}
+
+func (l Local) retire(ctx context.Context, host string, captured hostguard.Captured, id, op, mode string, stamp uint64) error {
+ if !localSupported() {
+ return ErrLocalUnsupported
+ }
+ if ctx == nil || l.Authorize == nil || l.Claims == nil || l.Box == "" || l.DataDir == "" || l.Guard.DataDir != l.DataDir || (captured.Stamp == 0 && mode != "empty") || id == "" || op == "" {
+ return ErrLocalIdentity
+ }
+ obs, err := hostclaim.Read(ctx, l.Claims, host)
+ if err != nil {
+ return err
+ }
+ if obs.Absent {
+ return ErrLocalIdentity
+ }
+ if _, err := l.chore(obs.Claim, captured, id, op); err != nil {
+ if errors.Is(err, errLocalComplete) {
+ return nil
+ }
+ return err
+ }
+ var j localJournal
+ var inventory map[string]uint64
+ check := func(c *hostclaim.Claim) error {
+ o, err := l.chore(c, captured, id, op)
+ if err != nil {
+ return err
+ }
+ if err := l.Authorize(ctx, c, o); err != nil {
+ return err
+ }
+ inventory = c.Inventory[l.Box].Paths
+ if mode == "empty" && len(inventory) != 0 {
+ return ErrLocalInventory
+ }
+ paths := make([]string, 0, len(inventory))
+ for p, s := range inventory {
+ if !localPath(p) || s == 0 {
+ return ErrLocalInventory
+ }
+ paths = append(paths, p)
+ }
+ sort.Strings(paths)
+ for i := range paths {
+ for k := i + 1; k < len(paths); k++ {
+ if strings.HasPrefix(paths[k], paths[i]+"/") {
+ return ErrLocalInventory
+ }
+ }
+ }
+ j = localJournal{V: 1, Host: host, Incarnation: captured.Incarnation, Op: op, Obligation: id, Retirement: o.Retirement, Mode: mode, Installation: captured.Stamp, ArtifactStamp: stamp}
+ for _, p := range paths {
+ if mode == "installation" || inventory[p] == stamp {
+ j.Items = append(j.Items, localItem{Path: p, Stamp: inventory[p]})
+ }
+ }
+ return nil
+ }
+ work := func() error {
+ if err := l.parkLocal(ctx, &j); err != nil {
+ return err
+ }
+ fresh, err := hostclaim.Read(ctx, l.Claims, host)
+ if err != nil {
+ return err
+ }
+ if fresh.Absent {
+ return ErrLocalIdentity
+ }
+ _, err = hostclaim.Update(ctx, l.Claims, fresh, func(c *hostclaim.Claim) error {
+ o, err := l.chore(c, captured, id, op)
+ if err != nil {
+ return err
+ }
+ if err := l.Authorize(ctx, c, o); err != nil {
+ return err
+ }
+ entry := c.Inventory[l.Box]
+ for _, item := range j.Items {
+ if entry.Paths[item.Path] != item.Stamp {
+ return ErrLocalInventory
+ }
+ }
+ if (mode == "installation" || mode == "empty") && len(entry.Paths) != len(j.Items) {
+ return ErrLocalInventory
+ }
+ for _, item := range j.Items {
+ delete(entry.Paths, item.Path)
+ }
+ c.Inventory[l.Box] = entry
+ return hostclaim.CompleteObligation(op, id)(c)
+ })
+ return err
+ }
+ if mode == "installation" {
+ err = l.Guard.RetireInstallationWith(ctx, host, captured, check, work)
+ } else if mode == "empty" {
+ err = l.Guard.WithNoInstallation(ctx, host, captured.Incarnation, check, work)
+ } else {
+ err = l.Guard.With(ctx, host, captured, check, work)
+ }
+ if errors.Is(err, errLocalComplete) {
+ return nil
+ }
+ return err
+}
+
+func (l Local) RecordInventory(ctx context.Context, host string, captured hostguard.Captured, layout string, paths []string, check func(*hostclaim.Claim) error) error {
+ if !localSupported() {
+ return ErrLocalUnsupported
+ }
+ if ctx == nil || l.Claims == nil || l.Box == "" || l.Guard.DataDir != l.DataDir || l.DataDir == "" || check == nil || captured.Stamp == 0 || (layout != "flat" && layout != "nested") {
+ return ErrLocalInventory
+ }
+ for _, p := range paths {
+ if !localPath(p) {
+ return ErrLocalInventory
+ }
+ }
+ return l.Guard.With(ctx, host, captured, check, func() error {
+ obs, err := hostclaim.Read(ctx, l.Claims, host)
+ if err != nil {
+ return err
+ }
+ if obs.Absent {
+ return ErrLocalIdentity
+ }
+ _, err = hostclaim.Update(ctx, l.Claims, obs, func(c *hostclaim.Claim) error {
+ if c.Holder == nil || c.Holder.Incarnation != captured.Incarnation {
+ return ErrLocalIdentity
+ }
+ if err := check(c); err != nil {
+ return err
+ }
+ if c.Inventory == nil {
+ c.Inventory = make(map[string]hostclaim.InventoryEntry)
+ }
+ entry, exists := c.Inventory[l.Box]
+ if exists && entry.Layout != layout && len(entry.Paths) != 0 {
+ return ErrLocalInventory
+ }
+ entry.Layout = layout
+ if entry.Paths == nil {
+ entry.Paths = make(map[string]uint64)
+ }
+ for _, p := range paths {
+ if old := entry.Paths[p]; old != 0 && old != captured.Stamp {
+ return ErrLocalInventory
+ }
+ entry.Paths[p] = captured.Stamp
+ }
+ all := make([]string, 0, len(entry.Paths))
+ for p, s := range entry.Paths {
+ if !localPath(p) || s == 0 {
+ return ErrLocalInventory
+ }
+ all = append(all, p)
+ }
+ sort.Strings(all)
+ for i := range all {
+ for k := i + 1; k < len(all); k++ {
+ if strings.HasPrefix(all[k], all[i]+"/") {
+ return ErrLocalInventory
+ }
+ }
+ }
+ c.Inventory[l.Box] = entry
+ return nil
+ })
+ return err
+ })
+}
+
+func (l Local) AcknowledgeEmpty(ctx context.Context, host, incarnation, id, op string) error {
+ return l.retire(ctx, host, hostguard.Captured{Incarnation: incarnation}, id, op, "empty", 0)
+}
diff --git a/internal/park/local_linux.go b/internal/park/local_linux.go
new file mode 100644
index 000000000..a9866a923
--- /dev/null
+++ b/internal/park/local_linux.go
@@ -0,0 +1,348 @@
+//go:build linux
+
+package park
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "reflect"
+ "strings"
+
+ "golang.org/x/sys/unix"
+)
+
+func localSupported() bool { return true }
+
+func localDirAt(parent int, name string, create bool) (int, error) {
+ if !localComponent(name) {
+ return -1, ErrLocalInventory
+ }
+ if create {
+ if err := unix.Mkdirat(parent, name, 0770); err != nil && !errors.Is(err, unix.EEXIST) {
+ return -1, err
+ }
+ }
+ fd, err := unix.Openat(parent, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
+ if err != nil {
+ return -1, err
+ }
+ var st unix.Stat_t
+ if err := unix.Fstat(fd, &st); err != nil || st.Mode&unix.S_IFMT != unix.S_IFDIR {
+ unix.Close(fd)
+ if err != nil {
+ return -1, err
+ }
+ return -1, ErrLocalInventory
+ }
+ if create {
+ // A retry can encounter another worker's directory. Root also respects its owner.
+ if st.Uid == uint32(os.Geteuid()) {
+ if err := unix.Fchmod(fd, 02770); err != nil {
+ unix.Close(fd)
+ return -1, err
+ }
+ } else if st.Mode&02770 != 02770 {
+ unix.Close(fd)
+ return -1, ErrLocalInventory
+ }
+ if err := unix.Fsync(fd); err != nil {
+ unix.Close(fd)
+ return -1, err
+ }
+ if err := unix.Fsync(parent); err != nil {
+ unix.Close(fd)
+ return -1, err
+ }
+ }
+ return fd, nil
+}
+
+func localWalk(root int, parts []string, create bool) (int, error) {
+ fd, err := unix.Dup(root)
+ if err != nil {
+ return -1, err
+ }
+ unix.CloseOnExec(fd)
+ for _, p := range parts {
+ next, err := localDirAt(fd, p, create)
+ unix.Close(fd)
+ if err != nil {
+ return -1, err
+ }
+ fd = next
+ }
+ return fd, nil
+}
+
+func localParent(root int, p string, create bool) (int, string, error) {
+ if !localPath(p) {
+ return -1, "", ErrLocalInventory
+ }
+ parts := strings.Split(p, "/")
+ fd, err := localWalk(root, parts[:len(parts)-1], create)
+ return fd, parts[len(parts)-1], err
+}
+
+func localStat(fd int, name string) (unix.Stat_t, error) {
+ var st unix.Stat_t
+ err := unix.Fstatat(fd, name, &st, unix.AT_SYMLINK_NOFOLLOW)
+ if err == nil && st.Mode&unix.S_IFMT != unix.S_IFDIR && st.Mode&unix.S_IFMT != unix.S_IFREG {
+ return st, ErrLocalInventory
+ }
+ return st, err
+}
+
+func localSame(st unix.Stat_t, item localItem) bool {
+ return uint64(st.Dev) == item.Dev && st.Ino == item.Ino && st.Mode&unix.S_IFMT == item.Type
+}
+
+func localReadJournal(dir int) (*localJournal, error) {
+ fd, err := unix.Openat(dir, ".journal.json", unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0)
+ if err != nil {
+ return nil, err
+ }
+ f := os.NewFile(uintptr(fd), "journal")
+ defer f.Close()
+ st, err := f.Stat()
+ if err != nil {
+ return nil, err
+ }
+ if !st.Mode().IsRegular() {
+ return nil, ErrLocalJournal
+ }
+ b, err := io.ReadAll(io.LimitReader(f, (4<<20)+1))
+ if err != nil {
+ return nil, err
+ }
+ if len(b) > 4<<20 {
+ return nil, ErrLocalJournal
+ }
+ d := json.NewDecoder(bytes.NewReader(b))
+ d.DisallowUnknownFields()
+ var j localJournal
+ if err := d.Decode(&j); err != nil {
+ return nil, err
+ }
+ if _, err := d.Token(); err != io.EOF {
+ return nil, ErrLocalJournal
+ }
+ return &j, nil
+}
+
+func localWriteJournal(dir int, j *localJournal) error {
+ b, err := json.Marshal(j)
+ if err != nil {
+ return err
+ }
+ var nonce [16]byte
+ if _, err := rand.Read(nonce[:]); err != nil {
+ return err
+ }
+ name := ".journal-" + hex.EncodeToString(nonce[:])
+ fd, err := unix.Openat(dir, name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0660)
+ if err != nil {
+ return err
+ }
+ f := os.NewFile(uintptr(fd), "journal-temp")
+ defer f.Close()
+ defer unix.Unlinkat(dir, name, 0)
+ if err := unix.Fchmod(fd, 0660); err != nil {
+ return err
+ }
+ if _, err := f.Write(b); err != nil {
+ return err
+ }
+ if err := f.Sync(); err != nil {
+ return err
+ }
+ if err := unix.Renameat(dir, name, dir, ".journal.json"); err != nil {
+ return err
+ }
+ return unix.Fsync(dir)
+}
+
+func localJournalMatches(a, b localJournal) bool {
+ if len(a.Items) != len(b.Items) {
+ return false
+ }
+ for i := range a.Items {
+ if a.Items[i].Path != b.Items[i].Path || a.Items[i].Stamp != b.Items[i].Stamp {
+ return false
+ }
+ if a.Items[i].Missing {
+ if a.Items[i].Dev != 0 || a.Items[i].Ino != 0 || a.Items[i].Type != 0 {
+ return false
+ }
+ } else if a.Items[i].Ino == 0 || (a.Items[i].Type != unix.S_IFDIR && a.Items[i].Type != unix.S_IFREG) {
+ return false
+ }
+ }
+ a.Items, b.Items = nil, nil
+ return reflect.DeepEqual(a, b)
+}
+
+func (l Local) parkLocal(ctx context.Context, want *localJournal) error {
+ root, err := unix.Open(l.DataDir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
+ if err != nil {
+ return err
+ }
+ defer unix.Close(root)
+ parked, err := localDirAt(root, ".parked", false)
+ if err != nil {
+ return err
+ }
+ defer unix.Close(parked)
+ event, err := localWalk(parked, []string{want.Incarnation, want.Host, want.Retirement}, true)
+ if err != nil {
+ return err
+ }
+ defer unix.Close(event)
+ dest, err := localDirAt(event, "artifacts", true)
+ if err != nil {
+ return err
+ }
+ defer unix.Close(dest)
+ prior, err := localReadJournal(event)
+ if errors.Is(err, unix.ENOENT) {
+ for i := range want.Items {
+ it := &want.Items[i]
+ src, name, e := localParent(root, it.Path, false)
+ if errors.Is(e, unix.ENOENT) {
+ it.Missing = true
+ continue
+ }
+ if e != nil {
+ return e
+ }
+ st, e := localStat(src, name)
+ unix.Close(src)
+ if errors.Is(e, unix.ENOENT) {
+ it.Missing = true
+ continue
+ }
+ if e != nil {
+ return e
+ }
+ it.Dev, it.Ino, it.Type = uint64(st.Dev), st.Ino, st.Mode&unix.S_IFMT
+ dp, dn, e := localParent(dest, it.Path, true)
+ if e != nil {
+ return e
+ }
+ _, e = localStat(dp, dn)
+ unix.Close(dp)
+ if !errors.Is(e, unix.ENOENT) {
+ if e != nil {
+ return e
+ }
+ return ErrLocalJournal
+ }
+ }
+ if err := localWriteJournal(event, want); err != nil {
+ return err
+ }
+ } else if err != nil {
+ return err
+ } else {
+ if !localJournalMatches(*prior, *want) {
+ return ErrLocalJournal
+ }
+ *want = *prior
+ }
+ for i := range want.Items {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ it := &want.Items[i]
+ if it.Missing {
+ // A still-active installation can recreate an absent path between attempts.
+ sp, sn, err := localParent(root, it.Path, false)
+ if err == nil {
+ _, err = localStat(sp, sn)
+ unix.Close(sp)
+ }
+ if !errors.Is(err, unix.ENOENT) {
+ if err != nil {
+ return err
+ }
+ return ErrLocalJournal
+ }
+ it.Done = true
+ continue
+ }
+ dp, dn, err := localParent(dest, it.Path, true)
+ if err != nil {
+ return err
+ }
+ sp, sn, err := localParent(root, it.Path, false)
+ if err != nil {
+ unix.Close(dp)
+ return err
+ }
+ err = l.moveLocalItem(sp, sn, dp, dn, it)
+ if err == nil {
+ err = unix.Fsync(sp)
+ }
+ if err == nil {
+ err = unix.Fsync(dp)
+ }
+ unix.Close(sp)
+ unix.Close(dp)
+ if err != nil {
+ return err
+ }
+ it.Done = true
+ if err := localWriteJournal(event, want); err != nil {
+ return err
+ }
+ }
+ if err := localWriteJournal(event, want); err != nil {
+ return err
+ }
+ if err := unix.Fsync(dest); err != nil {
+ return err
+ }
+ return unix.Fsync(event)
+}
+
+func (l Local) moveLocalItem(sp int, sn string, dp int, dn string, it *localItem) error {
+ dst, de := localStat(dp, dn)
+ if de == nil {
+ if !localSame(dst, *it) {
+ return ErrLocalJournal
+ }
+ _, se := localStat(sp, sn)
+ if se == nil {
+ return ErrLocalJournal
+ }
+ if se != nil && !errors.Is(se, unix.ENOENT) {
+ return se
+ }
+ return nil
+ }
+ if !errors.Is(de, unix.ENOENT) {
+ return de
+ }
+ if it.Done {
+ return ErrLocalJournal
+ }
+ src, err := localStat(sp, sn)
+ if err != nil {
+ return err
+ }
+ if !localSame(src, *it) {
+ return ErrLocalJournal
+ }
+ if err := unix.Renameat2(sp, sn, dp, dn, unix.RENAME_NOREPLACE); err != nil {
+ return err
+ }
+ if l.afterRename != nil {
+ return l.afterRename(it.Path)
+ }
+ return nil
+}
diff --git a/internal/park/local_linux_test.go b/internal/park/local_linux_test.go
new file mode 100644
index 000000000..aae79d7f9
--- /dev/null
+++ b/internal/park/local_linux_test.go
@@ -0,0 +1,673 @@
+//go:build linux
+
+package park
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "syscall"
+ "testing"
+ "time"
+
+ "golang.org/x/sys/unix"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type localFixture struct {
+ l Local
+ host string
+ captured hostguard.Captured
+}
+
+func localFixtureFor(t *testing.T, paths map[string]uint64) localFixture {
+ t.Helper()
+ host := "shop.bab.oncarlos.com"
+ data, locks := t.TempDir(), t.TempDir()
+ for _, name := range []string{"hostguard", ".parked"} {
+ if err := os.Mkdir(filepath.Join(data, name), 0770); err != nil {
+ t.Fatal(err)
+ }
+ }
+ s := release.NewDirStore(t.TempDir())
+ at := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)
+ inc, err := hostclaim.Incarnation("bab", "jam", host, at)
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := &hostclaim.Claim{V: 2, Host: host, Gen: 5,
+ Holder: &hostclaim.Holder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", CreatedAt: at, Incarnation: inc, Location: "instances/" + host + "/" + inc, Stamp: 5},
+ Op: hostclaim.Op{Kind: hostclaim.KindParking, ID: "delete", Exec: "box"},
+ Inventory: map[string]hostclaim.InventoryEntry{"box": {Layout: "flat", Paths: paths}},
+ Obligations: []hostclaim.Obligation{{ID: "local", Op: "delete", Kind: "park-local", Box: "box", Owner: "box:box", Retirement: "retirement"}}}
+ if err := c.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ b, err := json.Marshal(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, err := hostclaim.Key(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(t.Context(), key, b, ""); err != nil {
+ t.Fatal(err)
+ }
+ g := hostguard.Guard{DataDir: data, LockDir: locks, Claims: hostguard.StoreReader{Store: s}}
+ if err := g.Activate(t.Context(), host, "box", nil); err != nil {
+ t.Fatal(err)
+ }
+ capture, err := hostguard.Capture(data, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return localFixture{l: Local{Authorize: func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return nil }, Guard: g, Claims: s, DataDir: data, Box: "box"}, host: host, captured: capture}
+}
+
+func (f localFixture) write(t *testing.T, name, body string) {
+ t.Helper()
+ p := filepath.Join(f.l.DataDir, name)
+ if err := os.MkdirAll(filepath.Dir(p), 0770); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, []byte(body), 0660); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func (f localFixture) parked(name string) string {
+ return filepath.Join(f.l.DataDir, ".parked", f.captured.Incarnation, f.host, "retirement", "artifacts", name)
+}
+
+func TestLocalArtifactStampAndInstallationRetirement(t *testing.T) {
+ for _, mode := range []string{"artifacts", "installation"} {
+ t.Run(mode, func(t *testing.T) {
+ f := localFixtureFor(t, map[string]uint64{"old.db": 3, "new.db": 5, "nested/state": 3})
+ for _, p := range []string{"old.db", "new.db", "nested/state"} {
+ f.write(t, p, p)
+ }
+ var err error
+ if mode == "artifacts" {
+ err = f.l.RetireArtifacts(t.Context(), f.host, f.captured, "local", "delete", 3)
+ } else {
+ err = f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete")
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, p := range []string{"old.db", "nested/state"} {
+ if b, err := os.ReadFile(f.parked(p)); err != nil || string(b) != p {
+ t.Fatalf("parked %s: %q %v", p, b, err)
+ }
+ }
+ got, err := hostguard.Capture(f.l.DataDir, f.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if mode == "artifacts" {
+ if got != f.captured {
+ t.Fatal("artifact retirement changed stamp")
+ }
+ if _, err := os.Stat(filepath.Join(f.l.DataDir, "new.db")); err != nil {
+ t.Fatal(err)
+ }
+ } else if got.Stamp != 0 {
+ t.Fatal("installation still active")
+ }
+ obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(obs.Claim.Obligations) != 0 {
+ t.Fatal("not acknowledged")
+ }
+ want := 0
+ if mode == "artifacts" {
+ want = 1
+ }
+ if len(obs.Claim.Inventory["box"].Paths) != want {
+ t.Fatal("inventory mismatch")
+ }
+ if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil {
+ t.Fatal("completion retry", err)
+ }
+ })
+ }
+}
+
+func TestLocalResumeAfterEveryRenameHoldsGuard(t *testing.T) {
+ for _, stop := range []string{"a", "b"} {
+ t.Run(stop, func(t *testing.T) {
+ f := localFixtureFor(t, map[string]uint64{"a": 5, "b": 5})
+ f.write(t, "a", "a")
+ f.write(t, "b", "b")
+ crash := errors.New("crash after rename")
+ f.l.afterRename = func(p string) error {
+ if p != stop {
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond)
+ defer cancel()
+ if err := f.l.Guard.With(ctx, f.host, f.captured, func(*hostclaim.Claim) error { return nil }, func() error { t.Fatal("lock released inside retirement"); return nil }); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("competing writer %v", err)
+ }
+ return crash
+ }
+ if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, crash) {
+ t.Fatal(err)
+ }
+ current, err := hostguard.Capture(f.l.DataDir, f.host)
+ if err != nil || current.Stamp != 0 {
+ t.Fatalf("not retired before rename: %+v %v", current, err)
+ }
+ obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
+ if err != nil || len(obs.Claim.Obligations) != 1 {
+ t.Fatal("early completion", err)
+ }
+ f.l.afterRename = nil
+ if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil {
+ t.Fatal(err)
+ }
+ for _, p := range []string{"a", "b"} {
+ if b, err := os.ReadFile(f.parked(p)); err != nil || string(b) != p {
+ t.Fatalf("lost %s", p)
+ }
+ }
+ })
+ }
+}
+
+func TestLocalRestrictiveUmask(t *testing.T) {
+ // Umask is process-wide: this test and its fixture setup must not run in parallel.
+ f := localFixtureFor(t, map[string]uint64{"nested/a": 5, "nested/b": 5})
+ f.write(t, "nested/a", "a")
+ f.write(t, "nested/b", "b")
+ group := os.Getegid()
+ if os.Geteuid() == 0 {
+ group = 65534
+ }
+ for _, path := range []string{f.l.DataDir, filepath.Join(f.l.DataDir, ".parked"), filepath.Join(f.l.DataDir, "nested")} {
+ dir, err := os.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if os.Geteuid() == 0 {
+ if err := dir.Chown(0, group); err != nil {
+ dir.Close()
+ t.Fatal(err)
+ }
+ }
+ if err := dir.Chmod(os.ModeSetgid | 0770); err != nil {
+ dir.Close()
+ t.Fatal(err)
+ }
+ dir.Close()
+ }
+ oldUmask := syscall.Umask(0o077)
+ defer syscall.Umask(oldUmask)
+ crash := errors.New("crash under restrictive umask")
+ f.l.afterRename = func(string) error { return crash }
+ if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, crash) {
+ t.Fatal(err)
+ }
+ base := filepath.Join(f.l.DataDir, ".parked", f.captured.Incarnation)
+ event := filepath.Join(base, f.host, "retirement")
+ for _, path := range []string{base, filepath.Join(base, f.host), event, filepath.Join(event, "artifacts"), filepath.Join(event, "artifacts", "nested")} {
+ var st unix.Stat_t
+ if err := unix.Lstat(path, &st); err != nil {
+ t.Fatal(err)
+ }
+ if st.Mode&unix.S_IFMT != unix.S_IFDIR || st.Mode&07777 != 02770 || st.Gid != uint32(group) {
+ t.Fatalf("archive directory %s: mode=%#o gid=%d", path, st.Mode, st.Gid)
+ }
+ }
+ if os.Geteuid() == 0 {
+ body, err := os.ReadFile(filepath.Join(event, ".journal.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ dir, err := os.Open(f.l.DataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer dir.Close()
+ exe, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ run := func(refuse bool) {
+ t.Helper()
+ cmd := exec.Command(exe, "-test.run=^TestLocalRestrictiveUmaskWorker$")
+ cmd.ExtraFiles = []*os.File{dir}
+ cmd.Env = append(os.Environ(), "PARK_UMASK_WORKER=1", "PARK_UMASK_JOURNAL="+string(body))
+ if refuse {
+ cmd.Env = append(cmd.Env, "PARK_UMASK_REFUSE=1")
+ }
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("group worker: %v\n%s", err, out)
+ }
+ }
+ // A worker must refuse, not chmod, directories owned by another UID.
+ for _, mode := range []os.FileMode{os.ModeSetgid | 0750, 0770} {
+ path := filepath.Join(event, "artifacts")
+ if err := os.Chmod(path, mode); err != nil {
+ t.Fatal(err)
+ }
+ run(true)
+ st, err := os.Stat(path)
+ if err != nil || st.Mode()&(os.ModeSetgid|0777) != mode {
+ t.Fatal("foreign directory permissions changed", err)
+ }
+ if err := os.Chmod(path, os.ModeSetgid|0770); err != nil {
+ t.Fatal(err)
+ }
+ }
+ run(false)
+ } else {
+ t.Log("mode and group assertions passed; cross-user subprocess requires root")
+ }
+ f.l.afterRename = nil
+ if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"a", "b"} {
+ if body, err := os.ReadFile(f.parked("nested/" + name)); err != nil || string(body) != name {
+ t.Fatalf("resumed artifact %s: %q %v", name, body, err)
+ }
+ }
+ obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
+ if err != nil || len(obs.Claim.Obligations) != 0 {
+ t.Fatal("resumed retirement not acknowledged", err)
+ }
+}
+
+func TestLocalRestrictiveUmaskWorker(t *testing.T) {
+ if os.Getenv("PARK_UMASK_WORKER") != "1" {
+ t.Skip("subprocess helper")
+ }
+ var want localJournal
+ if err := json.Unmarshal([]byte(os.Getenv("PARK_UMASK_JOURNAL")), &want); err != nil {
+ t.Fatal(err)
+ }
+ // Enter the fixture through an inherited descriptor; its temp ancestors may be private.
+ if err := unix.Fchdir(3); err != nil {
+ t.Fatal(err)
+ }
+ for _, change := range []func() error{
+ func() error { return syscall.Setgroups([]int{65534}) },
+ func() error { return syscall.Setgid(65534) },
+ func() error { return syscall.Setuid(65534) },
+ } {
+ if err := change(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ oldUmask := syscall.Umask(0o077)
+ defer syscall.Umask(oldUmask)
+ err := (Local{DataDir: "."}).parkLocal(t.Context(), &want)
+ if os.Getenv("PARK_UMASK_REFUSE") == "1" {
+ if !errors.Is(err, ErrLocalInventory) {
+ t.Fatalf("foreign directory without required mode accepted: %v", err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat("nested/b"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("second process did not retire remaining artifact", err)
+ }
+}
+
+func TestLocalRefusesSymlinksAndOverlaps(t *testing.T) {
+ for _, place := range []string{"source", "ancestor", "park-root", "destination", "overlap", "missing-inventory"} {
+ t.Run(place, func(t *testing.T) {
+ paths := map[string]uint64{"sub/a": 5}
+ if place == "overlap" {
+ paths["sub"] = 3
+ }
+ f := localFixtureFor(t, paths)
+ f.write(t, "sub/a", "source")
+ outside := t.TempDir()
+ sentinel := filepath.Join(outside, "a")
+ if err := os.WriteFile(sentinel, []byte("sentinel"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ switch place {
+ case "source":
+ if err := os.Remove(filepath.Join(f.l.DataDir, "sub/a")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(sentinel, filepath.Join(f.l.DataDir, "sub/a")); err != nil {
+ t.Fatal(err)
+ }
+ case "ancestor":
+ if err := os.Rename(filepath.Join(f.l.DataDir, "sub"), filepath.Join(f.l.DataDir, "safe")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outside, filepath.Join(f.l.DataDir, "sub")); err != nil {
+ t.Fatal(err)
+ }
+ case "park-root":
+ if err := os.Remove(filepath.Join(f.l.DataDir, ".parked")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outside, filepath.Join(f.l.DataDir, ".parked")); err != nil {
+ t.Fatal(err)
+ }
+ case "destination":
+ if err := os.MkdirAll(filepath.Dir(f.parked("sub/a")), 0770); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(sentinel, f.parked("sub/a")); err != nil {
+ t.Fatal(err)
+ }
+ case "missing-inventory":
+ obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := hostclaim.Update(t.Context(), f.l.Claims, obs, func(c *hostclaim.Claim) error { delete(c.Inventory, "box"); return nil }); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err == nil {
+ t.Fatal("unsafe retirement accepted")
+ }
+ if b, err := os.ReadFile(sentinel); err != nil || string(b) != "sentinel" {
+ t.Fatal("symlink target changed")
+ }
+ obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
+ if err != nil || len(obs.Claim.Obligations) != 1 {
+ t.Fatal("acknowledged failure", err)
+ }
+ })
+ }
+}
+
+func TestLocalEmptyAndAbsentArtifactsAcknowledge(t *testing.T) {
+ for _, paths := range []map[string]uint64{{}, {"missing/file": 5}} {
+ f := localFixtureFor(t, paths)
+ if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil {
+ t.Fatal(err)
+ }
+ obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
+ if err != nil || len(obs.Claim.Obligations) != 0 {
+ t.Fatal("not complete", err)
+ }
+ }
+}
+
+func TestLocalOldRetirementCannotFenceSuccessor(t *testing.T) {
+ f := localFixtureFor(t, map[string]uint64{"a": 5})
+ f.write(t, "a", "old")
+ f.l.afterRename = func(string) error { return errors.New("crash") }
+ if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err == nil {
+ t.Fatal("expected crash")
+ }
+ obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := hostclaim.Update(t.Context(), f.l.Claims, obs, func(c *hostclaim.Claim) error {
+ c.Holder.Stamp = c.Gen + 1
+ c.Inventory["box"].Paths["a"] = c.Gen + 1
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.l.Guard.Activate(t.Context(), f.host, "box", nil); err != nil {
+ t.Fatal(err)
+ }
+ f.write(t, "a", "successor")
+ f.l.afterRename = nil
+ if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, hostguard.ErrStampMismatch) {
+ t.Fatal(err)
+ }
+ if b, err := os.ReadFile(filepath.Join(f.l.DataDir, "a")); err != nil || string(b) != "successor" {
+ t.Fatal("successor damaged")
+ }
+}
+
+func TestLocalArtifactResumeRefusesReusedSource(t *testing.T) {
+ f := localFixtureFor(t, map[string]uint64{"a": 3})
+ f.write(t, "a", "predecessor")
+ f.l.afterRename = func(string) error { return errors.New("crash") }
+ if err := f.l.RetireArtifacts(t.Context(), f.host, f.captured, "local", "delete", 3); err == nil {
+ t.Fatal("expected crash")
+ }
+ f.write(t, "a", "replacement")
+ f.l.afterRename = nil
+ if err := f.l.RetireArtifacts(t.Context(), f.host, f.captured, "local", "delete", 3); !errors.Is(err, ErrLocalJournal) {
+ t.Fatal(err)
+ }
+ if b, err := os.ReadFile(filepath.Join(f.l.DataDir, "a")); err != nil || string(b) != "replacement" {
+ t.Fatal("replacement changed")
+ }
+ if b, err := os.ReadFile(f.parked("a")); err != nil || string(b) != "predecessor" {
+ t.Fatal("archive changed")
+ }
+ obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
+ if err != nil || len(obs.Claim.Obligations) != 1 || len(obs.Claim.Inventory["box"].Paths) != 1 {
+ t.Fatal("source reuse acknowledged", err)
+ }
+}
+
+func TestLocalMissingPathRecreatedAfterCrash(t *testing.T) {
+ for _, kind := range []string{"file", "directory", "leaf-symlink", "ancestor-symlink", "still-missing"} {
+ t.Run(kind, func(t *testing.T) {
+ missing := "a-missing"
+ if kind == "ancestor-symlink" {
+ missing += "/child"
+ }
+ f := localFixtureFor(t, map[string]uint64{missing: 5, "z-present": 5})
+ f.write(t, "z-present", "old")
+ crash := errors.New("crash after missing check and artifact rename")
+ f.l.afterRename = func(string) error { return crash }
+ if err := f.l.RetireArtifacts(t.Context(), f.host, f.captured, "local", "delete", 5); !errors.Is(err, crash) {
+ t.Fatal(err)
+ }
+ f.l.afterRename = nil
+ outside := t.TempDir()
+ target := filepath.Join(outside, "child")
+ if err := os.WriteFile(target, []byte("outside"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(f.l.DataDir, "a-missing")
+ switch kind {
+ case "file":
+ f.write(t, missing, "new")
+ case "directory":
+ if err := os.Mkdir(path, 0770); err != nil {
+ t.Fatal(err)
+ }
+ case "leaf-symlink":
+ if err := os.Symlink(target, path); err != nil {
+ t.Fatal(err)
+ }
+ case "ancestor-symlink":
+ if err := os.Symlink(outside, path); err != nil {
+ t.Fatal(err)
+ }
+ }
+ err := f.l.RetireArtifacts(t.Context(), f.host, f.captured, "local", "delete", 5)
+ if kind == "still-missing" {
+ if err != nil {
+ t.Fatal(err)
+ }
+ } else if err == nil {
+ t.Fatal("acknowledged a recreated missing path")
+ }
+ obs, readErr := hostclaim.Read(t.Context(), f.l.Claims, f.host)
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ if kind == "still-missing" {
+ if len(obs.Claim.Obligations) != 0 || len(obs.Claim.Inventory["box"].Paths) != 0 {
+ t.Fatal("missing path did not acknowledge")
+ }
+ } else {
+ if len(obs.Claim.Obligations) != 1 || len(obs.Claim.Inventory["box"].Paths) != 2 {
+ t.Fatal("refusal changed inventory or chore")
+ }
+ if _, err := os.Lstat(path); err != nil {
+ t.Fatal("recreated artifact lost", err)
+ }
+ }
+ if got, err := hostguard.Capture(f.l.DataDir, f.host); err != nil || got != f.captured {
+ t.Fatal("artifact retirement changed installation", err)
+ }
+ if b, err := os.ReadFile(target); err != nil || string(b) != "outside" {
+ t.Fatal("followed planted symlink", err)
+ }
+ if b, err := os.ReadFile(f.parked("z-present")); err != nil || string(b) != "old" {
+ t.Fatal("lost parked artifact", err)
+ }
+ })
+ }
+}
+
+func TestLocalResolvedDirectoryAndDestinationConflict(t *testing.T) {
+ t.Run("nested directory", func(t *testing.T) {
+ f := localFixtureFor(t, map[string]uint64{"nested/instance": 5})
+ f.write(t, "nested/instance/data/db", "database")
+ if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil {
+ t.Fatal(err)
+ }
+ if b, err := os.ReadFile(f.parked("nested/instance/data/db")); err != nil || string(b) != "database" {
+ t.Fatal("nested tree lost", err)
+ }
+ })
+ t.Run("destination conflict", func(t *testing.T) {
+ f := localFixtureFor(t, map[string]uint64{"a": 5})
+ f.write(t, "a", "source")
+ if err := os.MkdirAll(filepath.Dir(f.parked("a")), 0770); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(f.parked("a"), []byte("foreign"), 0660); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, ErrLocalJournal) {
+ t.Fatal(err)
+ }
+ if b, err := os.ReadFile(f.parked("a")); err != nil || string(b) != "foreign" {
+ t.Fatal("destination overwritten")
+ }
+ if b, err := os.ReadFile(filepath.Join(f.l.DataDir, "a")); err != nil || string(b) != "source" {
+ t.Fatal("source lost")
+ }
+ })
+}
+
+func TestLocalAuthorizationAndInventoryEnrollment(t *testing.T) {
+ t.Run("authorization", func(t *testing.T) {
+ f := localFixtureFor(t, map[string]uint64{"a": 5})
+ f.write(t, "a", "data")
+ denied := errors.New("stop evidence unavailable")
+ f.l.Authorize = func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return denied }
+ if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, denied) {
+ t.Fatal(err)
+ }
+ got, err := hostguard.Capture(f.l.DataDir, f.host)
+ if err != nil || got != f.captured {
+ t.Fatal("unauthorized stamp mutation", err)
+ }
+ if _, err := os.Stat(filepath.Join(f.l.DataDir, "a")); err != nil {
+ t.Fatal(err)
+ }
+ })
+ t.Run("enrollment", func(t *testing.T) {
+ f := localFixtureFor(t, map[string]uint64{"old": 3})
+ allow := func(*hostclaim.Claim) error { return nil }
+ if err := f.l.RecordInventory(t.Context(), f.host, f.captured, "flat", []string{"new"}, allow); err != nil {
+ t.Fatal(err)
+ }
+ obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if obs.Claim.Inventory["box"].Paths["old"] != 3 || obs.Claim.Inventory["box"].Paths["new"] != 5 {
+ t.Fatal("inventory was replaced")
+ }
+ if _, err := os.Stat(filepath.Join(f.l.DataDir, "new")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("enrollment materialized state")
+ }
+ for _, p := range []string{"old", "old/child", "new/child", "../escape", "hostguard/record", ".parked/file"} {
+ if err := f.l.RecordInventory(t.Context(), f.host, f.captured, "flat", []string{p}, allow); !errors.Is(err, ErrLocalInventory) {
+ t.Fatalf("%s: %v", p, err)
+ }
+ }
+ if err := f.l.RecordInventory(t.Context(), f.host, f.captured, "nested", nil, allow); !errors.Is(err, ErrLocalInventory) {
+ t.Fatal("changed populated layout", err)
+ }
+ })
+}
+
+func TestLocalCompletionRechecksAuthority(t *testing.T) {
+ f := localFixtureFor(t, map[string]uint64{"a": 5})
+ f.write(t, "a", "data")
+ denied := errors.New("fence lost before acknowledgement")
+ calls := 0
+ f.l.Authorize = func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error {
+ calls++
+ if calls > 1 {
+ return denied
+ }
+ return nil
+ }
+ if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); !errors.Is(err, denied) {
+ t.Fatal(err)
+ }
+ obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
+ if err != nil || len(obs.Claim.Obligations) != 1 {
+ t.Fatal("completed without authority", err)
+ }
+ f.l.Authorize = func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return nil }
+ if err := f.l.RetireInstallation(t.Context(), f.host, f.captured, "local", "delete"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestLocalUntouchedBoxAcknowledgement(t *testing.T) {
+ for _, kind := range []string{"absent-record", "active-record", "corrupt-record", "nonempty-inventory"} {
+ t.Run(kind, func(t *testing.T) {
+ paths := map[string]uint64{}
+ if kind == "nonempty-inventory" {
+ paths["a"] = 5
+ }
+ f := localFixtureFor(t, paths)
+ record := filepath.Join(f.l.DataDir, "hostguard", f.host+".json")
+ if kind == "corrupt-record" {
+ if err := os.WriteFile(record, []byte("{"), 0660); err != nil {
+ t.Fatal(err)
+ }
+ } else if kind != "active-record" {
+ if err := os.Remove(record); err != nil {
+ t.Fatal(err)
+ }
+ }
+ err := f.l.AcknowledgeEmpty(t.Context(), f.host, f.captured.Incarnation, "local", "delete")
+ if kind == "absent-record" {
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs, err := hostclaim.Read(t.Context(), f.l.Claims, f.host)
+ if err != nil || len(obs.Claim.Obligations) != 0 {
+ t.Fatal("empty box not acknowledged", err)
+ }
+ if _, err := os.Stat(record); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("empty retirement created stamp", err)
+ }
+ } else if err == nil {
+ t.Fatal("unsafe empty acknowledgement")
+ }
+ })
+ }
+}
diff --git a/internal/park/local_other.go b/internal/park/local_other.go
new file mode 100644
index 000000000..6f4ff1621
--- /dev/null
+++ b/internal/park/local_other.go
@@ -0,0 +1,9 @@
+//go:build !linux
+
+package park
+
+import "context"
+
+func (l Local) parkLocal(context.Context, *localJournal) error { return ErrLocalUnsupported }
+
+func localSupported() bool { return false }