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>
6 files changed,
+1522
−0
internal/hostguard/retirement.go+95 −0internal/hostguard/retirement_test.go+127 −0internal/park/local.go+270 −0internal/park/local_linux.go+348 −0internal/park/local_linux_test.go+673 −0internal/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 } |