From 0f68597042964567364ee1753c1baace62cbd770 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:09:03 +0100 Subject: [PATCH 01/13] feat: add work-package identity and cargo registry foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First increment of work-state ferrying (plan 2026-07-18): project identity as the abcd-compatible root-SHA set, computed with an isolated git environment (an inherited GIT_DIR must never answer for another repo — the issue-#17 leak shape), refusing shallow clones, linked worktrees, and commit-less repos; and the data-driven cargo registry mapping the abcd layout and Claude Code harness to their work-state locations and receive policies. Not wired to any command yet — the work verbs land in later increments on this branch. Assisted-by: Claude:claude-fable-5 --- internal/work/doc.go | 16 +++ internal/work/identity.go | 191 +++++++++++++++++++++++++++++++++ internal/work/identity_test.go | 170 +++++++++++++++++++++++++++++ internal/work/registry.go | 120 +++++++++++++++++++++ internal/work/registry_test.go | 75 +++++++++++++ 5 files changed, 572 insertions(+) create mode 100644 internal/work/doc.go create mode 100644 internal/work/identity.go create mode 100644 internal/work/identity_test.go create mode 100644 internal/work/registry.go create mode 100644 internal/work/registry_test.go diff --git a/internal/work/doc.go b/internal/work/doc.go new file mode 100644 index 0000000..785a3bf --- /dev/null +++ b/internal/work/doc.go @@ -0,0 +1,16 @@ +// Package work implements work-state ferrying: carrying a project's in-flight +// working context — the session handover note, the run journal, the coding +// agent's per-project memory, and the redacted transcript store — between user +// accounts as an explicit baton pass. +// +// Work state is not configuration, so this is NOT a reconcile domain: it has +// an owner (whichever account is actively working), changes every session, and +// must never silently merge. The verbs are modelled on bundle export/import +// with handover semantics: pack refuses on a dirty secret scan and writes one +// cargo bundle to the store; receive lands the latest cargo behind guarded +// overwrite / union-merge policies, backup-first; claims are advisory +// per-account files, never locks. +// +// See .abcd/development/plans/2026-07-18-work-state-ferrying.md for the +// approved design. +package work diff --git a/internal/work/identity.go b/internal/work/identity.go new file mode 100644 index 0000000..333fcc9 --- /dev/null +++ b/internal/work/identity.go @@ -0,0 +1,191 @@ +package work + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Identity is how a project repo is recognised in the cargo store: the way the +// abcd tooling identifies it, so the same value locates the account-level +// transcript store ~/.abcd/history//. +type Identity struct { + // Key is the abcd-compatible store key: the FIRST line of + // `git rev-list --max-parents=0 HEAD`. + Key string + // Roots is the full sorted root-SHA set. A subtree import can add roots + // and reorder rev-list output, so receive matches on set intersection + // rather than on Key equality alone. + Roots []string +} + +// NotARepoError reports that dir is not inside a git working tree. +type NotARepoError struct { + Dir string + Detail string +} + +func (e *NotARepoError) Error() string { + msg := "work: " + e.Dir + " is not a git repository" + if e.Detail != "" { + msg += ": " + e.Detail + } + return msg +} + +// ShallowCloneError reports a shallow clone. A shallow clone reports the graft +// boundary as its root — a bogus identity nothing else will ever match — so +// pack and receive refuse it. +type ShallowCloneError struct{ Dir string } + +func (e *ShallowCloneError) Error() string { + return "work: " + e.Dir + " is a shallow clone (its root commit is a graft boundary, not the project's identity) — run `git fetch --unshallow` first" +} + +// LinkedWorktreeError reports a linked worktree. Worktrees share a root SHA +// but .abcd/.work.local/ is per-worktree, so their batons would collide in the +// store; v1 refuses both pack and receive there. +type LinkedWorktreeError struct{ Dir string } + +func (e *LinkedWorktreeError) Error() string { + return "work: " + e.Dir + " is a linked git worktree — work verbs run only in the main worktree" +} + +// rootSHA matches a full SHA-1 or SHA-256 object name. +var rootSHA = regexp.MustCompile(`^([0-9a-f]{40}|[0-9a-f]{64})$`) + +// ProjectIdentity computes the identity of the project repo at dir, guarding +// against the identities that would poison the store: not a repo, a shallow +// clone, a linked worktree, a commit-less repo. +// +// Every git invocation runs with an isolated environment so an inherited +// GIT_DIR cannot answer for another repo (the issue-#17 leak shape). +func ProjectIdentity(dir string) (Identity, error) { + inside, err := gitOutput(dir, "rev-parse", "--is-inside-work-tree") + if err != nil || inside != "true" { + return Identity{}, &NotARepoError{Dir: dir, Detail: firstLine(err)} + } + + shallow, err := gitOutput(dir, "rev-parse", "--is-shallow-repository") + if err != nil { + return Identity{}, fmt.Errorf("work: probe shallowness of %s: %w", dir, err) + } + if shallow == "true" { + return Identity{}, &ShallowCloneError{Dir: dir} + } + + linked, err := isLinkedWorktree(dir) + if err != nil { + return Identity{}, err + } + if linked { + return Identity{}, &LinkedWorktreeError{Dir: dir} + } + + out, err := gitOutput(dir, "rev-list", "--max-parents=0", "HEAD") + if err != nil { + return Identity{}, fmt.Errorf("work: %s has no commits to identify it by: %w", dir, err) + } + lines := strings.Split(out, "\n") + roots := make([]string, 0, len(lines)) + for _, l := range lines { + l = strings.TrimSpace(l) + if l == "" { + continue + } + if !rootSHA.MatchString(l) { + return Identity{}, fmt.Errorf("work: unexpected root-commit line %q from git rev-list in %s", l, dir) + } + roots = append(roots, l) + } + if len(roots) == 0 { + return Identity{}, fmt.Errorf("work: %s has no commits to identify it by", dir) + } + key := roots[0] + sort.Strings(roots) + return Identity{Key: key, Roots: roots}, nil +} + +// isLinkedWorktree reports whether dir is a linked worktree (its git dir and +// the common git dir differ once both are resolved against dir). +func isLinkedWorktree(dir string) (bool, error) { + out, err := gitOutput(dir, "rev-parse", "--absolute-git-dir", "--git-common-dir") + if err != nil { + return false, fmt.Errorf("work: probe worktree layout of %s: %w", dir, err) + } + lines := strings.SplitN(out, "\n", 2) + if len(lines) != 2 { + return false, fmt.Errorf("work: unexpected rev-parse output %q in %s", out, dir) + } + gitDir := filepath.Clean(strings.TrimSpace(lines[0])) + common := strings.TrimSpace(lines[1]) + if !filepath.IsAbs(common) { + common = filepath.Join(dir, common) + } + common = filepath.Clean(common) + // Resolve symlinks on both sides before comparing: on macOS a repo under + // /var/folders reports its absolute git dir under /private/var, while the + // path joined from dir keeps the /var spelling — same directory, two + // spellings, and a lexical compare would misread the main worktree as + // linked. + if r, err := filepath.EvalSymlinks(gitDir); err == nil { + gitDir = r + } + if r, err := filepath.EvalSymlinks(common); err == nil { + common = r + } + return gitDir != common, nil +} + +// gitOutput runs git -C dir with the isolated environment and returns trimmed +// stdout. On failure the error carries git's stderr. +func gitOutput(dir string, args ...string) (string, error) { + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = isolatedGitEnv() + var stderr strings.Builder + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), msg) + } + return strings.TrimSpace(string(out)), nil +} + +// isolatedGitEnv is the environment for every work-package git invocation: all +// inherited GIT_* variables stripped (an explicit GIT_DIR WINS over -C +// directory discovery — the corruption incident behind issue #17), config +// discovery neutralised, prompts off. Read-only invocations need no identity. +func isolatedGitEnv() []string { + env := make([]string, 0, len(os.Environ())+3) + for _, kv := range os.Environ() { + if strings.HasPrefix(kv, "GIT_") { + continue + } + env = append(env, kv) + } + return append(env, + "GIT_TERMINAL_PROMPT=0", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + ) +} + +// firstLine reduces an error to its first line for embedding in a refusal. +func firstLine(err error) string { + if err == nil { + return "" + } + s := err.Error() + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[:i] + } + return s +} diff --git a/internal/work/identity_test.go b/internal/work/identity_test.go new file mode 100644 index 0000000..b9b9652 --- /dev/null +++ b/internal/work/identity_test.go @@ -0,0 +1,170 @@ +package work + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +var hexRoot = regexp.MustCompile(`^([0-9a-f]{40}|[0-9a-f]{64})$`) + +// gitTest runs git in dir with the same isolation the implementation uses, so +// fixtures can never touch the host repository (an inherited GIT_DIR wins over +// -C — see issue #17). +func gitTest(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(isolatedGitEnv(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@localhost", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@localhost", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out) + } + return strings.TrimSpace(string(out)) +} + +// newRepo creates a temp git repo with one commit and returns its path. +func newRepo(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + dir := t.TempDir() + gitTest(t, dir, "init", "-q") + if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("seed\n"), 0o644); err != nil { + t.Fatal(err) + } + gitTest(t, dir, "add", ".") + gitTest(t, dir, "commit", "-q", "-m", "root") + return dir +} + +func TestProjectIdentity_SingleRoot(t *testing.T) { + dir := newRepo(t) + want := gitTest(t, dir, "rev-list", "--max-parents=0", "HEAD") + + id, err := ProjectIdentity(dir) + if err != nil { + t.Fatalf("ProjectIdentity: %v", err) + } + if id.Key != want { + t.Errorf("Key = %q, want %q", id.Key, want) + } + if !hexRoot.MatchString(id.Key) { + t.Errorf("Key %q is not 40/64-char hex", id.Key) + } + if len(id.Roots) != 1 || id.Roots[0] != want { + t.Errorf("Roots = %v, want [%s]", id.Roots, want) + } +} + +func TestProjectIdentity_MultiRoot(t *testing.T) { + dir := newRepo(t) + // Second root: an orphan branch merged with --allow-unrelated-histories. + first := gitTest(t, dir, "rev-parse", "--abbrev-ref", "HEAD") + gitTest(t, dir, "checkout", "-q", "--orphan", "second") + gitTest(t, dir, "rm", "-rf", "--cached", ".") + if err := os.WriteFile(filepath.Join(dir, "g.txt"), []byte("other\n"), 0o644); err != nil { + t.Fatal(err) + } + gitTest(t, dir, "add", "g.txt") + gitTest(t, dir, "commit", "-q", "-m", "second root") + // f.txt is untracked on the orphan branch and would block the checkout. + if err := os.Remove(filepath.Join(dir, "f.txt")); err != nil { + t.Fatal(err) + } + gitTest(t, dir, "checkout", "-q", first) + gitTest(t, dir, "merge", "-q", "--allow-unrelated-histories", "-m", "join", "second") + + rawFirst := strings.SplitN(gitTest(t, dir, "rev-list", "--max-parents=0", "HEAD"), "\n", 2)[0] + + id, err := ProjectIdentity(dir) + if err != nil { + t.Fatalf("ProjectIdentity: %v", err) + } + if id.Key != rawFirst { + t.Errorf("Key = %q, want first rev-list line %q", id.Key, rawFirst) + } + if len(id.Roots) != 2 { + t.Fatalf("Roots = %v, want 2 entries", id.Roots) + } + if !sort.StringsAreSorted(id.Roots) { + t.Errorf("Roots %v not sorted", id.Roots) + } +} + +func TestProjectIdentity_NotARepo(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + _, err := ProjectIdentity(t.TempDir()) + var nre *NotARepoError + if !errors.As(err, &nre) { + t.Fatalf("err = %v, want *NotARepoError", err) + } +} + +func TestProjectIdentity_InheritedGitDirCannotAnswer(t *testing.T) { + other := newRepo(t) + // An inherited GIT_DIR pointing at another repo must NOT let a plain + // directory resolve to that repo's identity (the issue-#17 leak shape). + t.Setenv("GIT_DIR", filepath.Join(other, ".git")) + _, err := ProjectIdentity(t.TempDir()) + var nre *NotARepoError + if !errors.As(err, &nre) { + t.Fatalf("err = %v, want *NotARepoError despite inherited GIT_DIR", err) + } +} + +func TestProjectIdentity_ShallowCloneRefused(t *testing.T) { + src := newRepo(t) + // A second commit so --depth 1 actually truncates history. + if err := os.WriteFile(filepath.Join(src, "f.txt"), []byte("more\n"), 0o644); err != nil { + t.Fatal(err) + } + gitTest(t, src, "commit", "-aqm", "second") + + shallow := filepath.Join(t.TempDir(), "shallow") + gitTest(t, filepath.Dir(shallow), "clone", "-q", "--depth", "1", "file://"+src, shallow) + + _, err := ProjectIdentity(shallow) + var sce *ShallowCloneError + if !errors.As(err, &sce) { + t.Fatalf("err = %v, want *ShallowCloneError", err) + } +} + +func TestProjectIdentity_LinkedWorktreeRefused(t *testing.T) { + dir := newRepo(t) + wt := filepath.Join(t.TempDir(), "wt") + gitTest(t, dir, "worktree", "add", "-q", wt) + + _, err := ProjectIdentity(wt) + var lwe *LinkedWorktreeError + if !errors.As(err, &lwe) { + t.Fatalf("err = %v, want *LinkedWorktreeError", err) + } + + // The main worktree of the same repo stays accepted. + if _, err := ProjectIdentity(dir); err != nil { + t.Fatalf("main worktree refused: %v", err) + } +} + +func TestProjectIdentity_NoCommits(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + dir := t.TempDir() + gitTest(t, dir, "init", "-q") + if _, err := ProjectIdentity(dir); err == nil { + t.Fatal("ProjectIdentity on a commit-less repo succeeded, want error") + } +} diff --git a/internal/work/registry.go b/internal/work/registry.go new file mode 100644 index 0000000..54f66ea --- /dev/null +++ b/internal/work/registry.go @@ -0,0 +1,120 @@ +package work + +import "path/filepath" + +// The registry maps each known harness and project layout to its work-state +// locations and receive policies. The source locations are DATA, not code — +// v1 ships the abcd layout and the Claude Code harness; adding another harness +// is a registry entry, never a redesign (modelled on the agents domain's +// Builtins() harness registry). + +// ItemKind is the on-disk shape of a cargo item. +type ItemKind string + +const ( + KindFile ItemKind = "file" + KindDir ItemKind = "dir" +) + +// Policy is a cargo item's receive policy, decided by the plan: +// +// - guarded-overwrite: refuse when the destination changed since this +// account last held the baton (content hash vs the recorded baseline) +// unless --force; a first receive into an already-populated destination +// likewise refuses. +// - union-merge: copy files absent at the destination, skip files already +// present, never delete (immutable, uniquely named session files). +type Policy string + +const ( + PolicyGuardedOverwrite Policy = "guarded-overwrite" + PolicyUnionMerge Policy = "union-merge" +) + +// Stable cargo item names. They are manifest identifiers — a persisted +// contract — so they never change spelling. +const ( + ItemNext = "next" + ItemRunJournal = "run-journal" + ItemAgentMemory = "agent-memory" + ItemTranscripts = "transcripts" +) + +// Locator carries the account-local facts an item needs to resolve its +// absolute path on THIS account. Items only ever recompute forward from these +// — never invert a store key back to a path. +type Locator struct { + // Home is the account's $HOME. + Home string + // ProjectDir is the absolute path of the project repo's main worktree. + ProjectDir string + // StoreKey is Identity.Key (the abcd-compatible root SHA). + StoreKey string +} + +// Item is one cargo item in the registry. +type Item struct { + Name string + Kind ItemKind + // Policy is how receive lands this item at the destination. + Policy Policy + // Required makes pack refuse when the item is missing (the handover note: + // "nothing to hand over — write the handover note first"); optional items + // are tolerated and noted in the manifest. + Required bool + // Locate resolves the item's absolute path for one account. + Locate func(lc Locator) (string, error) +} + +// BuiltinItems returns the v1 cargo registry: the abcd layout's in-repo work +// tier plus the Claude Code harness's per-project memory and the redacted +// transcript store. +func BuiltinItems() []Item { + return []Item{ + { + Name: ItemNext, Kind: KindFile, Policy: PolicyGuardedOverwrite, Required: true, + Locate: func(lc Locator) (string, error) { + return filepath.Join(lc.ProjectDir, ".abcd", ".work.local", "NEXT.md"), nil + }, + }, + { + Name: ItemRunJournal, Kind: KindFile, Policy: PolicyGuardedOverwrite, + Locate: func(lc Locator) (string, error) { + return filepath.Join(lc.ProjectDir, ".abcd", ".work.local", "run-journal.json"), nil + }, + }, + { + Name: ItemAgentMemory, Kind: KindDir, Policy: PolicyGuardedOverwrite, + Locate: func(lc Locator) (string, error) { + return filepath.Join(lc.Home, ".claude", "projects", ClaudeProjectsKey(lc.ProjectDir), "memory"), nil + }, + }, + { + Name: ItemTranscripts, Kind: KindDir, Policy: PolicyUnionMerge, + Locate: func(lc Locator) (string, error) { + return filepath.Join(lc.Home, ".abcd", "history", lc.StoreKey), nil + }, + }, + } +} + +// ClaudeProjectsKey flattens an absolute project path to the harness's +// per-project directory key: every character outside [A-Za-z0-9] becomes '-'. +// This is an observed convention of one tool, not a contract — lossy (a dash +// in a directory name is indistinguishable from a separator) and +// collision-prone on case-insensitive APFS — so ferry only ever recomputes it +// forward from a destination path and verifies the target before writing; it +// never inverts a key. This function is the one owner of the encoding rule: +// a harness-side scheme change is a one-line fix. +func ClaudeProjectsKey(absProjectPath string) string { + var out []rune + for _, r := range absProjectPath { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + out = append(out, r) + default: + out = append(out, '-') + } + } + return string(out) +} diff --git a/internal/work/registry_test.go b/internal/work/registry_test.go new file mode 100644 index 0000000..bb7845d --- /dev/null +++ b/internal/work/registry_test.go @@ -0,0 +1,75 @@ +package work + +import ( + "path/filepath" + "testing" +) + +func TestClaudeProjectsKey(t *testing.T) { + cases := []struct{ in, want string }{ + // The observed convention: every character outside [A-Za-z0-9] + // becomes '-'. + {"/Users/alice/dev/ferry", "-Users-alice-dev-ferry"}, + {"/Users/alice/my.project", "-Users-alice-my-project"}, + {"/Users/alice/my-project", "-Users-alice-my-project"}, + {"/Users/alice/über", "-Users-alice--ber"}, + } + for _, c := range cases { + if got := ClaudeProjectsKey(c.in); got != c.want { + t.Errorf("ClaudeProjectsKey(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestBuiltinItems(t *testing.T) { + items := BuiltinItems() + byName := map[string]Item{} + for _, it := range items { + if _, dup := byName[it.Name]; dup { + t.Fatalf("duplicate item name %q", it.Name) + } + byName[it.Name] = it + } + + lc := Locator{ + Home: "/home/bob", + ProjectDir: "/home/bob/src/proj", + StoreKey: "abc123", + } + + wantPaths := map[string]string{ + ItemNext: "/home/bob/src/proj/.abcd/.work.local/NEXT.md", + ItemRunJournal: "/home/bob/src/proj/.abcd/.work.local/run-journal.json", + ItemAgentMemory: "/home/bob/.claude/projects/-home-bob-src-proj/memory", + ItemTranscripts: "/home/bob/.abcd/history/abc123", + } + for name, want := range wantPaths { + it, ok := byName[name] + if !ok { + t.Errorf("builtin item %q missing", name) + continue + } + got, err := it.Locate(lc) + if err != nil { + t.Errorf("%s.Locate: %v", name, err) + continue + } + if got != filepath.FromSlash(want) { + t.Errorf("%s.Locate = %q, want %q", name, got, want) + } + } + + // Policies and shapes pinned by the plan. + if it := byName[ItemNext]; it.Policy != PolicyGuardedOverwrite || it.Kind != KindFile || !it.Required { + t.Errorf("next item = %+v, want guarded-overwrite required file", it) + } + if it := byName[ItemRunJournal]; it.Policy != PolicyGuardedOverwrite || it.Kind != KindFile || it.Required { + t.Errorf("run-journal item = %+v, want guarded-overwrite optional file", it) + } + if it := byName[ItemAgentMemory]; it.Policy != PolicyGuardedOverwrite || it.Kind != KindDir { + t.Errorf("agent-memory item = %+v, want guarded-overwrite dir", it) + } + if it := byName[ItemTranscripts]; it.Policy != PolicyUnionMerge || it.Kind != KindDir { + t.Errorf("transcripts item = %+v, want union-merge dir", it) + } +} From 237612f95595b7671fc47c6118f3d4fc74d005b8 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:17:06 +0100 Subject: [PATCH 02/13] feat: add cargo manifest schema and per-project work state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second increment of work-state ferrying: the versioned cargo manifest (validated as untrusted input — path containment, hex identity, strict decode, newer-version refusal) and the per-account local state file (pack/receive baselines keyed by content hash, hash-pinned secret-gate acknowledgements, last-receive record for precise reversal, and the written-path set that ferry restore work will enumerate). Assisted-by: Claude:claude-fable-5 --- internal/work/manifest.go | 189 +++++++++++++++++++++++++++++++ internal/work/manifest_test.go | 120 ++++++++++++++++++++ internal/work/state.go | 196 +++++++++++++++++++++++++++++++++ internal/work/state_test.go | 112 +++++++++++++++++++ 4 files changed, 617 insertions(+) create mode 100644 internal/work/manifest.go create mode 100644 internal/work/manifest_test.go create mode 100644 internal/work/state.go create mode 100644 internal/work/state_test.go diff --git a/internal/work/manifest.go b/internal/work/manifest.go new file mode 100644 index 0000000..f0388ed --- /dev/null +++ b/internal/work/manifest.go @@ -0,0 +1,189 @@ +package work + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/REPPL/ferry/internal/statefile" +) + +// ManifestMember is the zip member holding the cargo manifest inside a +// .ferrywork bundle. The member name and the manifest schema are cargo- +// specific — deliberately distinct from the config bundle's manifest. +const ManifestMember = "ferry-work.json" + +// manifestVersion is the current cargo manifest schema version. On-disk +// formats carry the standard version envelope and follow the compatibility +// policy: forward-only migration, newer-version refusal. +const manifestVersion = 1 + +// Scan verdicts recorded in the manifest: the pack-side secret gate either +// found nothing, or every finding was explicitly acknowledged (pinned to +// file + content hash in local state). +const ( + ScanVerdictClean = "clean" + ScanVerdictAcknowledged = "acknowledged" +) + +// ManifestFile is one packed file within an item. Path is the canonical +// forward-slash path RELATIVE to the item's root (never absolute, never +// traversing) — receive resolves it against the destination account's own +// item location, so a manifest can never name an arbitrary target. +type ManifestFile struct { + Path string `json:"path"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +// ManifestItem records one cargo item's inclusion. An excluded item carries +// its reason ("missing", "excluded") so `work status` can surface the gap — +// a silent gap in a handover is worse than a loud one. +type ManifestItem struct { + Name string `json:"name"` + Included bool `json:"included"` + Reason string `json:"reason,omitempty"` + Files []ManifestFile `json:"files,omitempty"` +} + +// Manifest is the cargo bundle's manifest member. RepoPath and Home are the +// SOURCE account's paths, recorded for path rewriting and diagnostics only — +// receive always recomputes destinations forward from its own account. +// PackedAt is display-only: ordering is by Seq, never by timestamp (clocks +// differ across machines and exFAT timestamps are coarse). +type Manifest struct { + Version int `json:"version"` + FerryVersion string `json:"ferry_version"` + Key string `json:"key"` + Roots []string `json:"roots"` + RepoPath string `json:"repo_path"` + Home string `json:"home"` + Seq uint64 `json:"seq"` + PackedBy string `json:"packed_by"` + PackedAt string `json:"packed_at"` + ScanVerdict string `json:"scan_verdict"` + Items []ManifestItem `json:"items"` +} + +// Encode serialises the manifest, stamping the current schema version and +// validating first, so a malformed manifest is refused at write time rather +// than discovered by the receiving account. +func (m *Manifest) Encode() ([]byte, error) { + m.Version = manifestVersion + if err := m.validate(); err != nil { + return nil, fmt.Errorf("work: manifest: %w", err) + } + return json.MarshalIndent(m, "", " ") +} + +// DecodeManifest parses and validates a cargo manifest. A manifest written by +// a newer ferry is refused (*statefile.FutureVersionError); an unversioned or +// unknown-shaped payload is refused rather than guessed at — receive writes +// files from this data, so it is validated as untrusted input. +func DecodeManifest(data []byte) (*Manifest, error) { + v, versioned := statefile.PeekVersion(data) + if !versioned { + return nil, fmt.Errorf("work: %s carries no schema version — not a cargo manifest", ManifestMember) + } + if v > manifestVersion { + return nil, &statefile.FutureVersionError{Path: ManifestMember, Found: v, Supported: manifestVersion} + } + if v < 1 { + return nil, fmt.Errorf("work: %s declares invalid schema version %d", ManifestMember, v) + } + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.DisallowUnknownFields() + var m Manifest + if err := dec.Decode(&m); err != nil { + return nil, fmt.Errorf("work: parse %s: %w", ManifestMember, err) + } + if err := m.validate(); err != nil { + return nil, fmt.Errorf("work: %s: %w", ManifestMember, err) + } + return &m, nil +} + +// validate enforces the manifest contract shared by Encode and Decode. +func (m *Manifest) validate() error { + if !rootSHA.MatchString(m.Key) { + return fmt.Errorf("key %q is not a full commit SHA", m.Key) + } + if len(m.Roots) == 0 { + return fmt.Errorf("empty root-SHA set") + } + keyInRoots := false + for _, r := range m.Roots { + if !rootSHA.MatchString(r) { + return fmt.Errorf("root %q is not a full commit SHA", r) + } + if r == m.Key { + keyInRoots = true + } + } + if !keyInRoots { + return fmt.Errorf("key %s is not in the root-SHA set", m.Key) + } + seen := map[string]bool{} + for _, it := range m.Items { + if it.Name == "" { + return fmt.Errorf("item with empty name") + } + if seen[it.Name] { + return fmt.Errorf("duplicate item %q", it.Name) + } + seen[it.Name] = true + if !it.Included && len(it.Files) > 0 { + return fmt.Errorf("item %q is excluded but lists files", it.Name) + } + for _, f := range it.Files { + if err := validateCargoRel(f.Path); err != nil { + return fmt.Errorf("item %q: %w", it.Name, err) + } + if len(f.SHA256) != 64 || !isLowerHex(f.SHA256) { + return fmt.Errorf("item %q: file %q has malformed sha256", it.Name, f.Path) + } + if f.Size < 0 { + return fmt.Errorf("item %q: file %q has negative size", it.Name, f.Path) + } + } + } + return nil +} + +// validateCargoRel refuses any member path that is not a clean, relative, +// forward-slash path: no traversal, no absolute paths, no backslashes, no +// "."/".." segments, no empty segments. Receive joins these against the +// destination item root, so this is the first containment layer. +func validateCargoRel(p string) error { + if p == "" { + return fmt.Errorf("empty file path") + } + if strings.HasPrefix(p, "/") { + return fmt.Errorf("absolute file path %q", p) + } + if strings.ContainsRune(p, '\\') { + return fmt.Errorf("backslash in file path %q", p) + } + if strings.ContainsRune(p, 0) { + return fmt.Errorf("NUL in file path %q", p) + } + for _, seg := range strings.Split(p, "/") { + switch seg { + case "": + return fmt.Errorf("empty segment in file path %q", p) + case ".", "..": + return fmt.Errorf("dot segment in file path %q", p) + } + } + return nil +} + +// isLowerHex reports whether s is entirely lowercase hex. +func isLowerHex(s string) bool { + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} diff --git a/internal/work/manifest_test.go b/internal/work/manifest_test.go new file mode 100644 index 0000000..6ea0424 --- /dev/null +++ b/internal/work/manifest_test.go @@ -0,0 +1,120 @@ +package work + +import ( + "errors" + "strings" + "testing" + + "github.com/REPPL/ferry/internal/statefile" +) + +func validManifest() *Manifest { + return &Manifest{ + FerryVersion: "v0.10.0-dev", + Key: strings.Repeat("a", 40), + Roots: []string{strings.Repeat("a", 40)}, + RepoPath: "/home/alice/src/proj", + Home: "/home/alice", + Seq: 3, + PackedBy: "alice@studio", + PackedAt: "2026-07-18T10:00:00Z", + ScanVerdict: ScanVerdictClean, + Items: []ManifestItem{ + {Name: ItemNext, Included: true, Files: []ManifestFile{ + {Path: "NEXT.md", Size: 5, SHA256: strings.Repeat("b", 64)}, + }}, + {Name: ItemTranscripts, Included: false, Reason: "missing"}, + }, + } +} + +func TestManifestRoundTrip(t *testing.T) { + m := validManifest() + data, err := m.Encode() + if err != nil { + t.Fatalf("Encode: %v", err) + } + got, err := DecodeManifest(data) + if err != nil { + t.Fatalf("DecodeManifest: %v", err) + } + if got.Version != manifestVersion { + t.Errorf("Version = %d, want %d", got.Version, manifestVersion) + } + if got.Key != m.Key || got.Seq != 3 || len(got.Items) != 2 { + t.Errorf("round trip mismatch: %+v", got) + } + if got.Items[0].Files[0].SHA256 != m.Items[0].Files[0].SHA256 { + t.Errorf("file hash lost in round trip") + } +} + +func TestDecodeManifest_FutureVersionRefused(t *testing.T) { + m := validManifest() + data, err := m.Encode() + if err != nil { + t.Fatal(err) + } + bumped := strings.Replace(string(data), `"version": 1`, `"version": 99`, 1) + if bumped == string(data) { + t.Fatal("test setup: version field not found to bump") + } + _, err = DecodeManifest([]byte(bumped)) + var fve *statefile.FutureVersionError + if !errors.As(err, &fve) { + t.Fatalf("err = %v, want *statefile.FutureVersionError", err) + } +} + +func TestDecodeManifest_UnversionedRefused(t *testing.T) { + if _, err := DecodeManifest([]byte(`{"key":"abc"}`)); err == nil { + t.Fatal("unversioned manifest accepted, want refusal") + } + if _, err := DecodeManifest([]byte(`not json`)); err == nil { + t.Fatal("non-JSON manifest accepted, want refusal") + } +} + +func TestDecodeManifest_BadPathsRefused(t *testing.T) { + for _, bad := range []string{"../escape", "/abs", `a\b`, "", "a/../b", "./x"} { + m := validManifest() + m.Items[0].Files[0].Path = bad + data, err := m.Encode() + if err != nil { + // Encode may refuse too; that also protects the contract. + continue + } + if _, err := DecodeManifest(data); err == nil { + t.Errorf("manifest with file path %q accepted, want refusal", bad) + } + } +} + +func TestDecodeManifest_BadIdentityRefused(t *testing.T) { + m := validManifest() + m.Key = "not-hex" + if data, err := m.Encode(); err == nil { + if _, err := DecodeManifest(data); err == nil { + t.Error("manifest with non-hex key accepted, want refusal") + } + } + m = validManifest() + m.Roots = nil + if data, err := m.Encode(); err == nil { + if _, err := DecodeManifest(data); err == nil { + t.Error("manifest with no roots accepted, want refusal") + } + } +} + +func TestDecodeManifest_UnknownFieldRefused(t *testing.T) { + m := validManifest() + data, err := m.Encode() + if err != nil { + t.Fatal(err) + } + tampered := strings.Replace(string(data), `"ferry_version"`, `"stray": 1, "ferry_version"`, 1) + if _, err := DecodeManifest([]byte(tampered)); err == nil { + t.Fatal("manifest with unknown top-level field accepted, want refusal") + } +} diff --git a/internal/work/state.go b/internal/work/state.go new file mode 100644 index 0000000..bcac10d --- /dev/null +++ b/internal/work/state.go @@ -0,0 +1,196 @@ +package work + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/REPPL/ferry/internal/backup" + "github.com/REPPL/ferry/internal/paths" + "github.com/REPPL/ferry/internal/statefile" +) + +// stateVersion is the current per-project work-state schema version. +const stateVersion = 1 + +const ( + stateDirPerm os.FileMode = 0o700 + stateFilePerm os.FileMode = 0o600 +) + +// Baseline records the content hashes of what the last pack or receive on +// THIS account carried, per item and canonical relative path. Divergence is +// detected by comparing destination content hashes against it — never +// timestamps. +type Baseline struct { + // Op is "pack" or "receive" — which verb set this baseline. + Op string `json:"op"` + // Seq and Bundle name the cargo bundle the baseline came from. + Seq uint64 `json:"seq"` + Bundle string `json:"bundle_sha256"` + // At is RFC3339, display-only. + At string `json:"at"` + // Files maps item name -> canonical rel path -> content sha256. + Files map[string]map[string]string `json:"files"` +} + +// Ack is one acknowledged secret-gate finding, pinned to file + content hash: +// pack re-aborts if the flagged content changes, so an acknowledgement never +// silently covers new material. +type Ack struct { + Item string `json:"item"` + Path string `json:"path"` + SHA256 string `json:"sha256"` + Note string `json:"note,omitempty"` +} + +// ReceiveRecord records the last receive so `ferry work restore` can revert +// exactly it from its per-receive snapshot. +type ReceiveRecord struct { + SnapshotID string `json:"snapshot_id"` + Seq uint64 `json:"seq"` + Bundle string `json:"bundle_sha256,omitempty"` + At string `json:"at,omitempty"` + // Paths are the absolute destination paths this receive wrote. + Paths []string `json:"paths,omitempty"` +} + +// State is the per-project, per-account local work state: baselines, +// acknowledgements, the last receive, and every path work verbs have written +// (the enumeration `ferry restore work` reverts to baseline). +type State struct { + path string + + Baseline *Baseline + Acks []Ack + LastReceive *ReceiveRecord + Written []string +} + +// stateEnvelope is the on-disk form. +type stateEnvelope struct { + Version int `json:"version"` + Baseline *Baseline `json:"baseline,omitempty"` + Acks []Ack `json:"acks,omitempty"` + LastReceive *ReceiveRecord `json:"last_receive,omitempty"` + Written []string `json:"written,omitempty"` +} + +// LoadState loads the per-project work state for storeKey from ferry's state +// dir (~/.local/state/ferry/work/.json). +func LoadState(storeKey string) (*State, error) { + root, err := paths.StateDir() + if err != nil { + return nil, err + } + return LoadStateAt(root, storeKey) +} + +// LoadStateAt is the testable core: it loads the state for storeKey under an +// explicit state root. A missing file is an empty state; a zero-length file +// (torn write) degrades to an empty state; a file a newer ferry wrote is +// refused untouched (*statefile.FutureVersionError); an unversioned or +// unknown-shaped file is refused, never guessed at. +func LoadStateAt(stateRoot, storeKey string) (*State, error) { + if !rootSHA.MatchString(storeKey) { + return nil, fmt.Errorf("work: store key %q is not a full commit SHA", storeKey) + } + dir := filepath.Join(stateRoot, "work") + // Symlink-harden the state dir before reading through it, mirroring every + // other ferry store (lexical, creates nothing, no-op outside $HOME). + if err := paths.HardenStoreDir(dir); err != nil { + return nil, err + } + s := &State{path: filepath.Join(dir, storeKey+".json")} + data, err := os.ReadFile(s.path) + if errors.Is(err, fs.ErrNotExist) { + return s, nil + } + if err != nil { + return nil, err + } + if len(data) == 0 { + return s, nil + } + v, versioned := statefile.PeekVersion(data) + if !versioned { + return nil, fmt.Errorf("work: state file %s carries no schema version — it looks corrupt and has been left untouched; repair or remove it", s.path) + } + if v > stateVersion { + return nil, &statefile.FutureVersionError{Path: s.path, Found: v, Supported: stateVersion} + } + if v < 1 { + return nil, fmt.Errorf("work: state file %s declares invalid schema version %d", s.path, v) + } + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.DisallowUnknownFields() + var env stateEnvelope + if err := dec.Decode(&env); err != nil { + return nil, fmt.Errorf("work: parse state file %s: %w", s.path, err) + } + s.Baseline = env.Baseline + s.Acks = env.Acks + s.LastReceive = env.LastReceive + s.Written = env.Written + return s, nil +} + +// Save persists the state atomically, owner-only. +func (s *State) Save() error { + dir := filepath.Dir(s.path) + if err := paths.HardenStoreDir(dir); err != nil { + return err + } + if err := os.MkdirAll(dir, stateDirPerm); err != nil { + return err + } + if err := os.Chmod(dir, stateDirPerm); err != nil { + return err + } + env := stateEnvelope{ + Version: stateVersion, + Baseline: s.Baseline, + Acks: s.Acks, + LastReceive: s.LastReceive, + Written: s.Written, + } + data, err := json.MarshalIndent(env, "", " ") + if err != nil { + return err + } + return backup.AtomicWrite(s.path, data, stateFilePerm) +} + +// Acknowledged reports whether the finding at (item, relPath) is acknowledged +// for EXACTLY this content hash. +func (s *State) Acknowledged(item, relPath, sha256Hex string) bool { + for _, a := range s.Acks { + if a.Item == item && a.Path == relPath && a.SHA256 == sha256Hex { + return true + } + } + return false +} + +// RecordWritten merges paths into the sorted, deduplicated set of absolute +// paths work verbs have written on this account. +func (s *State) RecordWritten(pathsWritten ...string) { + set := map[string]bool{} + for _, p := range s.Written { + set[p] = true + } + for _, p := range pathsWritten { + set[p] = true + } + out := make([]string, 0, len(set)) + for p := range set { + out = append(out, p) + } + sort.Strings(out) + s.Written = out +} diff --git a/internal/work/state_test.go b/internal/work/state_test.go new file mode 100644 index 0000000..2a7784a --- /dev/null +++ b/internal/work/state_test.go @@ -0,0 +1,112 @@ +package work + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/REPPL/ferry/internal/statefile" +) + +func testKey() string { return strings.Repeat("c", 40) } + +func TestState_MissingFileIsEmpty(t *testing.T) { + s, err := LoadStateAt(t.TempDir(), testKey()) + if err != nil { + t.Fatalf("LoadStateAt: %v", err) + } + if s.Baseline != nil || len(s.Acks) != 0 || s.LastReceive != nil || len(s.Written) != 0 { + t.Errorf("empty state not empty: %+v", s) + } +} + +func TestState_RoundTrip(t *testing.T) { + root := t.TempDir() + s, err := LoadStateAt(root, testKey()) + if err != nil { + t.Fatal(err) + } + s.Baseline = &Baseline{ + Op: "pack", Seq: 7, Bundle: strings.Repeat("d", 64), + At: "2026-07-18T10:00:00Z", + Files: map[string]map[string]string{ItemNext: {"NEXT.md": strings.Repeat("e", 64)}}, + } + s.Acks = []Ack{{Item: ItemNext, Path: "NEXT.md", SHA256: strings.Repeat("f", 64)}} + s.LastReceive = &ReceiveRecord{SnapshotID: "snap-1", Seq: 7, Paths: []string{"/home/bob/x"}} + s.RecordWritten("/home/bob/b", "/home/bob/a", "/home/bob/b") + if err := s.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := LoadStateAt(root, testKey()) + if err != nil { + t.Fatalf("re-load: %v", err) + } + if got.Baseline == nil || got.Baseline.Seq != 7 || got.Baseline.Files[ItemNext]["NEXT.md"] != strings.Repeat("e", 64) { + t.Errorf("baseline lost: %+v", got.Baseline) + } + if got.LastReceive == nil || got.LastReceive.SnapshotID != "snap-1" { + t.Errorf("last receive lost: %+v", got.LastReceive) + } + if want := []string{"/home/bob/a", "/home/bob/b"}; len(got.Written) != 2 || got.Written[0] != want[0] || got.Written[1] != want[1] { + t.Errorf("Written = %v, want %v (sorted, deduped)", got.Written, want) + } + if !got.Acknowledged(ItemNext, "NEXT.md", strings.Repeat("f", 64)) { + t.Error("recorded acknowledgement not found") + } + if got.Acknowledged(ItemNext, "NEXT.md", strings.Repeat("0", 64)) { + t.Error("acknowledgement matched a different content hash — must be pinned") + } +} + +func TestState_FutureVersionRefused(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "work") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, testKey()+".json") + if err := os.WriteFile(path, []byte(`{"version": 99}`), 0o600); err != nil { + t.Fatal(err) + } + _, err := LoadStateAt(root, testKey()) + var fve *statefile.FutureVersionError + if !errors.As(err, &fve) { + t.Fatalf("err = %v, want *statefile.FutureVersionError", err) + } +} + +func TestState_CorruptRefusedButEmptyTolerated(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "work") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, testKey()+".json") + + // Zero-length (torn write) degrades to an empty state. + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadStateAt(root, testKey()); err != nil { + t.Errorf("zero-length state file refused, want empty state: %v", err) + } + + // An unversioned object is not a work state file: refuse, never guess. + if err := os.WriteFile(path, []byte(`{"written": ["/x"]}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadStateAt(root, testKey()); err == nil { + t.Error("unversioned state file accepted, want refusal") + } +} + +func TestState_BadKeyRefused(t *testing.T) { + for _, bad := range []string{"", "short", "../../etc/passwd", strings.Repeat("g", 40)} { + if _, err := LoadStateAt(t.TempDir(), bad); err == nil { + t.Errorf("LoadStateAt accepted key %q, want refusal", bad) + } + } +} From b618d5ed78093201302ad74bd6cb444544611504 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:19:26 +0100 Subject: [PATCH 03/13] feat: add the cargo store and advisory claim files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third increment of work-state ferrying: the store layout (//-.ferrywork with O_CREAT|O_EXCL sequence allocation and fsynced bundle writes, safe on APFS/SMB/exFAT), the mechanical store-location guards (inside-a-git-worktree refusal, known sync-root refusal behind an explicit override), equal-seq fork listing, and per-account claim files — each written only by its owner, merged on read, so no file is ever written by two accounts. Assisted-by: Claude:claude-fable-5 --- internal/work/claim.go | 164 ++++++++++++++++++++++ internal/work/store.go | 262 ++++++++++++++++++++++++++++++++++++ internal/work/store_test.go | 192 ++++++++++++++++++++++++++ 3 files changed, 618 insertions(+) create mode 100644 internal/work/claim.go create mode 100644 internal/work/store.go create mode 100644 internal/work/store_test.go diff --git a/internal/work/claim.go b/internal/work/claim.go new file mode 100644 index 0000000..f100b69 --- /dev/null +++ b/internal/work/claim.go @@ -0,0 +1,164 @@ +package work + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/REPPL/ferry/internal/backup" + "github.com/REPPL/ferry/internal/statefile" +) + +// Baton tracking is advisory, not locking: per-account claim files record who +// packed and received what. One human across their own accounts is the threat +// model; divergence is detected and surfaced, not prevented. Each claim file +// is written ONLY by its owning account and merged on read. + +// claimVersion is the current claim-file schema version. +const claimVersion = 1 + +// Claim event operations. +const ( + OpPack = "pack" + OpReceive = "receive" + OpTakeBack = "take-back" +) + +// ClaimEvent is one recorded baton event. At is RFC3339, display-only — +// ordering within an account's claim is append order, and cross-account +// ordering is by bundle sequence, never by timestamp. +type ClaimEvent struct { + Op string `json:"op"` + Seq uint64 `json:"seq"` + Bundle string `json:"bundle_sha256,omitempty"` + At string `json:"at,omitempty"` +} + +// Claim is one account's claim file: an append-only event history. +type Claim struct { + Version int `json:"version"` + Account string `json:"account"` + Events []ClaimEvent `json:"events"` +} + +// claimAccount pins the account spelling: local@host, each part starting +// alphanumeric — which also keeps the derived filename free of separators +// and dot-only names. +var claimAccount = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// claimFileName matches "claim..json". +var claimFileName = regexp.MustCompile(`^claim\.(.+)\.json$`) + +// AppendClaim appends one event to THIS account's claim file for the project. +// The file is only ever written by its owner, so no cross-account write race +// exists; the write is atomic against a torn read by the other account. +func (st *Store) AppendClaim(key, account string, ev ClaimEvent) error { + if !rootSHA.MatchString(key) { + return fmt.Errorf("work: store key %q is not a full commit SHA", key) + } + if !claimAccount.MatchString(account) { + return fmt.Errorf("work: claim account %q is not of the form user@host", account) + } + switch ev.Op { + case OpPack, OpReceive, OpTakeBack: + default: + return fmt.Errorf("work: unknown claim op %q", ev.Op) + } + if err := st.ensureProjectDir(key); err != nil { + return err + } + path := filepath.Join(st.ProjectDir(key), "claim."+account+".json") + claim := Claim{Version: claimVersion, Account: account} + data, err := os.ReadFile(path) + switch { + case errors.Is(err, fs.ErrNotExist): + // First event for this account. + case err != nil: + return err + default: + loaded, err := decodeClaim(path, data) + if err != nil { + return err + } + if loaded.Account != account { + return fmt.Errorf("work: claim file %s belongs to %q, not %q — refusing to write another account's claim", path, loaded.Account, account) + } + claim = *loaded + } + claim.Version = claimVersion + claim.Events = append(claim.Events, ev) + out, err := json.MarshalIndent(claim, "", " ") + if err != nil { + return err + } + // World-readable: the OTHER account must be able to read it to merge. + return backup.AtomicWrite(path, out, 0o644) +} + +// Claims reads and merges every account's claim file for the project, sorted +// by account for deterministic output. A missing project directory reads as +// no claims. +func (st *Store) Claims(key string) ([]Claim, error) { + if !rootSHA.MatchString(key) { + return nil, fmt.Errorf("work: store key %q is not a full commit SHA", key) + } + entries, err := os.ReadDir(st.ProjectDir(key)) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var claims []Claim + for _, e := range entries { + if e.IsDir() { + continue + } + m := claimFileName.FindStringSubmatch(e.Name()) + if m == nil { + continue + } + path := filepath.Join(st.ProjectDir(key), e.Name()) + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + claim, err := decodeClaim(path, data) + if err != nil { + return nil, err + } + claims = append(claims, *claim) + } + sort.Slice(claims, func(i, j int) bool { return claims[i].Account < claims[j].Account }) + return claims, nil +} + +// decodeClaim parses a claim file with the standard version gate. +func decodeClaim(path string, data []byte) (*Claim, error) { + v, versioned := statefile.PeekVersion(data) + if !versioned { + return nil, fmt.Errorf("work: claim file %s carries no schema version — it looks corrupt", path) + } + if v > claimVersion { + return nil, &statefile.FutureVersionError{Path: path, Found: v, Supported: claimVersion} + } + if v < 1 { + return nil, fmt.Errorf("work: claim file %s declares invalid schema version %d", path, v) + } + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.DisallowUnknownFields() + var c Claim + if err := dec.Decode(&c); err != nil { + return nil, fmt.Errorf("work: parse claim file %s: %w", path, err) + } + if !claimAccount.MatchString(c.Account) { + return nil, fmt.Errorf("work: claim file %s names malformed account %q", path, c.Account) + } + return &c, nil +} diff --git a/internal/work/store.go b/internal/work/store.go new file mode 100644 index 0000000..5314cb3 --- /dev/null +++ b/internal/work/store.go @@ -0,0 +1,262 @@ +package work + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// The cargo store is a plain directory of cargo bundles on shared or portable +// media — never the ferry config repo, never hosted by default. Layout, per +// project (keyed by the abcd-compatible root SHA): +// +// //-.ferrywork cargo bundles +// //claim.@.json per-account claims +// +// No file is ever written by two accounts (each claim file is written only by +// its owner; bundles are created O_CREAT|O_EXCL), which sidesteps both the +// /Users/Shared POSIX-permission trap and claim-write races, and works +// unchanged on permissionless exFAT. + +// StoreInWorktreeError reports a store location inside a git worktree — which +// mechanically catches "inside the ferry config repo". +type StoreInWorktreeError struct{ Dir string } + +func (e *StoreInWorktreeError) Error() string { + return "work: cargo store " + e.Dir + " resolves inside a git worktree — the store must be a plain directory outside any repository" +} + +// StoreSyncRootError reports a store under a known cloud-sync root. Cargo +// holds personal working context; syncing it to a hosted service must be an +// explicit, loud choice. +type StoreSyncRootError struct { + Dir string + SyncDir string +} + +func (e *StoreSyncRootError) Error() string { + return "work: cargo store " + e.Dir + " is under the cloud-synced directory " + e.SyncDir + " — nothing in ferry uploads the store, but this location would; pass the explicit override to accept that" +} + +// syncRootSegments are path segments that mark a cloud-synced tree: iCloud +// ("Mobile Documents"), macOS provider mounts ("CloudStorage"), and the +// classic vendor directories. +var syncRootSegments = []string{ + "mobile documents", + "cloudstorage", + "dropbox", + "google drive", + "onedrive", +} + +// Store is an opened cargo store. +type Store struct { + root string +} + +// OpenStore validates and opens the cargo store at root. The location guards +// are mechanical, not just documented: a store inside any git worktree (or +// git dir) is refused outright; a store under a known sync root is refused +// unless allowSyncRoot. The top-level store directory is created once by the +// user (documented setup); a missing root is a clear error, not a silent +// mkdir — on removable media it usually means the volume is not mounted. +func OpenStore(root string, allowSyncRoot bool) (*Store, error) { + abs, err := filepath.Abs(root) + if err != nil { + return nil, err + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + abs = resolved + } else if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("work: cargo store %s does not exist (is the volume mounted?) — create it once, then retry", abs) + } else { + return nil, err + } + + if inside, err := dirInsideGit(abs); err != nil { + return nil, err + } else if inside { + return nil, &StoreInWorktreeError{Dir: abs} + } + + if syncDir := syncRootAncestor(abs); syncDir != "" && !allowSyncRoot { + return nil, &StoreSyncRootError{Dir: abs, SyncDir: syncDir} + } + return &Store{root: abs}, nil +} + +// Root returns the store's resolved root directory. +func (st *Store) Root() string { return st.root } + +// ProjectDir returns the per-project directory for a store key. +func (st *Store) ProjectDir(key string) string { return filepath.Join(st.root, key) } + +// dirInsideGit reports whether dir resolves inside a git worktree or git dir, +// probed with the isolated git environment. Git being absent skips the check +// (the work verbs cannot run without git anyway — identity needs it first). +func dirInsideGit(dir string) (bool, error) { + if _, err := exec.LookPath("git"); err != nil { + return false, nil + } + out, err := gitOutput(dir, "rev-parse", "--is-inside-work-tree", "--is-inside-git-dir") + if err != nil { + // "not a git repository" is the healthy answer for a store location. + return false, nil + } + return strings.Contains(out, "true"), nil +} + +// syncRootAncestor returns the shallowest ancestor of dir whose basename is a +// known sync-root segment, or "" when there is none. +func syncRootAncestor(dir string) string { + segs := strings.Split(dir, string(filepath.Separator)) + prefix := "" + for _, seg := range segs { + if seg == "" { + prefix = string(filepath.Separator) + continue + } + prefix = filepath.Join(prefix, seg) + lower := strings.ToLower(seg) + for _, mark := range syncRootSegments { + if lower == mark { + return prefix + } + } + } + return "" +} + +// BundleRef names one cargo bundle in the store. +type BundleRef struct { + Seq uint64 + SHA256 string + Path string +} + +// bundleName matches "-.ferrywork". Seq padding is +// display-sugar; the parse accepts any width. +var bundleName = regexp.MustCompile(`^([0-9]+)-([0-9a-f]{64})\.ferrywork$`) + +// Bundles lists the project's cargo bundles in ascending sequence order. A +// missing project directory reads as "never packed". Equal-seq entries (two +// accounts packed without an intervening receive) are BOTH returned — the +// caller surfaces the fork; ordering between them is by name, deterministic. +// Non-bundle files (claims, junk) are ignored. +func (st *Store) Bundles(key string) ([]BundleRef, error) { + if !rootSHA.MatchString(key) { + return nil, fmt.Errorf("work: store key %q is not a full commit SHA", key) + } + entries, err := os.ReadDir(st.ProjectDir(key)) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var refs []BundleRef + for _, e := range entries { + if e.IsDir() { + continue + } + m := bundleName.FindStringSubmatch(e.Name()) + if m == nil { + continue + } + seq, err := strconv.ParseUint(m[1], 10, 64) + if err != nil { + continue + } + refs = append(refs, BundleRef{ + Seq: seq, + SHA256: m[2], + Path: filepath.Join(st.ProjectDir(key), e.Name()), + }) + } + sort.Slice(refs, func(i, j int) bool { + if refs[i].Seq != refs[j].Seq { + return refs[i].Seq < refs[j].Seq + } + return refs[i].Path < refs[j].Path + }) + return refs, nil +} + +// WriteBundle stores cargo bytes as the project's next bundle. The sequence +// is allocated by creating the file O_CREAT|O_EXCL and retrying at seq+1 on +// collision — safe on APFS, SMB, and exFAT. The bundle hash in the name is +// the content hash of data. The write is fsynced before the ref is returned: +// a handover the packer believes in must be durable on the shared medium. +func (st *Store) WriteBundle(key string, data []byte) (BundleRef, error) { + if !rootSHA.MatchString(key) { + return BundleRef{}, fmt.Errorf("work: store key %q is not a full commit SHA", key) + } + if err := st.ensureProjectDir(key); err != nil { + return BundleRef{}, err + } + sum := sha256.Sum256(data) + hash := hex.EncodeToString(sum[:]) + + existing, err := st.Bundles(key) + if err != nil { + return BundleRef{}, err + } + var next uint64 = 1 + if n := len(existing); n > 0 { + next = existing[n-1].Seq + 1 + } + const maxAttempts = 10000 + for attempt := 0; attempt < maxAttempts; attempt++ { + name := fmt.Sprintf("%06d-%s.ferrywork", next, hash) + path := filepath.Join(st.ProjectDir(key), name) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if errors.Is(err, fs.ErrExist) { + next++ + continue + } + if err != nil { + return BundleRef{}, err + } + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(path) + return BundleRef{}, err + } + if err := f.Sync(); err != nil { + f.Close() + os.Remove(path) + return BundleRef{}, err + } + if err := f.Close(); err != nil { + os.Remove(path) + return BundleRef{}, err + } + return BundleRef{Seq: next, SHA256: hash, Path: path}, nil + } + return BundleRef{}, fmt.Errorf("work: could not allocate a bundle sequence for %s after %d attempts", key, maxAttempts) +} + +// ensureProjectDir creates the per-project store directory, group/world- +// writable: two accounts of one human share it, and on /Users/Shared a +// default-umask directory created by Alice would be unwritable by Bob. +func (st *Store) ensureProjectDir(key string) error { + dir := st.ProjectDir(key) + if err := os.MkdirAll(dir, 0o777); err != nil { + return err + } + // MkdirAll's mode is filtered by the umask; make the grant explicit. + // Ignored (no-op) on permissionless filesystems like exFAT. + if err := os.Chmod(dir, 0o777); err != nil { + return err + } + return nil +} diff --git a/internal/work/store_test.go b/internal/work/store_test.go new file mode 100644 index 0000000..9fb5351 --- /dev/null +++ b/internal/work/store_test.go @@ -0,0 +1,192 @@ +package work + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestOpenStore_InsideWorktreeRefused(t *testing.T) { + repo := newRepo(t) + sub := filepath.Join(repo, "cargo") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + _, err := OpenStore(sub, false) + var wte *StoreInWorktreeError + if !errors.As(err, &wte) { + t.Fatalf("err = %v, want *StoreInWorktreeError", err) + } +} + +func TestOpenStore_SyncRootRefusedWithoutOverride(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + base := t.TempDir() + for _, seg := range []string{"Dropbox", "Mobile Documents", "CloudStorage", "Google Drive"} { + dir := filepath.Join(base, seg, "cargo") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + _, err := OpenStore(dir, false) + var sre *StoreSyncRootError + if !errors.As(err, &sre) { + t.Errorf("OpenStore under %q: err = %v, want *StoreSyncRootError", seg, err) + } + if _, err := OpenStore(dir, true); err != nil { + t.Errorf("OpenStore under %q with override: %v", seg, err) + } + } +} + +func TestOpenStore_PlainDirAccepted(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + if _, err := OpenStore(t.TempDir(), false); err != nil { + t.Fatalf("OpenStore on plain dir: %v", err) + } +} + +func openTestStore(t *testing.T) *Store { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + st, err := OpenStore(t.TempDir(), false) + if err != nil { + t.Fatal(err) + } + return st +} + +func TestStore_WriteBundleAllocatesSequences(t *testing.T) { + st := openTestStore(t) + key := testKey() + + r1, err := st.WriteBundle(key, []byte("cargo-one")) + if err != nil { + t.Fatalf("WriteBundle: %v", err) + } + r2, err := st.WriteBundle(key, []byte("cargo-two")) + if err != nil { + t.Fatalf("WriteBundle: %v", err) + } + if r1.Seq != 1 || r2.Seq != 2 { + t.Errorf("seqs = %d, %d, want 1, 2", r1.Seq, r2.Seq) + } + if r1.SHA256 == r2.SHA256 { + t.Error("different cargo produced identical bundle hashes") + } + if got, err := os.ReadFile(r2.Path); err != nil || string(got) != "cargo-two" { + t.Errorf("bundle content = %q, %v", got, err) + } + + refs, err := st.Bundles(key) + if err != nil { + t.Fatalf("Bundles: %v", err) + } + if len(refs) != 2 || refs[0].Seq != 1 || refs[1].Seq != 2 { + t.Errorf("Bundles = %+v, want seqs [1 2] ascending", refs) + } +} + +func TestStore_BundlesSurfacesEqualSeqForks(t *testing.T) { + st := openTestStore(t) + key := testKey() + if _, err := st.WriteBundle(key, []byte("alice")); err != nil { + t.Fatal(err) + } + // Simulate Bob's concurrent pack that allocated the SAME seq on other + // media: drop a same-seq, different-hash file in directly. + forkName := "000001-" + strings.Repeat("9", 64) + ".ferrywork" + if err := os.WriteFile(filepath.Join(st.ProjectDir(key), forkName), []byte("bob"), 0o644); err != nil { + t.Fatal(err) + } + refs, err := st.Bundles(key) + if err != nil { + t.Fatalf("Bundles: %v", err) + } + if len(refs) != 2 { + t.Fatalf("Bundles = %+v, want both fork files", refs) + } + if refs[0].Seq != 1 || refs[1].Seq != 1 { + t.Errorf("fork seqs = %d, %d, want 1, 1", refs[0].Seq, refs[1].Seq) + } + + // The next pack must skip past the fork, not collide with it. + r, err := st.WriteBundle(key, []byte("carol")) + if err != nil { + t.Fatalf("WriteBundle after fork: %v", err) + } + if r.Seq != 2 { + t.Errorf("post-fork seq = %d, want 2", r.Seq) + } +} + +func TestStore_BundlesIgnoresJunk(t *testing.T) { + st := openTestStore(t) + key := testKey() + dir := st.ProjectDir(key) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, junk := range []string{"README.txt", "claim.alice@studio.json", ".DS_Store", "nonseq-xyz.ferrywork"} { + if err := os.WriteFile(filepath.Join(dir, junk), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + refs, err := st.Bundles(key) + if err != nil { + t.Fatalf("Bundles: %v", err) + } + if len(refs) != 0 { + t.Errorf("Bundles = %+v, want none (junk ignored)", refs) + } +} + +func TestStore_ClaimsRoundTripAndMerge(t *testing.T) { + st := openTestStore(t) + key := testKey() + + if err := st.AppendClaim(key, "alice@studio", ClaimEvent{Op: OpPack, Seq: 1, At: "2026-07-18T10:00:00Z"}); err != nil { + t.Fatalf("AppendClaim: %v", err) + } + if err := st.AppendClaim(key, "alice@studio", ClaimEvent{Op: OpTakeBack, Seq: 1}); err != nil { + t.Fatalf("AppendClaim: %v", err) + } + if err := st.AppendClaim(key, "bob@laptop", ClaimEvent{Op: OpReceive, Seq: 1}); err != nil { + t.Fatalf("AppendClaim: %v", err) + } + + claims, err := st.Claims(key) + if err != nil { + t.Fatalf("Claims: %v", err) + } + if len(claims) != 2 { + t.Fatalf("Claims = %+v, want 2 accounts", claims) + } + byAccount := map[string][]ClaimEvent{} + for _, c := range claims { + byAccount[c.Account] = c.Events + } + if evs := byAccount["alice@studio"]; len(evs) != 2 || evs[0].Op != OpPack || evs[1].Op != OpTakeBack { + t.Errorf("alice events = %+v", evs) + } + if evs := byAccount["bob@laptop"]; len(evs) != 1 || evs[0].Op != OpReceive { + t.Errorf("bob events = %+v", evs) + } +} + +func TestStore_ClaimAccountNameValidated(t *testing.T) { + st := openTestStore(t) + for _, bad := range []string{"", "a/b@host", "..@host", "a b@host"} { + if err := st.AppendClaim(testKey(), bad, ClaimEvent{Op: OpPack, Seq: 1}); err == nil { + t.Errorf("AppendClaim accepted account %q, want refusal", bad) + } + } +} From 93ce99ed9a58b0ee8d92cf669519ae42e5eefb8c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:23:55 +0100 Subject: [PATCH 04/13] feat: add the pack engine with fail-closed secret gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth increment of work-state ferrying: Pack collects the registry items (missing optional items tolerated and noted; the handover note required unless --allow-empty), passes every byte through the secret gate — a high-confidence finding aborts the whole pack, releasable only by --exclude or an acknowledgement pinned to file + content hash — builds the cargo zip (sorted members, sanitised modes, manifest stamped with the sequence the store actually allocated via the new WriteBundle build callback), appends the pack claim, updates the local baseline, and records the sidecar handover marker without mutating any cargo file. Symlinks anywhere in cargo are a refusal, never a silent skip. Assisted-by: Claude:claude-fable-5 --- internal/work/manifest.go | 18 +- internal/work/pack.go | 449 ++++++++++++++++++++++++++++++++++++ internal/work/pack_test.go | 311 +++++++++++++++++++++++++ internal/work/store.go | 24 +- internal/work/store_test.go | 22 +- 5 files changed, 805 insertions(+), 19 deletions(-) create mode 100644 internal/work/pack.go create mode 100644 internal/work/pack_test.go diff --git a/internal/work/manifest.go b/internal/work/manifest.go index f0388ed..e473128 100644 --- a/internal/work/manifest.go +++ b/internal/work/manifest.go @@ -151,9 +151,12 @@ func (m *Manifest) validate() error { } // validateCargoRel refuses any member path that is not a clean, relative, -// forward-slash path: no traversal, no absolute paths, no backslashes, no -// "."/".." segments, no empty segments. Receive joins these against the -// destination item root, so this is the first containment layer. +// forward-slash path: no traversal, no absolute paths (including Windows +// drive prefixes), no backslashes, no "."/".." segments, no empty segments, +// and no .ssh or VCS-control component anywhere. Receive joins these against +// the destination item root, so this is the first containment layer. (Kin of +// internal/bundle's private canonicalRel, but strict — cargo paths are built +// by pack, so a path that NEEDS cleaning is already suspect.) func validateCargoRel(p string) error { if p == "" { return fmt.Errorf("empty file path") @@ -161,6 +164,11 @@ func validateCargoRel(p string) error { if strings.HasPrefix(p, "/") { return fmt.Errorf("absolute file path %q", p) } + if len(p) >= 2 && p[1] == ':' { + if c := p[0]; (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') { + return fmt.Errorf("absolute file path %q", p) + } + } if strings.ContainsRune(p, '\\') { return fmt.Errorf("backslash in file path %q", p) } @@ -174,6 +182,10 @@ func validateCargoRel(p string) error { case ".", "..": return fmt.Errorf("dot segment in file path %q", p) } + switch strings.ToLower(seg) { + case ".ssh", ".git", ".hg", ".svn": + return fmt.Errorf("control component %q in file path %q", seg, p) + } } return nil } diff --git a/internal/work/pack.go b/internal/work/pack.go new file mode 100644 index 0000000..548aaef --- /dev/null +++ b/internal/work/pack.go @@ -0,0 +1,449 @@ +package work + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "unicode/utf8" + + "github.com/REPPL/ferry/internal/backup" + "github.com/REPPL/ferry/internal/secret" + "github.com/REPPL/ferry/internal/statefile" +) + +// HandoverMarkerName is the sidecar handover marker in .abcd/.work.local/. +// Pack records the baton there WITHOUT mutating any cargo file — marking the +// baton must not modify the baton (that would trip the divergence guard on +// every normal cycle and fight the user's editor). +const HandoverMarkerName = ".ferry-handover.json" + +// handoverVersion is the marker's schema version. +const handoverVersion = 1 + +// HandoverMarker records the last pack from this project directory: bundle +// identity plus the content hashes of what was packed, so `work status` can +// tell "handed over, not modified since" from "modified after handover". +type HandoverMarker struct { + Version int `json:"version"` + Seq uint64 `json:"seq"` + Bundle string `json:"bundle_sha256"` + At string `json:"at,omitempty"` + // Files maps item name -> canonical rel path -> content sha256. + Files map[string]map[string]string `json:"files"` +} + +// SecretFinding is one unacknowledged secret-gate hit, named precisely enough +// to act on. SHA256 is the flagged content's hash — the pin an +// acknowledgement must carry. +type SecretFinding struct { + Item string + Path string + Rule string + Detail string + SHA256 string +} + +// SecretGateError aborts a pack: nothing was written. This is deliberately +// stricter than bundle export (which withholds the offending file and +// continues) — work-state cargo is small and personal, and a silent gap in a +// handover is worse than a loud stop. +type SecretGateError struct { + Findings []SecretFinding +} + +func (e *SecretGateError) Error() string { + var b strings.Builder + b.WriteString("work: the secret gate stopped the pack; nothing was written:\n") + for _, f := range e.Findings { + fmt.Fprintf(&b, " %s/%s: %s (%s)\n", f.Item, f.Path, f.Detail, f.Rule) + } + b.WriteString("fix the content at its source, exclude the item (--exclude), or acknowledge the finding to pack it as-is") + return b.String() +} + +// PackOptions carries the caller's choices into a pack. +type PackOptions struct { + // Excludes names items to leave out (recorded in the manifest; status + // shows the gap). + Excludes []string + // AllowEmpty permits a pack without the handover note (memory/ + // transcript-only cargo). + AllowEmpty bool + // Account is this account's claim identity, user@host. + Account string + // FerryVersion is stamped into the manifest. + FerryVersion string + // Now is an RFC3339 timestamp for display fields; never used for + // ordering. + Now string +} + +// PackResult reports a completed pack. +type PackResult struct { + Ref BundleRef + Manifest *Manifest + MarkerPath string +} + +// packedFile is one collected cargo file. +type packedFile struct { + item string + rel string + data []byte + mode os.FileMode + sha string +} + +// Pack bundles the project's work state into the store: collect every +// registry item, pass EVERY byte through the secret gate (fail closed, with +// the two auditable escape hatches), build the cargo zip, store it under the +// next sequence, append this account's claim, update the local baseline, and +// record the sidecar handover marker. The caller has already established the +// project identity (same guards as receive) and validated the account. +func Pack(st *Store, lc Locator, id Identity, state *State, opts PackOptions) (*PackResult, error) { + items := BuiltinItems() + byName := map[string]Item{} + for _, it := range items { + byName[it.Name] = it + } + excluded := map[string]bool{} + for _, x := range opts.Excludes { + if _, ok := byName[x]; !ok { + return nil, fmt.Errorf("work: --exclude names unknown item %q", x) + } + excluded[x] = true + } + + var ( + manifestItems []ManifestItem + files []packedFile + findings []SecretFinding + acked bool + ) + for _, it := range items { + if excluded[it.Name] { + manifestItems = append(manifestItems, ManifestItem{Name: it.Name, Included: false, Reason: "excluded"}) + continue + } + root, err := it.Locate(lc) + if err != nil { + return nil, err + } + collected, missing, err := collectItem(it, root) + if err != nil { + return nil, err + } + if missing { + if it.Required && !opts.AllowEmpty { + return nil, fmt.Errorf("work: nothing to hand over — write the handover note (%s) first, or pass --allow-empty for memory/transcript-only cargo", root) + } + manifestItems = append(manifestItems, ManifestItem{Name: it.Name, Included: false, Reason: "missing"}) + continue + } + mi := ManifestItem{Name: it.Name, Included: true} + for _, f := range collected { + if hit, finding := gateFile(f); hit { + if state.Acknowledged(f.item, f.rel, f.sha) { + acked = true + } else { + findings = append(findings, finding) + } + } + mi.Files = append(mi.Files, ManifestFile{Path: f.rel, Size: int64(len(f.data)), SHA256: f.sha}) + } + manifestItems = append(manifestItems, mi) + files = append(files, collected...) + } + if len(findings) > 0 { + return nil, &SecretGateError{Findings: findings} + } + if len(files) == 0 { + return nil, fmt.Errorf("work: nothing to pack — every item is missing or excluded") + } + + verdict := ScanVerdictClean + if acked { + verdict = ScanVerdictAcknowledged + } + + manifest := &Manifest{ + FerryVersion: opts.FerryVersion, + Key: id.Key, + Roots: id.Roots, + RepoPath: lc.ProjectDir, + Home: lc.Home, + PackedBy: opts.Account, + PackedAt: opts.Now, + ScanVerdict: verdict, + Items: manifestItems, + } + ref, err := st.WriteBundle(id.Key, func(seq uint64) ([]byte, error) { + manifest.Seq = seq + return buildCargoZip(manifest, files) + }) + if err != nil { + return nil, err + } + + if err := st.AppendClaim(id.Key, opts.Account, ClaimEvent{Op: OpPack, Seq: ref.Seq, Bundle: ref.SHA256, At: opts.Now}); err != nil { + return nil, fmt.Errorf("work: bundle %06d stored but recording the claim failed: %w", ref.Seq, err) + } + + hashes := fileHashesByItem(files) + state.Baseline = &Baseline{Op: OpPack, Seq: ref.Seq, Bundle: ref.SHA256, At: opts.Now, Files: hashes} + if err := state.Save(); err != nil { + return nil, fmt.Errorf("work: bundle %06d stored but saving local state failed: %w", ref.Seq, err) + } + + markerPath, err := writeHandoverMarker(lc.ProjectDir, HandoverMarker{ + Seq: ref.Seq, Bundle: ref.SHA256, At: opts.Now, Files: hashes, + }) + if err != nil { + return nil, fmt.Errorf("work: bundle %06d stored but writing the handover marker failed: %w", ref.Seq, err) + } + return &PackResult{Ref: ref, Manifest: manifest, MarkerPath: markerPath}, nil +} + +// collectItem gathers an item's files. A missing file/dir (or an empty dir) +// reports missing=true. A symlink or special file anywhere in an item is a +// refusal, never a silent skip — cargo must contain exactly what the manifest +// says. +func collectItem(it Item, root string) (collected []packedFile, missing bool, err error) { + fi, statErr := os.Lstat(root) + if errors.Is(statErr, fs.ErrNotExist) { + return nil, true, nil + } + if statErr != nil { + return nil, false, statErr + } + switch it.Kind { + case KindFile: + if !fi.Mode().IsRegular() { + return nil, false, fmt.Errorf("work: %s is not a regular file (mode %v) — refusing to pack it", root, fi.Mode()) + } + data, err := os.ReadFile(root) + if err != nil { + return nil, false, err + } + rel := filepath.Base(root) + if err := validateCargoRel(rel); err != nil { + return nil, false, fmt.Errorf("work: item %s: %w", it.Name, err) + } + return []packedFile{newPackedFile(it.Name, rel, data, fi.Mode())}, false, nil + case KindDir: + if !fi.IsDir() { + return nil, false, fmt.Errorf("work: %s is not a directory (mode %v) — refusing to pack it", root, fi.Mode()) + } + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + if !d.Type().IsRegular() { + return fmt.Errorf("work: %s is not a regular file (mode %v) — refusing to pack it", p, d.Type()) + } + relOS, err := filepath.Rel(root, p) + if err != nil { + return err + } + rel := filepath.ToSlash(relOS) + if err := validateCargoRel(rel); err != nil { + return fmt.Errorf("work: item %s: %w", it.Name, err) + } + info, err := d.Info() + if err != nil { + return err + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + collected = append(collected, newPackedFile(it.Name, rel, data, info.Mode())) + return nil + }) + if err != nil { + return nil, false, err + } + if len(collected) == 0 { + return nil, true, nil + } + return collected, false, nil + default: + return nil, false, fmt.Errorf("work: item %s has unknown kind %q", it.Name, it.Kind) + } +} + +// newPackedFile hashes and mode-sanitises one collected file. +func newPackedFile(item, rel string, data []byte, mode os.FileMode) packedFile { + sum := sha256.Sum256(data) + return packedFile{item: item, rel: rel, data: data, mode: cargoMode(mode), sha: hex.EncodeToString(sum[:])} +} + +// cargoMode reduces an on-disk mode to a portable member mode: 0755 when any +// execute bit is set, else 0644 — the same reduction the config bundle +// writer applies. +func cargoMode(m os.FileMode) os.FileMode { + if m.Perm()&0o111 != 0 { + return 0o755 + } + return 0o644 +} + +// gateFile passes one file through the secret gate. Text content is scanned +// line-wise (high-confidence findings block); non-text content is checked for +// embedded key material. Transcripts arrive pre-redacted by the project +// tooling; ferry re-scans anyway — defence in depth, and the gate also covers +// memory and the handover note, which nothing has scanned before. +func gateFile(f packedFile) (bool, SecretFinding) { + if utf8.Valid(f.data) && !bytes.ContainsRune(f.data, 0) { + fs := secret.ScanText(string(f.data)) + if fs.HasHigh() { + first := fs[0] + for _, fd := range fs { + if fd.Confidence == secret.High { + first = fd + break + } + } + return true, SecretFinding{Item: f.item, Path: f.rel, Rule: first.Rule, Detail: first.Detail, SHA256: f.sha} + } + return false, SecretFinding{} + } + if secret.HasBinarySecret(f.data) { + return true, SecretFinding{Item: f.item, Path: f.rel, Rule: "binary-key-material", Detail: "embedded key material in binary content", SHA256: f.sha} + } + return false, SecretFinding{} +} + +// buildCargoZip assembles the cargo bytes: every member under +// "/", deterministic order, sanitised modes, the manifest last. +func buildCargoZip(m *Manifest, files []packedFile) ([]byte, error) { + sorted := make([]packedFile, len(files)) + copy(sorted, files) + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].item != sorted[j].item { + return sorted[i].item < sorted[j].item + } + return sorted[i].rel < sorted[j].rel + }) + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, f := range sorted { + hdr := &zip.FileHeader{Name: f.item + "/" + f.rel, Method: zip.Deflate} + hdr.SetMode(f.mode) + w, err := zw.CreateHeader(hdr) + if err != nil { + return nil, fmt.Errorf("work: create member %s/%s: %w", f.item, f.rel, err) + } + if _, err := w.Write(f.data); err != nil { + return nil, fmt.Errorf("work: write member %s/%s: %w", f.item, f.rel, err) + } + } + manifestJSON, err := m.Encode() + if err != nil { + return nil, err + } + hdr := &zip.FileHeader{Name: ManifestMember, Method: zip.Deflate} + hdr.SetMode(0o644) + w, err := zw.CreateHeader(hdr) + if err != nil { + return nil, err + } + if _, err := w.Write(manifestJSON); err != nil { + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// fileHashesByItem indexes collected files as item -> rel -> sha256. +func fileHashesByItem(files []packedFile) map[string]map[string]string { + out := map[string]map[string]string{} + for _, f := range files { + if out[f.item] == nil { + out[f.item] = map[string]string{} + } + out[f.item][f.rel] = f.sha + } + return out +} + +// workLocalDir resolves the project's .abcd/.work.local directory and +// verifies no component of it is a symlink (comparing the symlink-resolved +// path against the resolved project dir), so a planted link cannot redirect +// the marker write. It creates the directory when absent. +func workLocalDir(projectDir string) (string, error) { + resolvedProject, err := filepath.EvalSymlinks(projectDir) + if err != nil { + return "", err + } + dir := filepath.Join(projectDir, ".abcd", ".work.local") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + resolvedDir, err := filepath.EvalSymlinks(dir) + if err != nil { + return "", err + } + want := filepath.Join(resolvedProject, ".abcd", ".work.local") + if resolvedDir != want { + return "", fmt.Errorf("work: %s resolves through a symlink (to %s) — refusing to write there", dir, resolvedDir) + } + return dir, nil +} + +// writeHandoverMarker atomically records the sidecar marker. +func writeHandoverMarker(projectDir string, mk HandoverMarker) (string, error) { + dir, err := workLocalDir(projectDir) + if err != nil { + return "", err + } + mk.Version = handoverVersion + data, err := json.MarshalIndent(mk, "", " ") + if err != nil { + return "", err + } + path := filepath.Join(dir, HandoverMarkerName) + if err := backup.AtomicWrite(path, data, 0o644); err != nil { + return "", err + } + return path, nil +} + +// ReadHandoverMarker loads the project's handover marker. Absent is not an +// error: (nil, false, nil). +func ReadHandoverMarker(projectDir string) (*HandoverMarker, bool, error) { + path := filepath.Join(projectDir, ".abcd", ".work.local", HandoverMarkerName) + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + v, versioned := statefile.PeekVersion(data) + if !versioned { + return nil, false, fmt.Errorf("work: handover marker %s carries no schema version — it looks corrupt", path) + } + if v > handoverVersion { + return nil, false, &statefile.FutureVersionError{Path: path, Found: v, Supported: handoverVersion} + } + var mk HandoverMarker + if err := json.Unmarshal(data, &mk); err != nil { + return nil, false, fmt.Errorf("work: parse handover marker %s: %w", path, err) + } + return &mk, true, nil +} diff --git a/internal/work/pack_test.go b/internal/work/pack_test.go new file mode 100644 index 0000000..f265359 --- /dev/null +++ b/internal/work/pack_test.go @@ -0,0 +1,311 @@ +package work + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "testing" +) + +// packFixture is a complete two-sided pack setup: a fake home containing a +// project repo with work-local files, agent memory, and a transcript store. +type packFixture struct { + home string + repo string + id Identity + lc Locator + st *Store + state *State +} + +func newPackFixture(t *testing.T) *packFixture { + t.Helper() + home := t.TempDir() + repo := filepath.Join(home, "src", "proj") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatal(err) + } + gitTest(t, repo, "init", "-q") + if err := os.WriteFile(filepath.Join(repo, "main.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + gitTest(t, repo, "add", ".") + gitTest(t, repo, "commit", "-q", "-m", "root") + + id, err := ProjectIdentity(repo) + if err != nil { + t.Fatal(err) + } + lc := Locator{Home: home, ProjectDir: repo, StoreKey: id.Key} + + writeFileT(t, filepath.Join(repo, ".abcd", ".work.local", "NEXT.md"), "# NEXT\ncontinue here\n") + writeFileT(t, filepath.Join(repo, ".abcd", ".work.local", "run-journal.json"), `{"runs":[]}`) + memDir := filepath.Join(home, ".claude", "projects", ClaudeProjectsKey(repo), "memory") + writeFileT(t, filepath.Join(memDir, "MEMORY.md"), "# Memory index\n") + writeFileT(t, filepath.Join(memDir, "fact.md"), "a fact\n") + writeFileT(t, filepath.Join(home, ".abcd", "history", id.Key, "sess-1.jsonl"), `{"redacted":true}`) + + st, err := OpenStore(t.TempDir(), false) + if err != nil { + t.Fatal(err) + } + state, err := LoadStateAt(filepath.Join(home, ".local", "state", "ferry"), id.Key) + if err != nil { + t.Fatal(err) + } + return &packFixture{home: home, repo: repo, id: id, lc: lc, st: st, state: state} +} + +func writeFileT(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func defaultOpts() PackOptions { + return PackOptions{Account: "alice@studio", FerryVersion: "test", Now: "2026-07-18T10:00:00Z"} +} + +func TestPack_HappyPath(t *testing.T) { + fx := newPackFixture(t) + res, err := Pack(fx.st, fx.lc, fx.id, fx.state, defaultOpts()) + if err != nil { + t.Fatalf("Pack: %v", err) + } + if res.Ref.Seq != 1 { + t.Errorf("seq = %d, want 1", res.Ref.Seq) + } + + // The stored bundle is a zip whose members and manifest agree. + data, err := os.ReadFile(res.Ref.Path) + if err != nil { + t.Fatal(err) + } + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.Fatalf("stored bundle is not a zip: %v", err) + } + members := map[string]bool{} + var manifestData []byte + for _, f := range zr.File { + members[f.Name] = true + if f.Name == ManifestMember { + rc, err := f.Open() + if err != nil { + t.Fatal(err) + } + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(rc); err != nil { + t.Fatal(err) + } + rc.Close() + manifestData = buf.Bytes() + } + } + for _, want := range []string{ + "next/NEXT.md", "run-journal/run-journal.json", + "agent-memory/MEMORY.md", "agent-memory/fact.md", + "transcripts/sess-1.jsonl", ManifestMember, + } { + if !members[want] { + t.Errorf("bundle missing member %q (have %v)", want, members) + } + } + m, err := DecodeManifest(manifestData) + if err != nil { + t.Fatalf("bundle manifest: %v", err) + } + if m.Seq != res.Ref.Seq || m.Key != fx.id.Key || m.ScanVerdict != ScanVerdictClean { + t.Errorf("manifest = seq %d key %s verdict %s", m.Seq, m.Key, m.ScanVerdict) + } + + // Claim, baseline, and marker are all recorded. + claims, err := fx.st.Claims(fx.id.Key) + if err != nil || len(claims) != 1 || claims[0].Events[0].Op != OpPack { + t.Errorf("claims = %+v, %v", claims, err) + } + if fx.state.Baseline == nil || fx.state.Baseline.Op != OpPack || fx.state.Baseline.Seq != 1 { + t.Errorf("baseline = %+v", fx.state.Baseline) + } + mk, ok, err := ReadHandoverMarker(fx.repo) + if err != nil || !ok { + t.Fatalf("marker: %v ok=%v", err, ok) + } + if mk.Seq != 1 || mk.Bundle != res.Ref.SHA256 || mk.Files[ItemNext]["NEXT.md"] == "" { + t.Errorf("marker = %+v", mk) + } + + // The marker itself must never be packed as cargo. + if members["run-journal/"+HandoverMarkerName] || members["next/"+HandoverMarkerName] { + t.Error("handover marker leaked into the cargo") + } +} + +func TestPack_RefusesWithoutHandoverNote(t *testing.T) { + fx := newPackFixture(t) + if err := os.Remove(filepath.Join(fx.repo, ".abcd", ".work.local", "NEXT.md")); err != nil { + t.Fatal(err) + } + if _, err := Pack(fx.st, fx.lc, fx.id, fx.state, defaultOpts()); err == nil { + t.Fatal("pack without NEXT.md succeeded, want refusal") + } + opts := defaultOpts() + opts.AllowEmpty = true + res, err := Pack(fx.st, fx.lc, fx.id, fx.state, opts) + if err != nil { + t.Fatalf("pack --allow-empty: %v", err) + } + for _, it := range res.Manifest.Items { + if it.Name == ItemNext && (it.Included || it.Reason != "missing") { + t.Errorf("next item = %+v, want excluded as missing", it) + } + } +} + +func TestPack_SecretGateAbortsAndAckReleases(t *testing.T) { + fx := newPackFixture(t) + secretText := "api_key = \"sk-ferrytest-FAKE1234567890abcdefghijklmnopqrstuv\"\n" + notePath := filepath.Join(fx.repo, ".abcd", ".work.local", "NEXT.md") + writeFileT(t, notePath, secretText) + + _, err := Pack(fx.st, fx.lc, fx.id, fx.state, defaultOpts()) + var sge *SecretGateError + if !errors.As(err, &sge) { + t.Fatalf("err = %v, want *SecretGateError", err) + } + if len(sge.Findings) == 0 || sge.Findings[0].Item != ItemNext { + t.Fatalf("findings = %+v", sge.Findings) + } + // Nothing was written on abort. + if refs, _ := fx.st.Bundles(fx.id.Key); len(refs) != 0 { + t.Errorf("bundles after abort = %+v, want none", refs) + } + + // Acknowledge the finding, pinned to the exact content hash: pack passes. + fx.state.Acks = append(fx.state.Acks, Ack{ + Item: sge.Findings[0].Item, Path: sge.Findings[0].Path, SHA256: sge.Findings[0].SHA256, + }) + res, err := Pack(fx.st, fx.lc, fx.id, fx.state, defaultOpts()) + if err != nil { + t.Fatalf("pack after ack: %v", err) + } + if res.Manifest.ScanVerdict != ScanVerdictAcknowledged { + t.Errorf("verdict = %s, want acknowledged", res.Manifest.ScanVerdict) + } + + // The pin is to content: changed flagged content re-aborts. + writeFileT(t, notePath, secretText+"more\n") + if _, err := Pack(fx.st, fx.lc, fx.id, fx.state, defaultOpts()); !errors.As(err, &sge) { + t.Fatalf("err after content change = %v, want *SecretGateError again", err) + } +} + +func TestPack_ExcludeIsRecorded(t *testing.T) { + fx := newPackFixture(t) + opts := defaultOpts() + opts.Excludes = []string{ItemTranscripts} + res, err := Pack(fx.st, fx.lc, fx.id, fx.state, opts) + if err != nil { + t.Fatalf("Pack: %v", err) + } + var found bool + for _, it := range res.Manifest.Items { + if it.Name == ItemTranscripts { + found = true + if it.Included || it.Reason != "excluded" { + t.Errorf("transcripts item = %+v, want excluded", it) + } + } + } + if !found { + t.Error("transcripts item missing from manifest") + } + + opts.Excludes = []string{"no-such-item"} + if _, err := Pack(fx.st, fx.lc, fx.id, fx.state, opts); err == nil { + t.Error("unknown --exclude accepted, want refusal") + } +} + +func TestPack_MissingOptionalItemsTolerated(t *testing.T) { + fx := newPackFixture(t) + if err := os.RemoveAll(filepath.Join(fx.home, ".abcd", "history", fx.id.Key)); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(filepath.Join(fx.home, ".claude")); err != nil { + t.Fatal(err) + } + res, err := Pack(fx.st, fx.lc, fx.id, fx.state, defaultOpts()) + if err != nil { + t.Fatalf("Pack: %v", err) + } + for _, it := range res.Manifest.Items { + switch it.Name { + case ItemAgentMemory, ItemTranscripts: + if it.Included || it.Reason != "missing" { + t.Errorf("%s = %+v, want missing", it.Name, it) + } + } + } +} + +func TestPack_SymlinkInCargoRefused(t *testing.T) { + fx := newPackFixture(t) + memDir := filepath.Join(fx.home, ".claude", "projects", ClaudeProjectsKey(fx.repo), "memory") + if err := os.Symlink(filepath.Join(fx.home, "elsewhere"), filepath.Join(memDir, "link.md")); err != nil { + t.Fatal(err) + } + if _, err := Pack(fx.st, fx.lc, fx.id, fx.state, defaultOpts()); err == nil { + t.Fatal("pack with symlinked cargo member succeeded, want refusal") + } +} + +func TestPack_SequencesAdvance(t *testing.T) { + fx := newPackFixture(t) + if _, err := Pack(fx.st, fx.lc, fx.id, fx.state, defaultOpts()); err != nil { + t.Fatal(err) + } + writeFileT(t, filepath.Join(fx.repo, ".abcd", ".work.local", "NEXT.md"), "# NEXT\nupdated\n") + res, err := Pack(fx.st, fx.lc, fx.id, fx.state, defaultOpts()) + if err != nil { + t.Fatal(err) + } + if res.Ref.Seq != 2 { + t.Errorf("second pack seq = %d, want 2", res.Ref.Seq) + } + if fx.state.Baseline.Seq != 2 { + t.Errorf("baseline seq = %d, want 2", fx.state.Baseline.Seq) + } +} + +// contentHash mirrors the pack-side hashing for assertions. +func contentHash(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +func TestPack_MarkerRecordsContentHashes(t *testing.T) { + fx := newPackFixture(t) + note := "# NEXT\ncontinue here\n" + if _, err := Pack(fx.st, fx.lc, fx.id, fx.state, defaultOpts()); err != nil { + t.Fatal(err) + } + mk, ok, err := ReadHandoverMarker(fx.repo) + if err != nil || !ok { + t.Fatal(err) + } + if got := mk.Files[ItemNext]["NEXT.md"]; got != contentHash(note) { + t.Errorf("marker hash = %s, want %s", got, contentHash(note)) + } + if len(mk.Bundle) != 64 || !isLowerHex(mk.Bundle) { + t.Errorf("marker bundle hash malformed: %q", mk.Bundle) + } +} diff --git a/internal/work/store.go b/internal/work/store.go index 5314cb3..fe5e791 100644 --- a/internal/work/store.go +++ b/internal/work/store.go @@ -191,21 +191,21 @@ func (st *Store) Bundles(key string) ([]BundleRef, error) { return refs, nil } -// WriteBundle stores cargo bytes as the project's next bundle. The sequence -// is allocated by creating the file O_CREAT|O_EXCL and retrying at seq+1 on -// collision — safe on APFS, SMB, and exFAT. The bundle hash in the name is -// the content hash of data. The write is fsynced before the ref is returned: -// a handover the packer believes in must be durable on the shared medium. -func (st *Store) WriteBundle(key string, data []byte) (BundleRef, error) { +// WriteBundle stores the project's next cargo bundle. The sequence is +// allocated by creating the file O_CREAT|O_EXCL and retrying at seq+1 on +// collision — safe on APFS, SMB, and exFAT. Because the bundle's manifest +// records its own sequence number, the caller passes a build callback: it is +// invoked with each candidate sequence and returns the exact bytes to store, +// so the recorded seq always matches the allocated one. The write is fsynced +// before the ref is returned: a handover the packer believes in must be +// durable on the shared medium. +func (st *Store) WriteBundle(key string, build func(seq uint64) ([]byte, error)) (BundleRef, error) { if !rootSHA.MatchString(key) { return BundleRef{}, fmt.Errorf("work: store key %q is not a full commit SHA", key) } if err := st.ensureProjectDir(key); err != nil { return BundleRef{}, err } - sum := sha256.Sum256(data) - hash := hex.EncodeToString(sum[:]) - existing, err := st.Bundles(key) if err != nil { return BundleRef{}, err @@ -216,6 +216,12 @@ func (st *Store) WriteBundle(key string, data []byte) (BundleRef, error) { } const maxAttempts = 10000 for attempt := 0; attempt < maxAttempts; attempt++ { + data, err := build(next) + if err != nil { + return BundleRef{}, err + } + sum := sha256.Sum256(data) + hash := hex.EncodeToString(sum[:]) name := fmt.Sprintf("%06d-%s.ferrywork", next, hash) path := filepath.Join(st.ProjectDir(key), name) f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) diff --git a/internal/work/store_test.go b/internal/work/store_test.go index 9fb5351..76beb31 100644 --- a/internal/work/store_test.go +++ b/internal/work/store_test.go @@ -68,11 +68,14 @@ func TestStore_WriteBundleAllocatesSequences(t *testing.T) { st := openTestStore(t) key := testKey() - r1, err := st.WriteBundle(key, []byte("cargo-one")) + fixed := func(data string) func(uint64) ([]byte, error) { + return func(uint64) ([]byte, error) { return []byte(data), nil } + } + r1, err := st.WriteBundle(key, fixed("cargo-one")) if err != nil { t.Fatalf("WriteBundle: %v", err) } - r2, err := st.WriteBundle(key, []byte("cargo-two")) + r2, err := st.WriteBundle(key, fixed("cargo-two")) if err != nil { t.Fatalf("WriteBundle: %v", err) } @@ -98,7 +101,7 @@ func TestStore_WriteBundleAllocatesSequences(t *testing.T) { func TestStore_BundlesSurfacesEqualSeqForks(t *testing.T) { st := openTestStore(t) key := testKey() - if _, err := st.WriteBundle(key, []byte("alice")); err != nil { + if _, err := st.WriteBundle(key, func(uint64) ([]byte, error) { return []byte("alice"), nil }); err != nil { t.Fatal(err) } // Simulate Bob's concurrent pack that allocated the SAME seq on other @@ -118,13 +121,18 @@ func TestStore_BundlesSurfacesEqualSeqForks(t *testing.T) { t.Errorf("fork seqs = %d, %d, want 1, 1", refs[0].Seq, refs[1].Seq) } - // The next pack must skip past the fork, not collide with it. - r, err := st.WriteBundle(key, []byte("carol")) + // The next pack must skip past the fork, not collide with it, and the + // build callback must be told the seq it actually got. + var builtSeq uint64 + r, err := st.WriteBundle(key, func(seq uint64) ([]byte, error) { + builtSeq = seq + return []byte("carol"), nil + }) if err != nil { t.Fatalf("WriteBundle after fork: %v", err) } - if r.Seq != 2 { - t.Errorf("post-fork seq = %d, want 2", r.Seq) + if r.Seq != 2 || builtSeq != 2 { + t.Errorf("post-fork seq = %d (built with %d), want 2", r.Seq, builtSeq) } } From eb1cc605d236f1c9feba713d2f2e2a38d8646a39 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:44:38 +0100 Subject: [PATCH 05/13] feat: export the backup engine's snapshot entry point The snapshot machinery was reachable only from inside restore flows. The work domain needs a snapshot per receive so `ferry work restore` can revert exactly that receive; Snapshot() exposes the existing snapshotCurrent unchanged. Assisted-by: Claude:claude-fable-5 --- internal/backup/backup_test.go | 26 ++++++++++++++++++++++++++ internal/backup/snapshot.go | 9 +++++++++ 2 files changed, 35 insertions(+) diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index 4787084..b4f68a9 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -1210,3 +1210,29 @@ func asErrLockHeld(err error, target **ErrLockHeld) bool { } return false } + +func TestSnapshotIsDirectlyReversible(t *testing.T) { + e, home := newEngine(t) + present := filepath.Join(home, "present") + absent := filepath.Join(home, "absent") + mustWrite(t, present, []byte("BEFORE"), 0o600) + + snapID, err := e.Snapshot([]string{present, absent}) + if err != nil { + t.Fatal(err) + } + + // Mutate both: overwrite one, create the other. + mustWrite(t, present, []byte("AFTER"), 0o600) + mustWrite(t, absent, []byte("NEW"), 0o600) + + if err := e.RestoreSnapshot(snapID); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(present); string(got) != "BEFORE" { + t.Errorf("present = %q, want BEFORE", got) + } + if _, err := os.Lstat(absent); err == nil { + t.Error("absent path exists after snapshot revert, want deleted") + } +} diff --git a/internal/backup/snapshot.go b/internal/backup/snapshot.go index 1c154cc..2da703f 100644 --- a/internal/backup/snapshot.go +++ b/internal/backup/snapshot.go @@ -58,6 +58,15 @@ func (e *Engine) snapshotCurrent(absPaths []string) (string, error) { return id, nil } +// Snapshot captures the current state of absPaths into a new snapshot and +// returns its ID — the exported entry point for callers OUTSIDE a restore +// flow that need their mutation to be precisely reversible (the work domain +// takes one per receive, so `ferry work restore` can revert exactly that +// receive). RestoreSnapshot re-applies it. +func (e *Engine) Snapshot(absPaths []string) (string, error) { + return e.snapshotCurrent(absPaths) +} + // RestoreSnapshot re-applies a previously taken pre-restore snapshot, undoing a // restore. It returns the machine's affected paths to their pre-restore state. func (e *Engine) RestoreSnapshot(snapID string) error { From 17875c554f9b9a54c7d0efab9c90b3b52a23b84d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:50:49 +0100 Subject: [PATCH 06/13] feat: add the receive engine, work restore, and store lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth increment of work-state ferrying: Receive lands the latest cargo (chosen by sequence, never mtime) behind the full guard set — bundle bytes verified against name hash and per-file manifest hashes, no unlisted members, root-SHA set intersection, equal-seq tie refusal naming both bundles and their claim owners, superseded (taken-back) refusal, the v1 project-under-home rule, guarded-overwrite divergence checks against the hash baseline (first-receive-into-populated refused; identical content is not divergence), union-merge for transcripts under the store's advisory lock, and memory-target verification. All writes are backup-first inside one journal run, preceded by a per-receive snapshot; WorkRestore reverts exactly that receive. Take-back on the packer's account clears the marker and restores nothing. LocateProject scans foreign store directories by manifest root-set intersection so a reordered rev-list cannot orphan cargo. Also fixes a bug caught by test: a receive plan failing after taking the transcript lock leaked the lock file (the release defer registered only after the error check). Assisted-by: Claude:claude-fable-5 --- internal/work/receive.go | 667 ++++++++++++++++++++++++++++++++++ internal/work/receive_test.go | 420 +++++++++++++++++++++ 2 files changed, 1087 insertions(+) create mode 100644 internal/work/receive.go create mode 100644 internal/work/receive_test.go diff --git a/internal/work/receive.go b/internal/work/receive.go new file mode 100644 index 0000000..4c0e612 --- /dev/null +++ b/internal/work/receive.go @@ -0,0 +1,667 @@ +package work + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/REPPL/ferry/internal/backup" +) + +// ReceiveOptions carries the caller's choices into a receive. +type ReceiveOptions struct { + // Force overrides the guarded-overwrite refusals, the superseded guard, + // and (on the packer's own account) turns a take-back into a real landing. + Force bool + // BundleSHA256 names one bundle explicitly, resolving an equal-seq tie. + BundleSHA256 string + // Account is this account's claim identity, user@host. + Account string + // Now is an RFC3339 timestamp for display fields. + Now string +} + +// ReceiveResult reports a completed receive (or take-back). +type ReceiveResult struct { + Ref BundleRef + Manifest *Manifest + // TakeBack is set when the packer reclaimed their own baton: the marker + // and claim were updated and NOTHING was restored. + TakeBack bool + // Written are the absolute paths this receive wrote or removed. + Written []string + // Skipped are destinations left untouched (union-merge files already + // present; guarded files already holding the incoming content). + Skipped []string + SnapshotID string +} + +// TieError reports an equal-seq fork at the highest sequence: Alice and Bob +// both packed without an intervening receive. The user prunes one or names +// one explicitly; ferry never picks silently. +type TieError struct { + Refs []BundleRef + Claims []Claim +} + +func (e *TieError) Error() string { + var b strings.Builder + fmt.Fprintf(&b, "work: %d bundles share the highest sequence %06d — two accounts packed without an intervening receive:\n", len(e.Refs), e.Refs[0].Seq) + for _, r := range e.Refs { + owner := "unknown" + for _, c := range e.Claims { + for _, ev := range c.Events { + if ev.Op == OpPack && ev.Seq == r.Seq && ev.Bundle == r.SHA256 { + owner = c.Account + } + } + } + fmt.Fprintf(&b, " %s (packed by %s)\n", filepath.Base(r.Path), owner) + } + b.WriteString("prune one, or name one explicitly to receive it") + return b.String() +} + +// DivergedError reports a guarded-overwrite refusal: the destination changed +// since this account last held the baton (or was already populated before a +// first receive). +type DivergedError struct { + Item string + Dest string + Why string +} + +func (e *DivergedError) Error() string { + return fmt.Sprintf("work: refusing to overwrite %s (%s): %s — review it, then re-run with --force to replace it", e.Dest, e.Item, e.Why) +} + +// SupersededError reports that the newest bundle was taken back at the source +// (Alice packed, reclaimed the baton, and kept working). +type SupersededError struct{ Ref BundleRef } + +func (e *SupersededError) Error() string { + return fmt.Sprintf("work: bundle %06d was taken back by its packer and superseded at the source — receive it anyway with --force, or wait for a fresh pack", e.Ref.Seq) +} + +// cargoContent is one verified, extracted cargo file. +type cargoContent struct { + data []byte + mode os.FileMode +} + +// pendingWrite is one planned destination mutation. +type pendingWrite struct { + path string + data []byte + mode os.FileMode +} + +// Receive lands the project's latest cargo at this account: locate the cargo +// (by sequence, never mtime), verify every byte against the manifest, apply +// the per-item receive policies (guarded overwrite / union merge) behind the +// divergence guards, snapshot the affected paths, write backup-first, and +// record state and claim. On the account that packed the chosen bundle it is +// a take-back instead: clear the handover marker, record the claim, restore +// nothing (unless Force). +func Receive(st *Store, eng *backup.Engine, lc Locator, id Identity, state *State, opts ReceiveOptions) (*ReceiveResult, error) { + if !claimAccount.MatchString(opts.Account) { + return nil, fmt.Errorf("work: claim account %q is not of the form user@host", opts.Account) + } + key, refs, err := st.LocateProject(id) + if err != nil { + return nil, err + } + if len(refs) == 0 { + return nil, fmt.Errorf("work: no cargo for this project in %s — nothing has been packed for it", st.Root()) + } + claims, err := st.Claims(key) + if err != nil { + return nil, err + } + + chosen, err := chooseBundle(refs, claims, opts.BundleSHA256) + if err != nil { + return nil, err + } + m, contents, err := readBundle(chosen.Path, chosen.SHA256) + if err != nil { + return nil, err + } + if !intersects(m.Roots, id.Roots) { + return nil, fmt.Errorf("work: bundle %06d belongs to a different project (no shared root commit)", chosen.Seq) + } + if m.Seq != chosen.Seq { + return nil, fmt.Errorf("work: bundle %s declares sequence %d but is stored as %06d — the store looks tampered with", filepath.Base(chosen.Path), m.Seq, chosen.Seq) + } + + // v1 landing surface: the project repo itself must live under $HOME so + // every write below is either $HOME-contained (memory, transcripts) or + // inside the repo's guarded .work.local. + if err := projectUnderHome(lc); err != nil { + return nil, err + } + + // Take-back: the packer reclaiming their own baton before anyone landed + // it. Clears the marker and records the claim; restores nothing. + if m.PackedBy == opts.Account && !opts.Force { + dir, err := workLocalDir(lc.ProjectDir) + if err != nil { + return nil, err + } + if err := os.Remove(filepath.Join(dir, HandoverMarkerName)); err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + if err := st.AppendClaim(key, opts.Account, ClaimEvent{Op: OpTakeBack, Seq: chosen.Seq, Bundle: chosen.SHA256, At: opts.Now}); err != nil { + return nil, err + } + return &ReceiveResult{Ref: chosen, Manifest: m, TakeBack: true}, nil + } + if !opts.Force && takenBack(claims, m.PackedBy, chosen) { + return nil, &SupersededError{Ref: chosen} + } + + // The lock (when taken) must be released on EVERY exit below, including a + // plan that failed after acquiring it — register the defer before the + // error check. + writes, removals, skipped, unlock, err := planReceive(lc, m, contents, state, opts.Force) + if unlock != nil { + defer unlock() + } + if err != nil { + return nil, err + } + + affected := make([]string, 0, len(writes)+len(removals)) + for _, w := range writes { + affected = append(affected, w.path) + } + affected = append(affected, removals...) + sort.Strings(affected) + + snapID, err := eng.Snapshot(affected) + if err != nil { + return nil, err + } + run, err := eng.Begin() + if err != nil { + return nil, err + } + for _, w := range writes { + if err := eng.BackupAndWrite(run, w.path, w.data, w.mode); err != nil { + return nil, fmt.Errorf("work: landing %s failed (`ferry work restore` reverts the partial receive): %w", w.path, err) + } + } + for _, p := range removals { + if err := eng.BackupAndRemove(run, p); err != nil { + return nil, fmt.Errorf("work: removing %s failed (`ferry work restore` reverts the partial receive): %w", p, err) + } + } + if err := run.Commit(); err != nil { + return nil, err + } + + state.Baseline = &Baseline{Op: OpReceive, Seq: chosen.Seq, Bundle: chosen.SHA256, At: opts.Now, Files: manifestHashes(m)} + state.LastReceive = &ReceiveRecord{SnapshotID: snapID, Seq: chosen.Seq, Bundle: chosen.SHA256, At: opts.Now, Paths: affected} + state.RecordWritten(affected...) + if err := state.Save(); err != nil { + return nil, fmt.Errorf("work: cargo landed but saving local state failed: %w", err) + } + if err := st.AppendClaim(key, opts.Account, ClaimEvent{Op: OpReceive, Seq: chosen.Seq, Bundle: chosen.SHA256, At: opts.Now}); err != nil { + return nil, fmt.Errorf("work: cargo landed but recording the claim failed: %w", err) + } + return &ReceiveResult{Ref: chosen, Manifest: m, Written: affected, Skipped: skipped, SnapshotID: snapID}, nil +} + +// chooseBundle picks the bundle to receive: an explicitly named one, or the +// single bundle at the highest sequence — an equal-seq tie there is refused. +func chooseBundle(refs []BundleRef, claims []Claim, explicit string) (BundleRef, error) { + if explicit != "" { + for _, r := range refs { + if r.SHA256 == explicit { + return r, nil + } + } + return BundleRef{}, fmt.Errorf("work: no bundle with hash %s in the store", explicit) + } + top := refs[len(refs)-1].Seq + var tied []BundleRef + for _, r := range refs { + if r.Seq == top { + tied = append(tied, r) + } + } + if len(tied) > 1 { + return BundleRef{}, &TieError{Refs: tied, Claims: claims} + } + return tied[0], nil +} + +// planReceive builds the write/remove plan for every included item under its +// receive policy. It returns an unlock func when the transcript store's +// advisory lock was taken (held across the writes). +func planReceive(lc Locator, m *Manifest, contents map[string]cargoContent, state *State, force bool) (writes []pendingWrite, removals []string, skipped []string, unlock func(), err error) { + items := BuiltinItems() + byName := map[string]Item{} + for _, it := range items { + byName[it.Name] = it + } + // The in-repo targets are gated by the .work.local symlink guard once, + // up front (both file items live there). + if _, err := workLocalDir(lc.ProjectDir); err != nil { + return nil, nil, nil, nil, err + } + + baseline := map[string]map[string]string{} + if state.Baseline != nil { + baseline = state.Baseline.Files + } + + for _, mi := range m.Items { + if !mi.Included { + continue + } + it, ok := byName[mi.Name] + if !ok { + return nil, nil, nil, unlock, fmt.Errorf("work: manifest names item %q, which this ferry's registry does not know", mi.Name) + } + root, err := it.Locate(lc) + if err != nil { + return nil, nil, nil, unlock, err + } + switch { + case it.Policy == PolicyGuardedOverwrite && it.Kind == KindFile: + if len(mi.Files) != 1 { + return nil, nil, nil, unlock, fmt.Errorf("work: item %q must carry exactly one file, has %d", mi.Name, len(mi.Files)) + } + mf := mi.Files[0] + if mf.Path != filepath.Base(root) { + return nil, nil, nil, unlock, fmt.Errorf("work: item %q names %q, expected %q", mi.Name, mf.Path, filepath.Base(root)) + } + in := contents[mi.Name+"/"+mf.Path] + destHash, exists, err := hashFileIfExists(root) + if err != nil { + return nil, nil, nil, unlock, err + } + if exists && destHash == mf.SHA256 { + skipped = append(skipped, root) + continue + } + if exists && !force { + if why, diverged := guardAgainstBaseline(baseline[mi.Name], mf.Path, destHash); diverged { + return nil, nil, nil, unlock, &DivergedError{Item: mi.Name, Dest: root, Why: why} + } + } + writes = append(writes, pendingWrite{path: root, data: in.data, mode: in.mode}) + + case it.Policy == PolicyGuardedOverwrite && it.Kind == KindDir: + existing, err := hashDir(root) + if err != nil { + return nil, nil, nil, unlock, err + } + incoming := map[string]ManifestFile{} + for _, mf := range mi.Files { + incoming[mf.Path] = mf + } + if !force { + for rel, destHash := range existing { + if mf, ok := incoming[rel]; ok && destHash == mf.SHA256 { + continue + } + if why, diverged := guardAgainstBaseline(baseline[mi.Name], rel, destHash); diverged { + return nil, nil, nil, unlock, &DivergedError{Item: mi.Name, Dest: filepath.Join(root, filepath.FromSlash(rel)), Why: why} + } + } + } + // Guarded REPLACE: destination becomes exactly the cargo. + for _, mf := range mi.Files { + dest := filepath.Join(root, filepath.FromSlash(mf.Path)) + if existing[mf.Path] == mf.SHA256 { + skipped = append(skipped, dest) + continue + } + in := contents[mi.Name+"/"+mf.Path] + writes = append(writes, pendingWrite{path: dest, data: in.data, mode: in.mode}) + } + for rel := range existing { + if _, ok := incoming[rel]; !ok { + removals = append(removals, filepath.Join(root, filepath.FromSlash(rel))) + } + } + + case it.Policy == PolicyUnionMerge: + var trWrites []pendingWrite + existing, err := hashDir(root) + if err != nil { + return nil, nil, nil, unlock, err + } + for _, mf := range mi.Files { + dest := filepath.Join(root, filepath.FromSlash(mf.Path)) + if _, ok := existing[mf.Path]; ok { + skipped = append(skipped, dest) + continue + } + in := contents[mi.Name+"/"+mf.Path] + trWrites = append(trWrites, pendingWrite{path: dest, data: in.data, mode: in.mode}) + } + if len(trWrites) > 0 { + // Take the transcript store's advisory lock while writing so + // the receive cannot interleave with a live session-end + // capture. + release, err := acquireDirLock(root) + if err != nil { + return nil, nil, nil, unlock, err + } + unlock = release + writes = append(writes, trWrites...) + } + + default: + return nil, nil, nil, unlock, fmt.Errorf("work: item %q has unsupported policy/kind %s/%s", mi.Name, it.Policy, it.Kind) + } + } + return writes, removals, skipped, unlock, nil +} + +// guardAgainstBaseline decides whether a destination file's current hash +// diverges from what this account last held. +func guardAgainstBaseline(itemBaseline map[string]string, rel, destHash string) (string, bool) { + base, ok := itemBaseline[rel] + if !ok { + return "it is already populated and this account has no baseline for it (first receive into a non-empty destination)", true + } + if base != destHash { + return "it changed since this account last held the baton", true + } + return "", false +} + +// takenBack reports whether the packer recorded a take-back for the bundle. +func takenBack(claims []Claim, packer string, ref BundleRef) bool { + for _, c := range claims { + if c.Account != packer { + continue + } + for _, ev := range c.Events { + if ev.Op == OpTakeBack && ev.Seq == ref.Seq && (ev.Bundle == "" || ev.Bundle == ref.SHA256) { + return true + } + } + } + return false +} + +// projectUnderHome enforces the v1 landing rule: the project repo must live +// under this account's $HOME (out-of-home repos are a documented limitation, +// not silently unguarded writes). +func projectUnderHome(lc Locator) error { + home, err := filepath.EvalSymlinks(lc.Home) + if err != nil { + return err + } + repo, err := filepath.EvalSymlinks(lc.ProjectDir) + if err != nil { + return err + } + if repo != home && !strings.HasPrefix(repo, home+string(filepath.Separator)) { + return fmt.Errorf("work: project %s is outside this account's home %s — v1 lands work state only for in-home repos", lc.ProjectDir, lc.Home) + } + return nil +} + +// hashFileIfExists returns the sha256 of a regular file, or exists=false. +func hashFileIfExists(path string) (string, bool, error) { + fi, err := os.Lstat(path) + if errors.Is(err, fs.ErrNotExist) { + return "", false, nil + } + if err != nil { + return "", false, err + } + if !fi.Mode().IsRegular() { + return "", false, fmt.Errorf("work: %s is not a regular file (mode %v)", path, fi.Mode()) + } + data, err := os.ReadFile(path) + if err != nil { + return "", false, err + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]), true, nil +} + +// hashDir maps every regular file under root (recursively) to its content +// hash, keyed by forward-slash rel path. A missing root is an empty map; a +// non-regular entry is a refusal. +func hashDir(root string) (map[string]string, error) { + out := map[string]string{} + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + if p == root && errors.Is(walkErr, fs.ErrNotExist) { + return filepath.SkipAll + } + return walkErr + } + if d.IsDir() { + return nil + } + if !d.Type().IsRegular() { + return fmt.Errorf("work: %s is not a regular file (mode %v)", p, d.Type()) + } + rel, err := filepath.Rel(root, p) + if err != nil { + return err + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + sum := sha256.Sum256(data) + out[filepath.ToSlash(rel)] = hex.EncodeToString(sum[:]) + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +// acquireDirLock takes the advisory lock file in dir (creating dir first), +// returning the release func. A held lock is a refusal, not a wait. +func acquireDirLock(dir string) (func(), error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + lock := filepath.Join(dir, ".lock") + f, err := os.OpenFile(lock, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if errors.Is(err, fs.ErrExist) { + return nil, fmt.Errorf("work: %s is locked (a live session capture may be writing) — retry when it finishes, or remove the stale lock", lock) + } + if err != nil { + return nil, err + } + f.Close() + return func() { os.Remove(lock) }, nil +} + +// manifestHashes indexes a manifest's included files as item -> rel -> sha. +func manifestHashes(m *Manifest) map[string]map[string]string { + out := map[string]map[string]string{} + for _, mi := range m.Items { + if !mi.Included { + continue + } + for _, mf := range mi.Files { + if out[mi.Name] == nil { + out[mi.Name] = map[string]string{} + } + out[mi.Name][mf.Path] = mf.SHA256 + } + } + return out +} + +// intersects reports whether two sorted root-SHA sets share a member. +func intersects(a, b []string) bool { + set := map[string]bool{} + for _, x := range a { + set[x] = true + } + for _, y := range b { + if set[y] { + return true + } + } + return false +} + +// readBundle reads and fully verifies a cargo bundle: the file's overall +// hash must match its name, every manifest-listed file must be present with +// matching size and hash, and no unlisted member may exist. Cargo is +// untrusted input from shared media — nothing is landed on trust. +func readBundle(path, wantSHA string) (*Manifest, map[string]cargoContent, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, nil, err + } + sum := sha256.Sum256(data) + if got := hex.EncodeToString(sum[:]); got != wantSHA { + return nil, nil, fmt.Errorf("work: bundle %s content hash %s does not match its name — the file was modified in the store", filepath.Base(path), got[:12]) + } + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, nil, fmt.Errorf("work: %s is not a cargo bundle: %w", filepath.Base(path), err) + } + m, err := readManifestMember(zr) + if err != nil { + return nil, nil, err + } + expected := map[string]ManifestFile{} + for _, mi := range m.Items { + for _, mf := range mi.Files { + expected[mi.Name+"/"+mf.Path] = mf + } + } + contents := map[string]cargoContent{} + for _, f := range zr.File { + if f.Name == ManifestMember { + continue + } + mf, ok := expected[f.Name] + if !ok { + return nil, nil, fmt.Errorf("work: bundle member %q is not in the manifest — refusing the bundle", f.Name) + } + rc, err := f.Open() + if err != nil { + return nil, nil, err + } + // Read at most the declared size + 1: a decompression bomb or a + // tampered member shows up as a size mismatch, never as unbounded + // memory. + buf, err := io.ReadAll(io.LimitReader(rc, mf.Size+1)) + rc.Close() + if err != nil { + return nil, nil, err + } + if int64(len(buf)) != mf.Size { + return nil, nil, fmt.Errorf("work: bundle member %q size %d does not match the manifest's %d", f.Name, len(buf), mf.Size) + } + s := sha256.Sum256(buf) + if hex.EncodeToString(s[:]) != mf.SHA256 { + return nil, nil, fmt.Errorf("work: bundle member %q content does not match its manifest hash", f.Name) + } + contents[f.Name] = cargoContent{data: buf, mode: cargoMode(f.Mode())} + } + for name := range expected { + if _, ok := contents[name]; !ok { + return nil, nil, fmt.Errorf("work: manifest lists %q but the bundle has no such member", name) + } + } + return m, contents, nil +} + +// readManifestMember extracts and decodes the manifest from an open zip. +func readManifestMember(zr *zip.Reader) (*Manifest, error) { + for _, f := range zr.File { + if f.Name != ManifestMember { + continue + } + rc, err := f.Open() + if err != nil { + return nil, err + } + // The manifest itself is small; 8 MiB is far beyond any real one and + // bounds a tampered header. + data, err := io.ReadAll(io.LimitReader(rc, 8<<20)) + rc.Close() + if err != nil { + return nil, err + } + return DecodeManifest(data) + } + return nil, fmt.Errorf("work: bundle has no %s member", ManifestMember) +} + +// WorkRestore reverts exactly the last receive from its per-receive snapshot +// and clears the record, so a second call is a clear "nothing to revert". +func WorkRestore(eng *backup.Engine, state *State) (string, error) { + if state.LastReceive == nil { + return "", fmt.Errorf("work: no receive to revert on this account") + } + snapID := state.LastReceive.SnapshotID + if err := eng.RestoreSnapshot(snapID); err != nil { + return "", err + } + state.LastReceive = nil + if err := state.Save(); err != nil { + return "", err + } + return snapID, nil +} + +// LocateProject finds the store directory holding this project's cargo: the +// computed-key directory first, then a manifest scan of the remaining store +// directories for a root-SHA-set intersection (a subtree import can reorder +// rev-list output without orphaning existing cargo). A corrupt foreign +// bundle never blocks the scan — it is simply not a match. +func (st *Store) LocateProject(id Identity) (string, []BundleRef, error) { + refs, err := st.Bundles(id.Key) + if err != nil { + return "", nil, err + } + if len(refs) > 0 { + return id.Key, refs, nil + } + entries, err := os.ReadDir(st.root) + if errors.Is(err, fs.ErrNotExist) { + return id.Key, nil, nil + } + if err != nil { + return "", nil, err + } + for _, e := range entries { + if !e.IsDir() || e.Name() == id.Key || !rootSHA.MatchString(e.Name()) { + continue + } + cand, err := st.Bundles(e.Name()) + if err != nil || len(cand) == 0 { + continue + } + newest := cand[len(cand)-1] + m, _, err := readBundle(newest.Path, newest.SHA256) + if err != nil { + continue + } + if intersects(m.Roots, id.Roots) { + return e.Name(), cand, nil + } + } + return id.Key, nil, nil +} diff --git a/internal/work/receive_test.go b/internal/work/receive_test.go new file mode 100644 index 0000000..c374d62 --- /dev/null +++ b/internal/work/receive_test.go @@ -0,0 +1,420 @@ +package work + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/REPPL/ferry/internal/backup" +) + +// handoffFixture is a two-account setup sharing one cargo store: alice (the +// packFixture) hands over, bob picks up in his own home with his own clone. +type handoffFixture struct { + alice *packFixture + // bob's side + home string + repo string + lc Locator + state *State + eng *backup.Engine +} + +func newHandoffFixture(t *testing.T) *handoffFixture { + t.Helper() + fx := newPackFixture(t) + if _, err := Pack(fx.st, fx.lc, fx.id, fx.state, defaultOpts()); err != nil { + t.Fatalf("alice pack: %v", err) + } + + bobHome := t.TempDir() + bobRepo := filepath.Join(bobHome, "src", "proj") + if err := os.MkdirAll(filepath.Dir(bobRepo), 0o755); err != nil { + t.Fatal(err) + } + gitTest(t, filepath.Dir(bobRepo), "clone", "-q", "file://"+fx.repo, bobRepo) + + id, err := ProjectIdentity(bobRepo) + if err != nil { + t.Fatal(err) + } + if id.Key != fx.id.Key { + t.Fatalf("clone changed identity: %s vs %s", id.Key, fx.id.Key) + } + state, err := LoadStateAt(filepath.Join(bobHome, ".local", "state", "ferry"), id.Key) + if err != nil { + t.Fatal(err) + } + eng, err := backup.NewAt(filepath.Join(bobHome, ".local", "state", "ferry")) + if err != nil { + t.Fatal(err) + } + return &handoffFixture{ + alice: fx, + home: bobHome, + repo: bobRepo, + lc: Locator{Home: bobHome, ProjectDir: bobRepo, StoreKey: id.Key}, + state: state, + eng: eng, + } +} + +func bobOpts() ReceiveOptions { + return ReceiveOptions{Account: "bob@laptop", Now: "2026-07-18T11:00:00Z"} +} + +func (h *handoffFixture) receive(t *testing.T, opts ReceiveOptions) (*ReceiveResult, error) { + t.Helper() + id, err := ProjectIdentity(h.lc.ProjectDir) + if err != nil { + t.Fatal(err) + } + return Receive(h.alice.st, h.eng, h.lc, id, h.state, opts) +} + +func TestReceive_HappyPath(t *testing.T) { + h := newHandoffFixture(t) + res, err := h.receive(t, bobOpts()) + if err != nil { + t.Fatalf("Receive: %v", err) + } + if res.TakeBack { + t.Fatal("receive on bob reported take-back") + } + if res.Ref.Seq != 1 { + t.Errorf("seq = %d, want 1", res.Ref.Seq) + } + + got, err := os.ReadFile(filepath.Join(h.repo, ".abcd", ".work.local", "NEXT.md")) + if err != nil || string(got) != "# NEXT\ncontinue here\n" { + t.Errorf("NEXT.md = %q, %v", got, err) + } + mem := filepath.Join(h.home, ".claude", "projects", ClaudeProjectsKey(h.repo), "memory", "MEMORY.md") + if _, err := os.Stat(mem); err != nil { + t.Errorf("memory not landed: %v", err) + } + tr := filepath.Join(h.home, ".abcd", "history", h.lc.StoreKey, "sess-1.jsonl") + if _, err := os.Stat(tr); err != nil { + t.Errorf("transcript not landed: %v", err) + } + + if h.state.Baseline == nil || h.state.Baseline.Op != OpReceive || h.state.Baseline.Seq != 1 { + t.Errorf("baseline = %+v", h.state.Baseline) + } + if h.state.LastReceive == nil || h.state.LastReceive.SnapshotID == "" { + t.Errorf("last receive = %+v", h.state.LastReceive) + } + claims, err := h.alice.st.Claims(h.lc.StoreKey) + if err != nil { + t.Fatal(err) + } + var sawReceive bool + for _, c := range claims { + for _, ev := range c.Events { + if c.Account == "bob@laptop" && ev.Op == OpReceive && ev.Seq == 1 { + sawReceive = true + } + } + } + if !sawReceive { + t.Errorf("no bob receive claim in %+v", claims) + } +} + +func TestReceive_UnionMergeNeverOverwritesOrDeletes(t *testing.T) { + h := newHandoffFixture(t) + trDir := filepath.Join(h.home, ".abcd", "history", h.lc.StoreKey) + writeFileT(t, filepath.Join(trDir, "sess-1.jsonl"), "bob's own copy") + writeFileT(t, filepath.Join(trDir, "sess-bob.jsonl"), "bob only") + + res, err := h.receive(t, bobOpts()) + if err != nil { + t.Fatalf("Receive: %v", err) + } + if got, _ := os.ReadFile(filepath.Join(trDir, "sess-1.jsonl")); string(got) != "bob's own copy" { + t.Errorf("union-merge overwrote an existing transcript: %q", got) + } + if _, err := os.Stat(filepath.Join(trDir, "sess-bob.jsonl")); err != nil { + t.Error("union-merge deleted a destination-only transcript") + } + var skipped bool + for _, s := range res.Skipped { + if strings.HasSuffix(s, "sess-1.jsonl") { + skipped = true + } + } + if !skipped { + t.Errorf("skip not reported: %+v", res.Skipped) + } +} + +func TestReceive_GuardedOverwriteRefusesDivergence(t *testing.T) { + h := newHandoffFixture(t) + if _, err := h.receive(t, bobOpts()); err != nil { + t.Fatal(err) + } + // Bob edits the note, then alice packs again and bob tries to receive: + // the destination changed since bob last held the baton. + writeFileT(t, filepath.Join(h.repo, ".abcd", ".work.local", "NEXT.md"), "bob's local edits\n") + writeFileT(t, filepath.Join(h.alice.repo, ".abcd", ".work.local", "NEXT.md"), "alice v2\n") + if _, err := Pack(h.alice.st, h.alice.lc, h.alice.id, h.alice.state, defaultOpts()); err != nil { + t.Fatal(err) + } + + _, err := h.receive(t, bobOpts()) + var de *DivergedError + if !errors.As(err, &de) { + t.Fatalf("err = %v, want *DivergedError", err) + } + + opts := bobOpts() + opts.Force = true + if _, err := h.receive(t, opts); err != nil { + t.Fatalf("forced receive: %v", err) + } + if got, _ := os.ReadFile(filepath.Join(h.repo, ".abcd", ".work.local", "NEXT.md")); string(got) != "alice v2\n" { + t.Errorf("NEXT.md after force = %q", got) + } +} + +func TestReceive_FirstReceiveIntoPopulatedRefused(t *testing.T) { + h := newHandoffFixture(t) + writeFileT(t, filepath.Join(h.repo, ".abcd", ".work.local", "NEXT.md"), "bob's pre-existing note\n") + _, err := h.receive(t, bobOpts()) + var de *DivergedError + if !errors.As(err, &de) { + t.Fatalf("err = %v, want *DivergedError (first receive into populated dest)", err) + } + opts := bobOpts() + opts.Force = true + if _, err := h.receive(t, opts); err != nil { + t.Fatalf("forced: %v", err) + } +} + +func TestReceive_IdenticalContentIsNotDivergence(t *testing.T) { + h := newHandoffFixture(t) + // Destination already holds exactly the incoming content: no refusal. + writeFileT(t, filepath.Join(h.repo, ".abcd", ".work.local", "NEXT.md"), "# NEXT\ncontinue here\n") + if _, err := h.receive(t, bobOpts()); err != nil { + t.Fatalf("identical-content receive refused: %v", err) + } +} + +func TestReceive_TakeBackOnPackerAccount(t *testing.T) { + h := newHandoffFixture(t) + fx := h.alice + id, err := ProjectIdentity(fx.repo) + if err != nil { + t.Fatal(err) + } + eng, err := backup.NewAt(filepath.Join(fx.home, ".local", "state", "ferry-eng")) + if err != nil { + t.Fatal(err) + } + // Alice edits after packing, then reclaims her baton. + writeFileT(t, filepath.Join(fx.repo, ".abcd", ".work.local", "NEXT.md"), "alice kept working\n") + res, err := Receive(fx.st, eng, fx.lc, id, fx.state, ReceiveOptions{Account: "alice@studio", Now: "2026-07-18T11:00:00Z"}) + if err != nil { + t.Fatalf("take-back: %v", err) + } + if !res.TakeBack { + t.Fatal("packer-account receive did not report take-back") + } + // It restores nothing… + if got, _ := os.ReadFile(filepath.Join(fx.repo, ".abcd", ".work.local", "NEXT.md")); string(got) != "alice kept working\n" { + t.Errorf("take-back overwrote local work: %q", got) + } + // …clears the marker, and records the claim. + if _, ok, _ := ReadHandoverMarker(fx.repo); ok { + t.Error("handover marker survived the take-back") + } + claims, _ := fx.st.Claims(id.Key) + var sawTakeBack bool + for _, c := range claims { + for _, ev := range c.Events { + if c.Account == "alice@studio" && ev.Op == OpTakeBack { + sawTakeBack = true + } + } + } + if !sawTakeBack { + t.Errorf("no take-back claim in %+v", claims) + } + + // Bob's receive of the taken-back bundle now refuses without force. + _, err = h.receive(t, bobOpts()) + var se *SupersededError + if !errors.As(err, &se) { + t.Fatalf("err = %v, want *SupersededError", err) + } + opts := bobOpts() + opts.Force = true + if _, err := h.receive(t, opts); err != nil { + t.Fatalf("forced receive of superseded bundle: %v", err) + } +} + +func TestReceive_EqualSeqTieRefused(t *testing.T) { + h := newHandoffFixture(t) + // A same-seq fork from another account. + forkName := "000001-" + strings.Repeat("9", 64) + ".ferrywork" + if err := os.WriteFile(filepath.Join(h.alice.st.ProjectDir(h.lc.StoreKey), forkName), []byte("fork"), 0o644); err != nil { + t.Fatal(err) + } + _, err := h.receive(t, bobOpts()) + var te *TieError + if !errors.As(err, &te) { + t.Fatalf("err = %v, want *TieError", err) + } + if len(te.Refs) != 2 { + t.Errorf("tie refs = %+v", te.Refs) + } + + // Naming one bundle explicitly resolves the tie. + opts := bobOpts() + opts.BundleSHA256 = te.Refs[0].SHA256 + if te.Refs[0].SHA256 == strings.Repeat("9", 64) { + opts.BundleSHA256 = te.Refs[1].SHA256 + } + if _, err := h.receive(t, opts); err != nil { + t.Fatalf("explicit-bundle receive: %v", err) + } +} + +func TestReceive_MemoryTargetGuard(t *testing.T) { + h := newHandoffFixture(t) + memDir := filepath.Join(h.home, ".claude", "projects", ClaudeProjectsKey(h.repo), "memory") + writeFileT(t, filepath.Join(memDir, "OTHER.md"), "someone else's memory\n") + _, err := h.receive(t, bobOpts()) + var de *DivergedError + if !errors.As(err, &de) { + t.Fatalf("err = %v, want *DivergedError (populated memory target)", err) + } + opts := bobOpts() + opts.Force = true + if _, err := h.receive(t, opts); err != nil { + t.Fatalf("forced: %v", err) + } +} + +func TestReceive_TranscriptLockRefuses(t *testing.T) { + h := newHandoffFixture(t) + trDir := filepath.Join(h.home, ".abcd", "history", h.lc.StoreKey) + writeFileT(t, filepath.Join(trDir, ".lock"), "held\n") + if _, err := h.receive(t, bobOpts()); err == nil { + t.Fatal("receive proceeded under a held transcript-store lock") + } +} + +func TestReceive_NoCargoIsClearError(t *testing.T) { + h := newHandoffFixture(t) + // An unrelated project (different identity) finds no cargo. + otherRepo := newRepo(t) + id, err := ProjectIdentity(otherRepo) + if err != nil { + t.Fatal(err) + } + lc := Locator{Home: h.home, ProjectDir: otherRepo, StoreKey: id.Key} + if _, err := Receive(h.alice.st, h.eng, lc, id, h.state, bobOpts()); err == nil { + t.Fatal("receive for a project with no cargo succeeded") + } +} + +func TestWorkRestore_RevertsLastReceive(t *testing.T) { + h := newHandoffFixture(t) + if _, err := h.receive(t, bobOpts()); err != nil { + t.Fatal(err) + } + notePath := filepath.Join(h.repo, ".abcd", ".work.local", "NEXT.md") + if _, err := os.Stat(notePath); err != nil { + t.Fatal("receive did not land the note") + } + + snapID, err := WorkRestore(h.eng, h.state) + if err != nil { + t.Fatalf("WorkRestore: %v", err) + } + if snapID == "" { + t.Error("empty snapshot id") + } + // The note did not exist before the receive; the revert removes it. + if _, err := os.Stat(notePath); err == nil { + t.Error("NEXT.md survived the work restore") + } + if h.state.LastReceive != nil { + t.Error("LastReceive not cleared") + } + if _, err := WorkRestore(h.eng, h.state); err == nil { + t.Error("second WorkRestore succeeded, want 'nothing to revert'") + } +} + +func TestLocateProject_MatchesOnRootIntersection(t *testing.T) { + h := newHandoffFixture(t) + st := h.alice.st + id := h.alice.id + + // Rename the store dir to a DIFFERENT root of the same project (as a + // subtree import that reordered rev-list would produce): the manifest + // scan must still find the cargo by set intersection. + altKey := strings.Repeat("b", 40) + if err := os.Rename(st.ProjectDir(id.Key), st.ProjectDir(altKey)); err != nil { + t.Fatal(err) + } + key, refs, err := st.LocateProject(id) + if err != nil { + t.Fatalf("LocateProject: %v", err) + } + if key != altKey || len(refs) != 1 { + t.Errorf("LocateProject = %q with %d refs, want %q with 1", key, len(refs), altKey) + } +} + +func TestReceive_FailedPlanReleasesTranscriptLock(t *testing.T) { + h := newHandoffFixture(t) + st := h.alice.st + key := h.lc.StoreKey + + // A crafted bundle whose manifest lists transcripts FIRST (lock taken) + // and then an item this registry does not know (plan fails after the + // lock): the failed receive must not leave the advisory lock behind. + files := []packedFile{ + newPackedFile(ItemTranscripts, "sess-x.jsonl", []byte("x"), 0o644), + newPackedFile("bogus-item", "y", []byte("y"), 0o644), + } + m := &Manifest{ + FerryVersion: "test", + Key: key, + Roots: []string{key}, + RepoPath: h.alice.repo, + Home: h.alice.home, + PackedBy: "alice@studio", + ScanVerdict: ScanVerdictClean, + Items: []ManifestItem{ + {Name: ItemTranscripts, Included: true, Files: []ManifestFile{ + {Path: "sess-x.jsonl", Size: 1, SHA256: files[0].sha}, + }}, + {Name: "bogus-item", Included: true, Files: []ManifestFile{ + {Path: "y", Size: 1, SHA256: files[1].sha}, + }}, + }, + } + if _, err := st.WriteBundle(key, func(seq uint64) ([]byte, error) { + m.Seq = seq + return buildCargoZip(m, files) + }); err != nil { + t.Fatal(err) + } + + _, err := h.receive(t, bobOpts()) + if err == nil || !strings.Contains(err.Error(), "bogus-item") { + t.Fatalf("err = %v, want unknown-item refusal", err) + } + lock := filepath.Join(h.home, ".abcd", "history", key, ".lock") + if _, statErr := os.Stat(lock); statErr == nil { + t.Error("failed receive left the transcript advisory lock behind") + } +} From 5a65facd92aad13ea4206882a382b1edcb37ab59 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:51:33 +0100 Subject: [PATCH 07/13] feat: add store pruning (keep-last-N and targeted removal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prune applies the retention policy on demand, refusing keep < 1; RemoveBundle deletes one bundle by hash — the targeted form the equal-seq tie message points users at. Assisted-by: Claude:claude-fable-5 --- internal/work/store.go | 38 +++++++++++++++++++++++++++ internal/work/store_test.go | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/internal/work/store.go b/internal/work/store.go index fe5e791..25c865c 100644 --- a/internal/work/store.go +++ b/internal/work/store.go @@ -266,3 +266,41 @@ func (st *Store) ensureProjectDir(key string) error { } return nil } + +// Prune applies keep-last-N to a project's bundles, removing the oldest ones +// beyond keep and returning what was removed. keep must be at least 1 — +// pruning every bundle would erase the handover history entirely. +func (st *Store) Prune(key string, keep int) ([]BundleRef, error) { + if keep < 1 { + return nil, fmt.Errorf("work: prune must keep at least 1 bundle, got %d", keep) + } + refs, err := st.Bundles(key) + if err != nil { + return nil, err + } + if len(refs) <= keep { + return nil, nil + } + victims := refs[:len(refs)-keep] + for _, v := range victims { + if err := os.Remove(v.Path); err != nil { + return nil, err + } + } + return victims, nil +} + +// RemoveBundle deletes one bundle by content hash — the targeted form used +// to resolve an equal-seq fork. +func (st *Store) RemoveBundle(key, sha256Hex string) error { + refs, err := st.Bundles(key) + if err != nil { + return err + } + for _, r := range refs { + if r.SHA256 == sha256Hex { + return os.Remove(r.Path) + } + } + return fmt.Errorf("work: no bundle with hash %s in the store", sha256Hex) +} diff --git a/internal/work/store_test.go b/internal/work/store_test.go index 76beb31..c456172 100644 --- a/internal/work/store_test.go +++ b/internal/work/store_test.go @@ -198,3 +198,55 @@ func TestStore_ClaimAccountNameValidated(t *testing.T) { } } } + +func TestStore_PruneKeepsLastN(t *testing.T) { + st := openTestStore(t) + key := testKey() + for i := 0; i < 5; i++ { + data := []byte{byte('a' + i)} + if _, err := st.WriteBundle(key, func(uint64) ([]byte, error) { return data, nil }); err != nil { + t.Fatal(err) + } + } + removed, err := st.Prune(key, 3) + if err != nil { + t.Fatalf("Prune: %v", err) + } + if len(removed) != 2 || removed[0].Seq != 1 || removed[1].Seq != 2 { + t.Errorf("removed = %+v, want seqs 1 and 2", removed) + } + left, err := st.Bundles(key) + if err != nil { + t.Fatal(err) + } + if len(left) != 3 || left[0].Seq != 3 { + t.Errorf("left = %+v, want seqs 3..5", left) + } + + // Keeping at least as many as exist removes nothing. + if removed, err := st.Prune(key, 10); err != nil || len(removed) != 0 { + t.Errorf("Prune(10) = %+v, %v", removed, err) + } + // keep < 1 would delete every bundle: refused. + if _, err := st.Prune(key, 0); err == nil { + t.Error("Prune(0) accepted, want refusal") + } +} + +func TestStore_RemoveBundle(t *testing.T) { + st := openTestStore(t) + key := testKey() + ref, err := st.WriteBundle(key, func(uint64) ([]byte, error) { return []byte("x"), nil }) + if err != nil { + t.Fatal(err) + } + if err := st.RemoveBundle(key, ref.SHA256); err != nil { + t.Fatalf("RemoveBundle: %v", err) + } + if left, _ := st.Bundles(key); len(left) != 0 { + t.Errorf("bundle survived removal: %+v", left) + } + if err := st.RemoveBundle(key, strings.Repeat("0", 64)); err == nil { + t.Error("RemoveBundle of absent hash succeeded, want error") + } +} From e36e16a6fb17ddb8bc8de5275fdc26750c47afea Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:16:30 +0100 Subject: [PATCH 08/13] feat: add work status engine, restore enumeration, and [work] config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status assembles the read-only picture the status verb renders: cargo list, unresolved equal-seq forks, merged claims, handover-marker freshness ("handed over, not modified since" vs "modified after"), divergence of guarded items against the local baseline, store size, and other projects present in the store by manifest repo name. AllWrittenPaths unions every project's written set — the enumeration the `ferry restore work` domain form reverts. The machine config gains the optional [work] table (store path, keep-last-N, sync-root opt-in); hostname and repo stay the only required fields. Assisted-by: Claude:claude-fable-5 --- internal/config/machine.go | 18 +++ internal/config/machine_test.go | 46 ++++++ internal/work/status.go | 264 ++++++++++++++++++++++++++++++++ internal/work/status_test.go | 111 ++++++++++++++ 4 files changed, 439 insertions(+) create mode 100644 internal/work/status.go create mode 100644 internal/work/status_test.go diff --git a/internal/config/machine.go b/internal/config/machine.go index c56fff2..411d65c 100644 --- a/internal/config/machine.go +++ b/internal/config/machine.go @@ -27,6 +27,24 @@ type MachineConfig struct { // `omitempty` so an unmanaged config writes no `managed` key, and an OLD // config (hostname+repo only, no `managed` key) still loads with managed=false. Managed bool `toml:"managed,omitempty"` + // Work is the optional [work] table: this machine's cargo-store location + // and retention for the work verbs. It is machine-specific (a shared or + // removable path that differs per machine), so it lives here and never in + // the repo-side manifests. A nil Work means the table is absent and the + // work verbs refuse with setup guidance. + Work *WorkConfig `toml:"work,omitempty"` +} + +// WorkConfig is the [work] table's content. +type WorkConfig struct { + // Store is the cargo-store directory (shared or portable media). + Store string `toml:"store,omitempty"` + // Keep is the keep-last-N retention pack applies per project (0 means the + // default). + Keep int `toml:"keep,omitempty"` + // AllowSyncRoot accepts a store under a known cloud-sync directory — + // an explicit, persisted opt-in to the loud warning. + AllowSyncRoot bool `toml:"allow_sync_root,omitempty"` } // LoadMachineConfig reads and parses ~/.config/ferry/config.toml (resolved via diff --git a/internal/config/machine_test.go b/internal/config/machine_test.go index 3bed7a0..ab095de 100644 --- a/internal/config/machine_test.go +++ b/internal/config/machine_test.go @@ -112,3 +112,49 @@ func indexOf(haystack, needle string) int { } return -1 } + +func TestMachineConfig_WorkTableOptional(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + + // Without [work] the config loads with no work settings. + plain := MachineConfig{Hostname: "h", Repo: filepath.Join(dir, "clone")} + if err := saveMachineConfigTo(path, plain); err != nil { + t.Fatal(err) + } + got, err := loadMachineConfigFrom(path) + if err != nil { + t.Fatal(err) + } + if got.Work != nil { + t.Errorf("Work = %+v, want nil when the table is absent", got.Work) + } + + // With [work] the settings round-trip. + withWork := plain + withWork.Work = &WorkConfig{Store: "/Users/Shared/ferry-cargo", Keep: 3, AllowSyncRoot: true} + if err := saveMachineConfigTo(path, withWork); err != nil { + t.Fatal(err) + } + got, err = loadMachineConfigFrom(path) + if err != nil { + t.Fatal(err) + } + if got.Work == nil || got.Work.Store != "/Users/Shared/ferry-cargo" || got.Work.Keep != 3 || !got.Work.AllowSyncRoot { + t.Errorf("Work = %+v", got.Work) + } + + // A hand-written [work] table parses too. + handWritten := "hostname = \"h\"\nrepo = \"/r\"\n\n[work]\nstore = \"/cargo\"\n" + if err := os.WriteFile(path, []byte(handWritten), 0o644); err != nil { + t.Fatal(err) + } + got, err = loadMachineConfigFrom(path) + if err != nil { + t.Fatal(err) + } + if got.Work == nil || got.Work.Store != "/cargo" || got.Work.Keep != 0 { + t.Errorf("hand-written Work = %+v", got.Work) + } +} diff --git a/internal/work/status.go b/internal/work/status.go new file mode 100644 index 0000000..20932a9 --- /dev/null +++ b/internal/work/status.go @@ -0,0 +1,264 @@ +package work + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/REPPL/ferry/internal/paths" +) + +// ProjectStatus is one project's work-state picture: cargo, claims, +// handover-marker freshness, divergence, and store size. +type ProjectStatus struct { + Key string + StoreDir string + Bundles []BundleRef + // TopTie holds every bundle at the highest sequence when there is more + // than one — an unresolved equal-seq fork. + TopTie []BundleRef + Claims []Claim + Marker *HandoverMarker + // MarkerDirty lists "item/rel" entries modified (or gone missing) since + // the handover marker was recorded. + MarkerDirty []string + Baseline *Baseline + // Diverged lists guarded "item/rel" entries whose current content differs + // from this account's last pack/receive baseline. + Diverged []string + // StoreBytes is the total size of the whole cargo store. + StoreBytes int64 + // OtherProjects names other projects present in the store, by their + // newest manifest's repo basename. + OtherProjects []string +} + +// Status assembles the project's work-state picture. It is read-only: no +// store, state, or project file is mutated. +func Status(st *Store, lc Locator, id Identity, state *State) (*ProjectStatus, error) { + key, refs, err := st.LocateProject(id) + if err != nil { + return nil, err + } + claims, err := st.Claims(key) + if err != nil { + return nil, err + } + s := &ProjectStatus{ + Key: key, + StoreDir: st.ProjectDir(key), + Bundles: refs, + Claims: claims, + Baseline: state.Baseline, + } + if n := len(refs); n > 0 { + top := refs[n-1].Seq + for _, r := range refs { + if r.Seq == top { + s.TopTie = append(s.TopTie, r) + } + } + if len(s.TopTie) == 1 { + s.TopTie = nil + } + } + + current, err := currentItemHashes(lc) + if err != nil { + return nil, err + } + marker, ok, err := ReadHandoverMarker(lc.ProjectDir) + if err != nil { + return nil, err + } + if ok { + s.Marker = marker + s.MarkerDirty = deltas(marker.Files, current) + } + if state.Baseline != nil { + s.Diverged = deltas(guardedOnly(state.Baseline.Files), current) + } + + s.StoreBytes, err = dirBytes(st.Root()) + if err != nil { + return nil, err + } + s.OtherProjects, err = st.otherProjects(key, id) + if err != nil { + return nil, err + } + return s, nil +} + +// currentItemHashes hashes every registry item's current on-disk content, +// keyed item -> rel -> sha. +func currentItemHashes(lc Locator) (map[string]map[string]string, error) { + out := map[string]map[string]string{} + for _, it := range BuiltinItems() { + root, err := it.Locate(lc) + if err != nil { + return nil, err + } + switch it.Kind { + case KindFile: + hash, exists, err := hashFileIfExists(root) + if err != nil { + return nil, err + } + if exists { + out[it.Name] = map[string]string{filepath.Base(root): hash} + } + case KindDir: + hashes, err := hashDir(root) + if err != nil { + return nil, err + } + if len(hashes) > 0 { + out[it.Name] = hashes + } + } + } + return out, nil +} + +// guardedOnly filters an item->rel->sha map down to guarded-overwrite items — +// union-merge items are never divergence. +func guardedOnly(files map[string]map[string]string) map[string]map[string]string { + guarded := map[string]bool{} + for _, it := range BuiltinItems() { + if it.Policy == PolicyGuardedOverwrite { + guarded[it.Name] = true + } + } + out := map[string]map[string]string{} + for item, rels := range files { + if guarded[item] { + out[item] = rels + } + } + return out +} + +// deltas lists "item/rel" entries whose recorded hash no longer matches the +// current content ("(missing)" when the file is gone), sorted. +func deltas(recorded, current map[string]map[string]string) []string { + var out []string + for item, rels := range recorded { + for rel, sha := range rels { + cur, ok := current[item][rel] + switch { + case !ok: + out = append(out, item+"/"+rel+" (missing)") + case cur != sha: + out = append(out, item+"/"+rel) + } + } + } + sort.Strings(out) + return out +} + +// dirBytes sums the regular-file sizes under root; a missing root is 0. +func dirBytes(root string) (int64, error) { + var total int64 + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + if errors.Is(walkErr, fs.ErrNotExist) { + return nil + } + return walkErr + } + if d.Type().IsRegular() { + if info, err := d.Info(); err == nil { + total += info.Size() + } + } + return nil + }) + return total, err +} + +// otherProjects names the store's other project directories by their newest +// manifest's repo basename (or the directory key when unreadable) — orphaned +// or foreign cargo stays visible rather than silent. +func (st *Store) otherProjects(currentKey string, id Identity) ([]string, error) { + entries, err := os.ReadDir(st.root) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var out []string + for _, e := range entries { + if !e.IsDir() || e.Name() == currentKey || !rootSHA.MatchString(e.Name()) { + continue + } + refs, err := st.Bundles(e.Name()) + if err != nil || len(refs) == 0 { + continue + } + newest := refs[len(refs)-1] + label := e.Name()[:12] + if m, _, err := readBundle(newest.Path, newest.SHA256); err == nil { + if intersects(m.Roots, id.Roots) { + continue // same project under another root key, already counted + } + label = filepath.Base(m.RepoPath) + " (" + label + ")" + } + out = append(out, label) + } + sort.Strings(out) + return out, nil +} + +// AllWrittenPaths unions the Written sets of every project's work state under +// ferry's state dir — the enumeration `ferry restore work` reverts. +func AllWrittenPaths() ([]string, error) { + root, err := paths.StateDir() + if err != nil { + return nil, err + } + return AllWrittenPathsAt(root) +} + +// AllWrittenPathsAt is the testable core of AllWrittenPaths. A missing state +// directory is an empty union. +func AllWrittenPathsAt(stateRoot string) ([]string, error) { + dir := filepath.Join(stateRoot, "work") + entries, err := os.ReadDir(dir) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + set := map[string]bool{} + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".json") { + continue + } + key := strings.TrimSuffix(name, ".json") + if !rootSHA.MatchString(key) { + continue + } + s, err := LoadStateAt(stateRoot, key) + if err != nil { + return nil, fmt.Errorf("work: reading state for %s: %w", key, err) + } + for _, p := range s.Written { + set[p] = true + } + } + out := make([]string, 0, len(set)) + for p := range set { + out = append(out, p) + } + sort.Strings(out) + return out, nil +} diff --git a/internal/work/status_test.go b/internal/work/status_test.go new file mode 100644 index 0000000..0da39b3 --- /dev/null +++ b/internal/work/status_test.go @@ -0,0 +1,111 @@ +package work + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestStatus_CleanAfterPack(t *testing.T) { + fx := newPackFixture(t) + if _, err := Pack(fx.st, fx.lc, fx.id, fx.state, defaultOpts()); err != nil { + t.Fatal(err) + } + s, err := Status(fx.st, fx.lc, fx.id, fx.state) + if err != nil { + t.Fatalf("Status: %v", err) + } + if s.Key != fx.id.Key || len(s.Bundles) != 1 || len(s.TopTie) != 0 { + t.Errorf("status = key %s, %d bundles, tie %d", s.Key, len(s.Bundles), len(s.TopTie)) + } + if s.Marker == nil || len(s.MarkerDirty) != 0 { + t.Errorf("marker = %+v dirty %v, want clean marker", s.Marker, s.MarkerDirty) + } + if len(s.Diverged) != 0 { + t.Errorf("diverged = %v, want none", s.Diverged) + } + if s.StoreBytes <= 0 { + t.Errorf("store bytes = %d, want > 0", s.StoreBytes) + } +} + +func TestStatus_MarkerDirtyAfterEdit(t *testing.T) { + fx := newPackFixture(t) + if _, err := Pack(fx.st, fx.lc, fx.id, fx.state, defaultOpts()); err != nil { + t.Fatal(err) + } + writeFileT(t, filepath.Join(fx.repo, ".abcd", ".work.local", "NEXT.md"), "edited after handover\n") + s, err := Status(fx.st, fx.lc, fx.id, fx.state) + if err != nil { + t.Fatal(err) + } + var dirty bool + for _, d := range s.MarkerDirty { + if strings.Contains(d, "NEXT.md") { + dirty = true + } + } + if !dirty { + t.Errorf("MarkerDirty = %v, want NEXT.md flagged", s.MarkerDirty) + } + // The same edit also diverges from the pack baseline. + var diverged bool + for _, d := range s.Diverged { + if strings.Contains(d, "NEXT.md") { + diverged = true + } + } + if !diverged { + t.Errorf("Diverged = %v, want NEXT.md flagged", s.Diverged) + } +} + +func TestStatus_SurfacesEqualSeqTie(t *testing.T) { + fx := newPackFixture(t) + if _, err := Pack(fx.st, fx.lc, fx.id, fx.state, defaultOpts()); err != nil { + t.Fatal(err) + } + forkName := "000001-" + strings.Repeat("9", 64) + ".ferrywork" + writeFileT(t, filepath.Join(fx.st.ProjectDir(fx.id.Key), forkName), "fork") + s, err := Status(fx.st, fx.lc, fx.id, fx.state) + if err != nil { + t.Fatal(err) + } + if len(s.TopTie) != 2 { + t.Errorf("TopTie = %+v, want the fork pair", s.TopTie) + } +} + +func TestAllWrittenPathsAt_UnionsProjects(t *testing.T) { + root := t.TempDir() + s1, err := LoadStateAt(root, strings.Repeat("a", 40)) + if err != nil { + t.Fatal(err) + } + s1.RecordWritten("/home/x/one", "/home/x/shared") + if err := s1.Save(); err != nil { + t.Fatal(err) + } + s2, err := LoadStateAt(root, strings.Repeat("b", 40)) + if err != nil { + t.Fatal(err) + } + s2.RecordWritten("/home/x/two", "/home/x/shared") + if err := s2.Save(); err != nil { + t.Fatal(err) + } + + got, err := AllWrittenPathsAt(root) + if err != nil { + t.Fatal(err) + } + want := []string{"/home/x/one", "/home/x/shared", "/home/x/two"} + if len(got) != 3 || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] { + t.Errorf("AllWrittenPathsAt = %v, want %v", got, want) + } + + // An empty or missing state root is an empty union, not an error. + if got, err := AllWrittenPathsAt(t.TempDir()); err != nil || len(got) != 0 { + t.Errorf("empty root: %v, %v", got, err) + } +} From 02177e9c2a659272b73b35e37fdafda7722942c4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:19:46 +0100 Subject: [PATCH 09/13] feat: wire the ferry work verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The work domain is now reachable from the production entry point: `ferry work pack|receive|status|prune|restore`, self-wired like the bundle noun. The command layer resolves the project identity (with its guards), the [work] cargo store from the machine config (missing table gets setup guidance; --allow-sync-root gives a one-run override), the per-project state, and a sanitised user@host claim identity. Receive and work-restore hold the same engine lock apply holds so they never interleave with a reconcile. Pack applies keep-last-N retention after storing, and --acknowledge pins named secret findings to their current content before a single retry. `ferry restore work` fans the domain out to every work-written path, mirroring the agents expansion. Verified end-to-end against the real binary: pack → status → receive → work restore → take-back → superseded refusal across two fake homes sharing one store. Full unit + eval suites green. Assisted-by: Claude:claude-fable-5 --- cmd/restore.go | 25 ++- cmd/work.go | 420 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 442 insertions(+), 3 deletions(-) create mode 100644 cmd/work.go diff --git a/cmd/restore.go b/cmd/restore.go index 9d5720d..a698838 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -17,6 +17,7 @@ import ( "github.com/REPPL/ferry/internal/paths" "github.com/REPPL/ferry/internal/platform" "github.com/REPPL/ferry/internal/terminal" + "github.com/REPPL/ferry/internal/work" ) func init() { @@ -280,12 +281,20 @@ func scopedRestore(ctx *cmdContext, domains []string, out io.Writer) error { // resolveScopedPaths would misread the name as a dotfile (~/.agents). var rest []string agentsRequested := false + workRequested := false for _, d := range domains { - if d == "agents" { + switch d { + case "agents": agentsRequested = true - continue + case "work": + // The "work" domain fans out to every path the work verbs have + // written, enumerated from the per-project work state files — + // like agents, expanded here before resolveScopedPaths would + // misread the name as a dotfile (~/.work). + workRequested = true + default: + rest = append(rest, d) } - rest = append(rest, d) } // resolveScopedPaths refuses ~/.ssh + path-traversal dotfile names (the security @@ -302,6 +311,16 @@ func scopedRestore(ctx *cmdContext, domains []string, out io.Writer) error { absPaths = append(absPaths, p) } } + if workRequested { + wpaths, werr := work.AllWrittenPaths() + if werr != nil { + refusals = append(refusals, fmt.Sprintf("restore: cannot enumerate the work domain's written paths (%v)", werr)) + } + for _, p := range wpaths { + validDomains = append(validDomains, "work") + absPaths = append(absPaths, p) + } + } for _, r := range refusals { fmt.Fprintln(out, r) } diff --git a/cmd/work.go b/cmd/work.go new file mode 100644 index 0000000..4be671b --- /dev/null +++ b/cmd/work.go @@ -0,0 +1,420 @@ +package cmd + +import ( + "errors" + "fmt" + "io" + "os" + "os/user" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/REPPL/ferry/internal/backup" + "github.com/REPPL/ferry/internal/config" + "github.com/REPPL/ferry/internal/work" +) + +var workCmd = &cobra.Command{ + Use: "work", + Short: "Carry in-flight project work between accounts as an explicit handover", + Long: `Carry a project's in-flight work between accounts as an explicit baton pass. + +A project's work state — the session handover note, the run journal, the coding +agent's per-project memory, and the redacted transcript store — never travels +with the project repo. "work pack" bundles it into a cargo store on shared or +portable media; "work receive" lands the latest cargo on another account, +backup-first and behind divergence guards; "work status" shows cargo, claims, +and drift; "work restore" reverts exactly the last receive. Work state is not +configuration: it has an owner, changes every session, and never merges +silently — so these are handover verbs, not a reconcile domain. + +The cargo store is configured per machine in ~/.config/ferry/config.toml: + + [work] + store = "/Users/Shared/ferry-cargo"`, +} + +var workPackCmd = &cobra.Command{ + Use: "pack ", + Short: "Bundle this project's work state into the cargo store", + Args: cobra.ExactArgs(1), + RunE: runWorkPack, +} + +var workReceiveCmd = &cobra.Command{ + Use: "receive ", + Short: "Land the latest cargo for this project here", + Args: cobra.ExactArgs(1), + RunE: runWorkReceive, +} + +var workStatusCmd = &cobra.Command{ + Use: "status []", + Short: "Show cargo, claims, divergence, and store size", + Args: cobra.MaximumNArgs(1), + RunE: runWorkStatus, +} + +var workPruneCmd = &cobra.Command{ + Use: "prune []", + Short: "Apply the cargo retention policy now", + Args: cobra.MaximumNArgs(1), + RunE: runWorkPrune, +} + +var workRestoreCmd = &cobra.Command{ + Use: "restore ", + Short: "Revert exactly the last work receive on this account", + Args: cobra.ExactArgs(1), + RunE: runWorkRestore, +} + +func init() { + workCmd.PersistentFlags().Bool("allow-sync-root", false, "accept a cargo store under a cloud-synced directory (iCloud/Dropbox/…) for this run") + workPackCmd.Flags().StringArray("exclude", nil, "leave a named item out of the cargo (repeatable; recorded in the manifest)") + workPackCmd.Flags().Bool("allow-empty", false, "permit a pack without the handover note (memory/transcript-only cargo)") + workPackCmd.Flags().StringArray("acknowledge", nil, "let a secret-flagged file travel as-is, named item/path (repeatable; pinned to its current content)") + workReceiveCmd.Flags().Bool("force", false, "override the divergence and superseded guards, replacing local work state") + workReceiveCmd.Flags().String("bundle", "", "receive the bundle with this exact SHA256 (resolves an equal-seq tie)") + workPruneCmd.Flags().Int("keep", 0, "keep the last N bundles (default: [work] keep, else 5)") + workPruneCmd.Flags().String("bundle", "", "remove exactly the bundle with this SHA256 instead of applying keep-last-N") + workCmd.AddCommand(workPackCmd, workReceiveCmd, workStatusCmd, workPruneCmd, workRestoreCmd) + rootCmd.AddCommand(workCmd) +} + +// defaultWorkKeep is the keep-last-N retention when neither the flag nor the +// [work] table sets one. +const defaultWorkKeep = 5 + +// workContext is everything a work verb needs, resolved once. +type workContext struct { + store *work.Store + lc work.Locator + id work.Identity + state *work.State + account string + keep int +} + +// loadWorkContext resolves the project identity (with its shallow/worktree +// guards), the configured cargo store, this project's local work state, and +// this account's claim identity. +func loadWorkContext(c *cobra.Command, projectArg string) (*workContext, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + projectDir, err := filepath.Abs(projectArg) + if err != nil { + return nil, err + } + id, err := work.ProjectIdentity(projectDir) + if err != nil { + return nil, err + } + + mc, err := config.LoadMachineConfig() + if errors.Is(err, os.ErrNotExist) { + return nil, errors.New("work: no machine config yet — run `ferry init` first, then add the [work] table to ~/.config/ferry/config.toml") + } + if err != nil { + return nil, err + } + if mc.Work == nil || strings.TrimSpace(mc.Work.Store) == "" { + return nil, errors.New(`work: no cargo store configured — add to ~/.config/ferry/config.toml: + + [work] + store = "/Users/Shared/ferry-cargo" # a shared or portable directory, created once + +then create the directory (world-writable if two accounts share it) and retry`) + } + allowSync, _ := c.Flags().GetBool("allow-sync-root") + st, err := work.OpenStore(mc.Work.Store, mc.Work.AllowSyncRoot || allowSync) + if err != nil { + return nil, err + } + state, err := work.LoadState(id.Key) + if err != nil { + return nil, err + } + keep := mc.Work.Keep + if keep == 0 { + keep = defaultWorkKeep + } + return &workContext{ + store: st, + lc: work.Locator{Home: home, ProjectDir: projectDir, StoreKey: id.Key}, + id: id, + state: state, + account: workAccount(mc.Hostname), + keep: keep, + }, nil +} + +// workAccount derives this account's claim identity, user@host, sanitised to +// the claim-file character set. +func workAccount(hostname string) string { + name := "user" + if u, err := user.Current(); err == nil && u.Username != "" { + name = u.Username + } + if hostname == "" { + hostname, _ = os.Hostname() + } + return sanitizeClaimPart(name) + "@" + sanitizeClaimPart(hostname) +} + +// sanitizeClaimPart maps a raw user/host string onto [A-Za-z0-9._-] with an +// alphanumeric first character, so it always forms a valid claim filename. +func sanitizeClaimPart(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', + r == '.', r == '_', r == '-': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + out := strings.TrimLeft(b.String(), "._-") + if out == "" { + return "x" + } + return out +} + +func runWorkPack(c *cobra.Command, args []string) error { + ctx, err := loadWorkContext(c, args[0]) + if err != nil { + return err + } + out := c.OutOrStdout() + excludes, _ := c.Flags().GetStringArray("exclude") + allowEmpty, _ := c.Flags().GetBool("allow-empty") + acks, _ := c.Flags().GetStringArray("acknowledge") + + opts := work.PackOptions{ + Excludes: excludes, + AllowEmpty: allowEmpty, + Account: ctx.account, + FerryVersion: version, + Now: time.Now().UTC().Format(time.RFC3339), + } + res, err := work.Pack(ctx.store, ctx.lc, ctx.id, ctx.state, opts) + + // A secret-gate stop with --acknowledge: pin each named finding to its + // current content and retry ONCE. An unnamed finding still aborts. + var sge *work.SecretGateError + if errors.As(err, &sge) && len(acks) > 0 { + named := map[string]bool{} + for _, a := range acks { + named[a] = true + } + var unmatched []string + for _, f := range sge.Findings { + if !named[f.Item+"/"+f.Path] { + unmatched = append(unmatched, f.Item+"/"+f.Path) + continue + } + ctx.state.Acks = append(ctx.state.Acks, work.Ack{ + Item: f.Item, Path: f.Path, SHA256: f.SHA256, + Note: "acknowledged via work pack --acknowledge", + }) + } + if len(unmatched) > 0 { + return fmt.Errorf("%w\n(--acknowledge covered some findings, but not: %s)", err, strings.Join(unmatched, ", ")) + } + if err := ctx.state.Save(); err != nil { + return err + } + fmt.Fprintf(out, "work: %d finding(s) acknowledged, pinned to current content\n", len(sge.Findings)) + res, err = work.Pack(ctx.store, ctx.lc, ctx.id, ctx.state, opts) + } + if err != nil { + return err + } + + fmt.Fprintf(out, "work: packed bundle %06d (%s) into %s\n", res.Ref.Seq, res.Ref.SHA256[:12], ctx.store.Root()) + for _, it := range res.Manifest.Items { + if it.Included { + fmt.Fprintf(out, " %-13s %d file(s)\n", it.Name, len(it.Files)) + } else { + fmt.Fprintf(out, " %-13s — not packed (%s)\n", it.Name, it.Reason) + } + } + if res.Manifest.ScanVerdict != work.ScanVerdictClean { + fmt.Fprintf(out, " secret scan: %s\n", res.Manifest.ScanVerdict) + } + fmt.Fprintf(out, "work: handover marker recorded; receive on the other account with `ferry work receive `\n") + + // Retention runs after a successful pack, as the plan pins. + removed, err := ctx.store.Prune(ctx.id.Key, ctx.keep) + if err != nil { + return fmt.Errorf("work: pack succeeded but pruning failed: %w", err) + } + if len(removed) > 0 { + fmt.Fprintf(out, "work: pruned %d old bundle(s) (keep-last-%d)\n", len(removed), ctx.keep) + } + return nil +} + +func runWorkReceive(c *cobra.Command, args []string) error { + ctx, err := loadWorkContext(c, args[0]) + if err != nil { + return err + } + out := c.OutOrStdout() + force, _ := c.Flags().GetBool("force") + bundleSHA, _ := c.Flags().GetString("bundle") + + eng, err := backup.New() + if err != nil { + return err + } + // Receive mutates $HOME-managed paths through the engine; hold the same + // lock apply holds so the two can never interleave. + lock, err := eng.Lock() + if err != nil { + return err + } + defer lock.Unlock() + + res, err := work.Receive(ctx.store, eng, ctx.lc, ctx.id, ctx.state, work.ReceiveOptions{ + Force: force, + BundleSHA256: bundleSHA, + Account: ctx.account, + Now: time.Now().UTC().Format(time.RFC3339), + }) + if err != nil { + return err + } + if res.TakeBack { + fmt.Fprintf(out, "work: baton taken back — bundle %06d was packed by this account and nothing was restored\n", res.Ref.Seq) + fmt.Fprintln(out, "work: the handover marker is cleared; keep working, then pack again when ready") + return nil + } + fmt.Fprintf(out, "work: received bundle %06d (%s), packed by %s\n", res.Ref.Seq, res.Ref.SHA256[:12], res.Manifest.PackedBy) + fmt.Fprintf(out, " wrote %d path(s), skipped %d already-present\n", len(res.Written), len(res.Skipped)) + fmt.Fprintf(out, "work: revert this receive with `ferry work restore %s`\n", args[0]) + return nil +} + +func runWorkStatus(c *cobra.Command, args []string) error { + dir := "." + if len(args) == 1 { + dir = args[0] + } + ctx, err := loadWorkContext(c, dir) + if err != nil { + return err + } + s, err := work.Status(ctx.store, ctx.lc, ctx.id, ctx.state) + if err != nil { + return err + } + renderWorkStatus(c.OutOrStdout(), s) + return nil +} + +// renderWorkStatus prints the status picture in stable, plain lines. +func renderWorkStatus(out io.Writer, s *work.ProjectStatus) { + fmt.Fprintf(out, "project %s\n", s.Key[:12]) + fmt.Fprintf(out, "store %s (%.1f MiB total)\n", s.StoreDir, float64(s.StoreBytes)/(1<<20)) + if len(s.Bundles) == 0 { + fmt.Fprintln(out, "cargo none packed yet") + } + for _, b := range s.Bundles { + fmt.Fprintf(out, "cargo %06d %s\n", b.Seq, b.SHA256[:12]) + } + if len(s.TopTie) > 1 { + fmt.Fprintf(out, "WARNING %d bundles tie at the highest sequence — receive refuses until one is named (--bundle) or pruned\n", len(s.TopTie)) + } + for _, cl := range s.Claims { + if n := len(cl.Events); n > 0 { + last := cl.Events[n-1] + fmt.Fprintf(out, "claim %s: last %s of %06d\n", cl.Account, last.Op, last.Seq) + } + } + switch { + case s.Marker == nil: + fmt.Fprintln(out, "baton no handover marker (not handed over from here)") + case len(s.MarkerDirty) == 0: + fmt.Fprintf(out, "baton handed over as bundle %06d, not modified since\n", s.Marker.Seq) + default: + fmt.Fprintf(out, "baton handed over as bundle %06d, MODIFIED after handover:\n", s.Marker.Seq) + for _, d := range s.MarkerDirty { + fmt.Fprintf(out, " %s\n", d) + } + } + if len(s.Diverged) > 0 { + fmt.Fprintln(out, "drift changed since this account last held the baton:") + for _, d := range s.Diverged { + fmt.Fprintf(out, " %s\n", d) + } + } + if len(s.OtherProjects) > 0 { + fmt.Fprintf(out, "also in store: %s\n", strings.Join(s.OtherProjects, ", ")) + } +} + +func runWorkPrune(c *cobra.Command, args []string) error { + dir := "." + if len(args) == 1 { + dir = args[0] + } + ctx, err := loadWorkContext(c, dir) + if err != nil { + return err + } + out := c.OutOrStdout() + if sha, _ := c.Flags().GetString("bundle"); sha != "" { + if err := ctx.store.RemoveBundle(ctx.id.Key, sha); err != nil { + return err + } + fmt.Fprintf(out, "work: removed bundle %s\n", sha[:12]) + return nil + } + keep := ctx.keep + if n, _ := c.Flags().GetInt("keep"); n > 0 { + keep = n + } + removed, err := ctx.store.Prune(ctx.id.Key, keep) + if err != nil { + return err + } + if len(removed) == 0 { + fmt.Fprintf(out, "work: nothing to prune (%d bundle(s) within keep-last-%d)\n", len(removed), keep) + return nil + } + for _, r := range removed { + fmt.Fprintf(out, "work: pruned bundle %06d (%s)\n", r.Seq, r.SHA256[:12]) + } + return nil +} + +func runWorkRestore(c *cobra.Command, args []string) error { + ctx, err := loadWorkContext(c, args[0]) + if err != nil { + return err + } + eng, err := backup.New() + if err != nil { + return err + } + lock, err := eng.Lock() + if err != nil { + return err + } + defer lock.Unlock() + + snapID, err := work.WorkRestore(eng, ctx.state) + if err != nil { + return err + } + fmt.Fprintf(c.OutOrStdout(), "work: reverted the last receive (snapshot %s re-applied)\n", snapID) + return nil +} From 008f0b06528abf3c66079202ff03b03d85e39f88 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:21:48 +0100 Subject: [PATCH 10/13] docs: document the work domain (how-to, explanation, reference, ADR) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Diátaxis set for work-state ferrying: how-to "Hand work over to another account" (one-time shared-store setup, pack/receive walkthrough, refusal guidance), explanation "The work domain" (why baton-pass verbs and not a reconcile domain, why the store is not the config repo, the no-shared-writes store design, advisory claims, project identity), reference rows for the five verbs and the [work] machine-config table, regenerated CLI pages, the CHANGELOG entry, and companion ADR 0003 citing ADR 0002's durable/ephemeral split as precedent. Assisted-by: Claude:claude-fable-5 --- .../0003-work-state-handover-verbs.md | 56 ++++++++++ CHANGELOG.md | 18 ++++ docs/README.md | 2 + docs/explanation/work-domain.md | 84 +++++++++++++++ docs/how-to/hand-over-work.md | 102 ++++++++++++++++++ docs/reference/cli/ferry.md | 1 + docs/reference/cli/ferry_work.md | 38 +++++++ docs/reference/cli/ferry_work_pack.md | 27 +++++ docs/reference/cli/ferry_work_prune.md | 26 +++++ docs/reference/cli/ferry_work_receive.md | 26 +++++ docs/reference/cli/ferry_work_restore.md | 24 +++++ docs/reference/cli/ferry_work_status.md | 24 +++++ docs/reference/commands.md | 6 ++ docs/reference/configuration.md | 20 ++++ 14 files changed, 454 insertions(+) create mode 100644 .abcd/development/decisions/0003-work-state-handover-verbs.md create mode 100644 docs/explanation/work-domain.md create mode 100644 docs/how-to/hand-over-work.md create mode 100644 docs/reference/cli/ferry_work.md create mode 100644 docs/reference/cli/ferry_work_pack.md create mode 100644 docs/reference/cli/ferry_work_prune.md create mode 100644 docs/reference/cli/ferry_work_receive.md create mode 100644 docs/reference/cli/ferry_work_restore.md create mode 100644 docs/reference/cli/ferry_work_status.md diff --git a/.abcd/development/decisions/0003-work-state-handover-verbs.md b/.abcd/development/decisions/0003-work-state-handover-verbs.md new file mode 100644 index 0000000..a13d3f3 --- /dev/null +++ b/.abcd/development/decisions/0003-work-state-handover-verbs.md @@ -0,0 +1,56 @@ +# Work state is carried by explicit handover verbs, not the reconcile loop + +- Status: accepted +- Date: 2026-07-18 + +## Context and problem statement + +ferry carries a development *setup* across accounts and machines; it did not +carry *in-flight work*. For an abcd-layout project, continuing work on +another account loses the session handover note and run journal (gitignored +by design), the coding agent's per-project memory (outside any repo, keyed +by absolute path), and the redacted transcript store (account-local). The +project-side tooling deliberately refuses this job. How should ferry carry +operator-local work state between accounts? + +## Considered options + +1. A new `FileDomain` in the `capture`/`apply` reconcile loop. +2. A new verb family with explicit handover (baton-pass) semantics and a + plain-directory cargo store. + +## Decision outcome + +Chosen option 2, `ferry work pack|receive|status|prune|restore`, because +work state fails every assumption the reconcile loop makes: it has an owner +(whichever account is actively working), it changes every session (drift is +its normal state, not a signal), and it must never silently merge. The +reconcile engine's `$HOME`-target model also does not cover files inside a +project repo's working tree. Divergence is detected by content hash against +the last-held baseline and surfaced, never resolved automatically; ordering +is by pack sequence number, never timestamps. + +The cargo store is a plain directory on shared or portable media — never the +config repo (declarative, long-lived, possibly hosted; personal working +context must not ride along), enforced mechanically: a store inside any git +worktree is refused, a store under a known cloud-sync root needs an explicit +opt-in, and no store file is ever written by two accounts (O_EXCL bundles, +per-account claim files merged on read). + +This extends the durable/ephemeral distinction of +[ADR 0002](0002-work-memory-public-private-split.md) across the account +boundary: the ephemeral tier travels only by explicit handover, with the +secret gate fail-closed over every byte. + +## Consequences + +- Good: no silent merges; every landing is backup-first, snapshotted, and + precisely reversible (`ferry work restore`); the store works on + permissionless media (exFAT, SMB) and across same-Mac accounts. +- Bad: handover is manual by design — there is no continuous sync, and a + forgotten `pack` leaves the other account stale (surfaced by + `work status`, not prevented). +- Neutral: the agent-memory path-key encoding is an observed convention of + one tool, owned by a single registry entry; a harness-side change is a + one-entry fix, and `receive` refuses rather than merges when the target + does not match the recorded baseline. diff --git a/CHANGELOG.md b/CHANGELOG.md index 379c8fa..8c905e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,24 @@ called out in a **Breaking** section. See ## [Unreleased] +### Added + +- **`ferry work` carries in-flight project work between accounts** as an + explicit baton pass: `work pack` bundles a project's work state — the + session handover note, run journal, per-project agent memory, and redacted + transcript store — into a shared cargo store; `work receive` lands the + latest cargo on another account, backup-first, snapshotted, and behind + divergence guards (never a silent merge); `work status` shows cargo, + claims, and drift; `work prune` applies retention; `work restore` reverts + exactly the last receive, and `ferry restore work` reverts all work-verb + writes. Every packed byte passes the secret gate fail-closed, with + `--exclude` and content-pinned `--acknowledge` as the auditable escape + hatches. The store is configured per machine in the new `[work]` table of + `~/.config/ferry/config.toml`; a store inside a git worktree is refused, + and one under a cloud-sync directory needs an explicit opt-in. See the new + how-to "Hand work over to another account" and explanation "The work + domain". + ## [0.9.0] - 2026-07-15 ### Added diff --git a/docs/README.md b/docs/README.md index b5dcb35..d1bf8da 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,8 @@ directory holds. Every page is exactly one Diátaxis type — tutorial | [The agents domain](explanation/agents.md) | Explanation | The repo-authoritative model: harnesses, asset mappings, apply/restore semantics | | [Why ferry stays out of `~/.ssh/`](explanation/ssh.md) | Explanation | The rationale and the untouchable-`~/.ssh` invariant | | [Reconciling drift and conflicts](explanation/reconciling-drift-and-conflicts.md) | Explanation | The four target states, and where a conflict resolves for `apply` vs `capture` | +| [The work domain](explanation/work-domain.md) | Explanation | Why work state moves by explicit handover verbs, not the reconcile loop | +| [Hand work over to another account](how-to/hand-over-work.md) | How-to | `ferry work`: the shared cargo store, pack on one account, receive on the other | | [Scaffold a project repo](how-to/scaffold-a-repo.md) | How-to | `ferry agents scaffold`: stamp a repo with the multi-tool pipeline layout | | [Adopt an existing agent config](how-to/adopt-agent-config.md) | How-to | `ferry agents adopt`: migrate a symlink-based instruction directory into ferry | | [Move SSH keys to a new machine](how-to/move-ssh-keys.md) | How-to | Carry your SSH setup across machines yourself, out-of-band | diff --git a/docs/explanation/work-domain.md b/docs/explanation/work-domain.md new file mode 100644 index 0000000..a77606a --- /dev/null +++ b/docs/explanation/work-domain.md @@ -0,0 +1,84 @@ +# The work domain + +This page explains why `ferry work` is a family of explicit handover verbs — +a baton pass — rather than another domain in the `apply`/`capture` reconcile +loop, and why its cargo store is a plain directory rather than the config +repo. + +## Work state is not configuration + +ferry's reconcile loop is built for configuration: declarative, long-lived +state with a single source of truth (the config repo) that every machine +converges towards. A project's in-flight work state is the opposite on every +axis: + +- **It has an owner.** Whichever account is actively working holds the baton; + the other side's copy is stale by definition. Configuration has no owner — + the repo does. +- **It changes every session.** The handover note and agent memory move with + every working day; drift is the normal state, not a signal. +- **It must never silently merge.** Two accounts' diverged handover notes + cannot be reconciled by a machine; picking either silently loses work. + +Reconcile semantics — repo-authoritative, drift detection, converge on apply +— are wrong for all three. So the work verbs model a **baton pass**: `pack` +hands over, `receive` picks up, and divergence is *detected and surfaced* +(by content hash, never timestamps), not resolved. + +## Why the store is not the config repo + +The config repo is declarative, long-lived, and possibly hosted. Work-state +cargo is none of those: it is personal working context — session notes, +agent memory, redacted transcripts — that belongs on local or private media +and expires quickly (retention keeps the last few bundles). Mixing the two +would push personal context into a repo that may be pushed to a host. + +The store is therefore a plain directory of cargo bundles, and the guards +are mechanical, not just documented: a store that resolves inside any git +worktree is refused outright (which also catches "inside the config repo"), +and a store under a known cloud-sync root (iCloud, Dropbox, provider mounts) +requires an explicit opt-in. Nothing in ferry ever uploads the store. + +## Two accounts, no shared writes + +The store is designed so that no file is ever written by two accounts: + +- Bundles are created once (`O_CREAT|O_EXCL`) under an increasing sequence + number; a same-sequence collision from two concurrent packs leaves both + files in place as a visible fork that `receive` refuses until one is named + or pruned. +- Each account writes only its own claim file + (`claim.@.json`), merged on read. + +This sidesteps POSIX-permission traps on `/Users/Shared` (a file Alice +creates is not writable by Bob) and works unchanged on permissionless +filesystems like exFAT. Ordering is always by sequence number — clocks +differ across machines, so timestamps are display-only everywhere. + +## The baton is advisory + +Claims record who packed and received what; they are not locks. The threat +model is one human across their own accounts, not an adversary — so ferry +detects divergence (hash against the last-held baseline) and refuses with a +clear message rather than preventing concurrent edits. `--force` is the +explicit override, and every forced write is still backed up first and +snapshotted, so `ferry work restore` reverts it precisely. + +## Project identity + +A project is identified by its git root-commit SHA — the same value the +project tooling uses for the account-level transcript store, so one identity +locates both. The full sorted root-SHA *set* travels in every bundle's +manifest, and `receive` matches on set intersection, so a subtree import +that reorders the roots does not orphan existing cargo. Shallow clones are +refused (their root is a graft boundary, not an identity); a history rewrite +is a new identity, and `work status` keeps orphaned store entries visible by +their manifest repo name. + +## Related + +- [Hand work over to another account](../how-to/hand-over-work.md) — the task + itself. +- [Commands](../reference/commands.md) — the `work` verb reference. +- ADR 0003 (in the repository's development records) records this decision + and its rejected alternative. diff --git a/docs/how-to/hand-over-work.md b/docs/how-to/hand-over-work.md new file mode 100644 index 0000000..a39e617 --- /dev/null +++ b/docs/how-to/hand-over-work.md @@ -0,0 +1,102 @@ +# Hand work over to another account + +This page walks through carrying a project's in-flight work state — the +session handover note, run journal, per-project agent memory, and redacted +transcripts — from one account to another with `ferry work`. Throughout, +**Alice** and **Bob** name two accounts of the same person: the side handing +work over and the side picking it up. + +## One-time setup: the cargo store + +Both accounts point at one shared directory — the cargo store. It lives on +shared or portable media (never inside a git repository, and never under a +cloud-synced directory unless you explicitly opt in). + +1. Create the store once, world-writable so both accounts can use it: + + ```bash + sudo mkdir -p /Users/Shared/ferry-cargo + sudo chmod 777 /Users/Shared/ferry-cargo + ``` + +2. On **each** account, add the store to `~/.config/ferry/config.toml`: + + ```toml + [work] + store = "/Users/Shared/ferry-cargo" + ``` + +An SSH-mounted path or a removable volume works the same way; if the volume +is not mounted, the work verbs say so instead of creating a stray directory. + +## Hand over (Alice) + +1. Write the handover note — `ferry work pack` refuses without one: + + ```bash + $EDITOR /.abcd/.work.local/NEXT.md + ``` + +2. Pack the project's work state into the store: + + ```bash + ferry work pack + ``` + + Every packed byte passes the secret gate. A high-confidence finding stops + the whole pack; either fix the content at its source, leave the item out + (`--exclude `), or let the flagged file travel as-is with + `--acknowledge /` (pinned to the file's current content — the + pack re-aborts if that content changes later). + +3. Check the baton: + + ```bash + ferry work status + ``` + + `status` shows the cargo, both accounts' claims, and whether anything + changed after the handover ("handed over, not modified since" is the + clean state). + +## Pick up (Bob) + +1. If the project is not cloned yet, clone it first — full history, not + shallow (`ferry work` refuses a shallow clone, whose identity is bogus): + + ```bash + git clone + ``` + +2. Land the latest cargo: + + ```bash + ferry work receive + ``` + + Every write is backed up first, and the whole receive is snapshotted, so + + ```bash + ferry work restore + ``` + + reverts exactly that receive. + +## When something refuses + +- **"changed since this account last held the baton"** — the destination has + local edits the receive would overwrite. Review them; `--force` replaces + them (the backup and snapshot still protect the previous content). +- **"bundles tie at the highest sequence"** — both accounts packed without an + intervening receive. Receive one explicitly with `--bundle `, or + remove one with `ferry work prune --bundle `. +- **"taken back by its packer"** — Alice reclaimed the baton + (`ferry work receive` on her own account) and kept working. Wait for her + fresh pack, or `--force` to land the superseded bundle anyway. +- **"is a linked git worktree"** — the work verbs run only in a project's + main worktree. + +## Retention + +`pack` keeps the last five bundles per project (configurable via `keep` in +the `[work]` table); `ferry work prune` applies the policy on demand. diff --git a/docs/reference/cli/ferry.md b/docs/reference/cli/ferry.md index baed100..f3b2ccb 100644 --- a/docs/reference/cli/ferry.md +++ b/docs/reference/cli/ferry.md @@ -33,4 +33,5 @@ ferry [flags] * [ferry status](ferry_status.md) - Report config drift (what changed on this machine) * [ferry sync](ferry_sync.md) - Publish captured changes and pull remote ones for a managed repo * [ferry version](ferry_version.md) - Print ferry's version (add --verbose for build details) +* [ferry work](ferry_work.md) - Carry in-flight project work between accounts as an explicit handover diff --git a/docs/reference/cli/ferry_work.md b/docs/reference/cli/ferry_work.md new file mode 100644 index 0000000..76faa5d --- /dev/null +++ b/docs/reference/cli/ferry_work.md @@ -0,0 +1,38 @@ +## ferry work + +Carry in-flight project work between accounts as an explicit handover + +### Synopsis + +Carry a project's in-flight work between accounts as an explicit baton pass. + +A project's work state — the session handover note, the run journal, the coding +agent's per-project memory, and the redacted transcript store — never travels +with the project repo. "work pack" bundles it into a cargo store on shared or +portable media; "work receive" lands the latest cargo on another account, +backup-first and behind divergence guards; "work status" shows cargo, claims, +and drift; "work restore" reverts exactly the last receive. Work state is not +configuration: it has an owner, changes every session, and never merges +silently — so these are handover verbs, not a reconcile domain. + +The cargo store is configured per machine in ~/.config/ferry/config.toml: + + [work] + store = "/Users/Shared/ferry-cargo" + +### Options + +``` + --allow-sync-root accept a cargo store under a cloud-synced directory (iCloud/Dropbox/…) for this run + -h, --help help for work +``` + +### SEE ALSO + +* [ferry](ferry.md) - Carries your terminal, dotfiles, and dependencies across machines +* [ferry work pack](ferry_work_pack.md) - Bundle this project's work state into the cargo store +* [ferry work prune](ferry_work_prune.md) - Apply the cargo retention policy now +* [ferry work receive](ferry_work_receive.md) - Land the latest cargo for this project here +* [ferry work restore](ferry_work_restore.md) - Revert exactly the last work receive on this account +* [ferry work status](ferry_work_status.md) - Show cargo, claims, divergence, and store size + diff --git a/docs/reference/cli/ferry_work_pack.md b/docs/reference/cli/ferry_work_pack.md new file mode 100644 index 0000000..ea17f6d --- /dev/null +++ b/docs/reference/cli/ferry_work_pack.md @@ -0,0 +1,27 @@ +## ferry work pack + +Bundle this project's work state into the cargo store + +``` +ferry work pack [flags] +``` + +### Options + +``` + --acknowledge stringArray let a secret-flagged file travel as-is, named item/path (repeatable; pinned to its current content) + --allow-empty permit a pack without the handover note (memory/transcript-only cargo) + --exclude stringArray leave a named item out of the cargo (repeatable; recorded in the manifest) + -h, --help help for pack +``` + +### Options inherited from parent commands + +``` + --allow-sync-root accept a cargo store under a cloud-synced directory (iCloud/Dropbox/…) for this run +``` + +### SEE ALSO + +* [ferry work](ferry_work.md) - Carry in-flight project work between accounts as an explicit handover + diff --git a/docs/reference/cli/ferry_work_prune.md b/docs/reference/cli/ferry_work_prune.md new file mode 100644 index 0000000..23d3204 --- /dev/null +++ b/docs/reference/cli/ferry_work_prune.md @@ -0,0 +1,26 @@ +## ferry work prune + +Apply the cargo retention policy now + +``` +ferry work prune [] [flags] +``` + +### Options + +``` + --bundle string remove exactly the bundle with this SHA256 instead of applying keep-last-N + -h, --help help for prune + --keep int keep the last N bundles (default: [work] keep, else 5) +``` + +### Options inherited from parent commands + +``` + --allow-sync-root accept a cargo store under a cloud-synced directory (iCloud/Dropbox/…) for this run +``` + +### SEE ALSO + +* [ferry work](ferry_work.md) - Carry in-flight project work between accounts as an explicit handover + diff --git a/docs/reference/cli/ferry_work_receive.md b/docs/reference/cli/ferry_work_receive.md new file mode 100644 index 0000000..3cef750 --- /dev/null +++ b/docs/reference/cli/ferry_work_receive.md @@ -0,0 +1,26 @@ +## ferry work receive + +Land the latest cargo for this project here + +``` +ferry work receive [flags] +``` + +### Options + +``` + --bundle string receive the bundle with this exact SHA256 (resolves an equal-seq tie) + --force override the divergence and superseded guards, replacing local work state + -h, --help help for receive +``` + +### Options inherited from parent commands + +``` + --allow-sync-root accept a cargo store under a cloud-synced directory (iCloud/Dropbox/…) for this run +``` + +### SEE ALSO + +* [ferry work](ferry_work.md) - Carry in-flight project work between accounts as an explicit handover + diff --git a/docs/reference/cli/ferry_work_restore.md b/docs/reference/cli/ferry_work_restore.md new file mode 100644 index 0000000..0f56357 --- /dev/null +++ b/docs/reference/cli/ferry_work_restore.md @@ -0,0 +1,24 @@ +## ferry work restore + +Revert exactly the last work receive on this account + +``` +ferry work restore [flags] +``` + +### Options + +``` + -h, --help help for restore +``` + +### Options inherited from parent commands + +``` + --allow-sync-root accept a cargo store under a cloud-synced directory (iCloud/Dropbox/…) for this run +``` + +### SEE ALSO + +* [ferry work](ferry_work.md) - Carry in-flight project work between accounts as an explicit handover + diff --git a/docs/reference/cli/ferry_work_status.md b/docs/reference/cli/ferry_work_status.md new file mode 100644 index 0000000..47a9ed9 --- /dev/null +++ b/docs/reference/cli/ferry_work_status.md @@ -0,0 +1,24 @@ +## ferry work status + +Show cargo, claims, divergence, and store size + +``` +ferry work status [] [flags] +``` + +### Options + +``` + -h, --help help for status +``` + +### Options inherited from parent commands + +``` + --allow-sync-root accept a cargo store under a cloud-synced directory (iCloud/Dropbox/…) for this run +``` + +### SEE ALSO + +* [ferry work](ferry_work.md) - Carry in-flight project work between accounts as an explicit handover + diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 54a624d..6f3b321 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -21,6 +21,12 @@ Every command is run as `ferry ` (e.g. `ferry init`). | `bundle` | Parent noun for moving the config repo offline as a portable bundle. Its subcommands are `bundle export` and `bundle import`. | | `bundle export` | Write a portable, secret-scanned `.zip` bundle of the repo's tracked shared files for an offline move. Prints the bundle's reproducible SHA256 — exporting the same tracked sources always yields the same digest (no timestamps or randomness are bundled), so the digest can be recomputed and verified with `bundle import --expect-sha256`. Never includes secrets, `~/.ssh`, or the local layer (unless `--include-local`). | | `bundle import` | Ingest a bundle into a fresh config repo (`~/.config/ferry/repo` by default), validate it fully, then write ferry's config. Refuses a non-empty target. `--expect-sha256 ` verifies integrity. | +| `work` | Parent noun for carrying a project's in-flight work state — handover note, run journal, per-project agent memory, redacted transcripts — between accounts as an explicit baton pass. Needs a `[work]` store in `~/.config/ferry/config.toml`. See [The work domain](../explanation/work-domain.md). | +| `work pack ` | Bundle the project's work state into the cargo store. Refuses without a handover note (`--allow-empty` permits memory/transcript-only cargo) and stops on any high-confidence secret finding — `--exclude ` leaves an item out (recorded in the manifest), `--acknowledge /` lets a flagged file travel, pinned to its current content. Records a sidecar handover marker and applies keep-last-N retention after storing. | +| `work receive ` | Land the project's latest cargo (by pack sequence, never timestamps) on this account. Every write is backed up first and the receive is snapshotted. Guarded items refuse when the destination changed since this account last held the baton (`--force` overrides); transcripts union-merge (never overwrite, never delete). On the account that packed, it is a take-back: the handover marker is cleared and nothing is restored. An equal-seq tie refuses until one bundle is named (`--bundle `) or pruned; a taken-back bundle refuses without `--force`. | +| `work status []` | Show the project's cargo, both accounts' claims, handover-marker freshness ("handed over, not modified since" vs modified after), drift against this account's baseline, store size, and other projects present in the store. Read-only. | +| `work prune []` | Apply keep-last-N retention now (`--keep `, default from the `[work]` table, else 5), or remove one bundle exactly with `--bundle `. | +| `work restore ` | Revert exactly the last `work receive` on this account from its per-receive snapshot. (`ferry restore work` instead reverts **all** work-verb writes to the pre-ferry baseline.) | | `agents scaffold` | Set up a project repo for AI-agent work from the templates in the config repo's `agents/` area: an `AGENTS.md` router, `CLAUDE.md`/`GEMINI.md` bridges, and committed `.abcd/work/` memory files. `--private` instead creates a `.abcd/.work.local/` layer hidden via `.git/info/exclude` — for repos you don't own, it leaves zero tracked trace. `--attribution` (mutually exclusive with `--private`) instead installs a `prepare-commit-msg` hook that appends a kernel-style `Assisted-by:` trailer to agent-authored commits, for repos that require AI disclosure. Idempotent; never overwrites or repoints anything it didn't create. Works in linked worktrees and submodules. | | `agents adopt` | One-time migration of an existing symlink-based instruction setup into the config repo: imports the source files (never modifying the source directory), then swaps each `$HOME` bridge symlink for a ferry-managed copy in a single journalled transaction — any failure rolls back and the symlinks return. Refuses directory-level bridges with exact instructions rather than writing through them. | | `version` | Print the version; `--verbose` adds the Go version and platform. | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 4553459..3d55bd7 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -431,6 +431,26 @@ a local path, a version-tagged spec, or a flag in the list is refused before npm runs. The `~/.npmrc` config file and its registry auth token are carried separately, as a dotfile — see [npm registry auth](#npm-registry-auth-npmrc). +## The work cargo store (`[work]`) + +The `work` verbs need a per-machine cargo store, declared in the machine +config `~/.config/ferry/config.toml` (not the repo-side manifests — the path +differs per machine): + +```toml +[work] +store = "/Users/Shared/ferry-cargo" # shared or portable directory, created once +keep = 5 # optional: keep-last-N bundles per project +allow_sync_root = false # optional: accept a store under iCloud/Dropbox/… +``` + +`store` is required for the work verbs and has no default. A store that +resolves inside any git worktree is refused; one under a known cloud-sync +directory is refused unless `allow_sync_root = true` here or +`--allow-sync-root` per run. `keep` defaults to 5. See +[The work domain](../explanation/work-domain.md) for the design and +[Hand work over to another account](../how-to/hand-over-work.md) for setup. + ## The `.local` layer Some settings should differ per machine on purpose: a colour scheme on your laptop, a From e56d8c42680dad468b5056b1a97e97e4e9c0ee26 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:23:56 +0100 Subject: [PATCH 11/13] test: add behavioural evals for the work verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-sandbox evals driving the real binary through the plan's matrix: pack→receive round trip across two homes sharing one store (with the ~/.ssh tripwire armed), work-restore reverting a receive, divergence refusal with --force override, the secret-gate abort with both escape hatches (--acknowledge pinned to content, --exclude recorded in the manifest), shallow-clone and linked-worktree refusals, take-back plus the superseded refusal, and the store-inside-a-worktree guard. Assisted-by: Claude:claude-fable-5 --- evals/work_test.go | 279 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 evals/work_test.go diff --git a/evals/work_test.go b/evals/work_test.go new file mode 100644 index 0000000..f6c179d --- /dev/null +++ b/evals/work_test.go @@ -0,0 +1,279 @@ +package evals + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// workFixture is one account's side of a work handover: a sandbox whose HOME +// holds a git project with a handover note, configured against a shared +// cargo store. +type workFixture struct { + sb *Sandbox + project string +} + +// gitProject runs git in dir with the eval-isolated environment. +func gitProject(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = gitIsolatedEnv() + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out) + } + return strings.TrimSpace(string(out)) +} + +// newWorkFixture builds an account: sandbox HOME, a committed project repo +// under it with .abcd/.work.local/NEXT.md, and a config.toml naming the +// shared store. +func newWorkFixture(t *testing.T, store, hostname string) *workFixture { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + sb := NewSandbox(t) + project := filepath.Join(sb.Home, "src", "proj") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + gitProject(t, project, "init", "-q") + sb.WriteHomeFile(t, "src/proj/main.go", "package main\n", 0o644) + gitProject(t, project, "add", ".") + gitProject(t, project, "commit", "-q", "-m", "root") + sb.WriteHomeFile(t, "src/proj/.abcd/.work.local/NEXT.md", "# NEXT\neval handoff\n", 0o644) + writeWorkConfig(t, sb, store, hostname) + return &workFixture{sb: sb, project: project} +} + +func writeWorkConfig(t *testing.T, sb *Sandbox, store, hostname string) { + t.Helper() + cfg := "hostname = \"" + hostname + "\"\nrepo = \"" + sb.Repo + "\"\n\n[work]\nstore = \"" + store + "\"\n" + if err := os.WriteFile(sb.ConfigTOMLPath(), []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } +} + +// cloneInto clones src's project into a second account's HOME. +func (f *workFixture) cloneInto(t *testing.T, store, hostname string) *workFixture { + t.Helper() + sb := NewSandbox(t) + project := filepath.Join(sb.Home, "src", "proj") + if err := os.MkdirAll(filepath.Dir(project), 0o755); err != nil { + t.Fatal(err) + } + gitProject(t, filepath.Dir(project), "clone", "-q", "file://"+f.project, project) + writeWorkConfig(t, sb, store, hostname) + return &workFixture{sb: sb, project: project} +} + +func TestWorkRoundTrip_AC_work_pack_receive_roundtrip(t *testing.T) { + requireBin(t) + store := t.TempDir() + alice := newWorkFixture(t, store, "alicebox") + alice.sb.SSHTripwire(t) + + stdout, stderr, code := alice.sb.Ferry("work", "pack", alice.project) + if code != 0 { + t.Fatalf("pack: exit %d\nstdout: %s\nstderr: %s", code, stdout, stderr) + } + if _, ok := containsAllFold(stdout, "packed bundle", "handover marker"); !ok { + t.Errorf("pack output missing expectations:\n%s", stdout) + } + + bob := alice.cloneInto(t, store, "bobbox") + stdout, stderr, code = bob.sb.Ferry("work", "receive", bob.project) + if code != 0 { + t.Fatalf("receive: exit %d\nstdout: %s\nstderr: %s", code, stdout, stderr) + } + note, err := os.ReadFile(filepath.Join(bob.project, ".abcd", ".work.local", "NEXT.md")) + if err != nil || string(note) != "# NEXT\neval handoff\n" { + t.Errorf("received NEXT.md = %q, %v", note, err) + } + + // The status verbs on both sides see a consistent picture. + stdout, _, code = bob.sb.Ferry("work", "status", bob.project) + if code != 0 || !strings.Contains(stdout, "cargo") { + t.Errorf("bob status: exit %d\n%s", code, stdout) + } + alice.sb.AssertSSHUntouched(t) +} + +func TestWorkRestore_AC_work_restore_reverts_receive(t *testing.T) { + requireBin(t) + store := t.TempDir() + alice := newWorkFixture(t, store, "alicebox") + if _, se, code := alice.sb.Ferry("work", "pack", alice.project); code != 0 { + t.Fatalf("pack failed: %s", se) + } + bob := alice.cloneInto(t, store, "bobbox") + if _, se, code := bob.sb.Ferry("work", "receive", bob.project); code != 0 { + t.Fatalf("receive failed: %s", se) + } + notePath := filepath.Join(bob.project, ".abcd", ".work.local", "NEXT.md") + if _, err := os.Stat(notePath); err != nil { + t.Fatal("receive did not land the note") + } + + stdout, stderr, code := bob.sb.Ferry("work", "restore", bob.project) + if code != 0 { + t.Fatalf("work restore: exit %d\nstdout: %s\nstderr: %s", code, stdout, stderr) + } + if _, err := os.Stat(notePath); err == nil { + t.Error("NEXT.md survived work restore, want reverted (absent)") + } +} + +func TestWorkGuards_AC_work_divergence_refused_force_overrides(t *testing.T) { + requireBin(t) + store := t.TempDir() + alice := newWorkFixture(t, store, "alicebox") + if _, se, code := alice.sb.Ferry("work", "pack", alice.project); code != 0 { + t.Fatalf("pack failed: %s", se) + } + bob := alice.cloneInto(t, store, "bobbox") + if _, se, code := bob.sb.Ferry("work", "receive", bob.project); code != 0 { + t.Fatalf("receive failed: %s", se) + } + + // Bob edits; alice packs v2; bob's receive refuses, then --force lands it. + bob.sb.WriteHomeFile(t, "src/proj/.abcd/.work.local/NEXT.md", "bob's edits\n", 0o644) + alice.sb.WriteHomeFile(t, "src/proj/.abcd/.work.local/NEXT.md", "# NEXT\nv2\n", 0o644) + if _, se, code := alice.sb.Ferry("work", "pack", alice.project); code != 0 { + t.Fatalf("pack v2 failed: %s", se) + } + + _, stderr, code := bob.sb.Ferry("work", "receive", bob.project) + if code == 0 { + t.Fatal("diverged receive succeeded, want refusal") + } + if _, ok := containsAllFold(stderr, "refusing to overwrite", "--force"); !ok { + t.Errorf("refusal message unhelpful:\n%s", stderr) + } + if _, se, code := bob.sb.Ferry("work", "receive", "--force", bob.project); code != 0 { + t.Fatalf("forced receive failed: %s", se) + } + note, _ := os.ReadFile(filepath.Join(bob.project, ".abcd", ".work.local", "NEXT.md")) + if string(note) != "# NEXT\nv2\n" { + t.Errorf("after force, NEXT.md = %q", note) + } +} + +func TestWorkSecretGate_AC_work_pack_secret_abort_and_escapes(t *testing.T) { + requireBin(t) + store := t.TempDir() + alice := newWorkFixture(t, store, "alicebox") + secretLine := "api_key = \"sk-ferrytest-FAKE1234567890abcdefghijklmnopqrstuv\"\n" + alice.sb.WriteHomeFile(t, "src/proj/.abcd/.work.local/NEXT.md", secretLine, 0o644) + + // Abort, and nothing lands in the store. + _, stderr, code := alice.sb.Ferry("work", "pack", alice.project) + if code == 0 { + t.Fatal("pack with a secret succeeded, want abort") + } + if _, ok := containsAllFold(stderr, "secret gate", "nothing was written"); !ok { + t.Errorf("abort message unhelpful:\n%s", stderr) + } + entries, _ := os.ReadDir(store) + for _, e := range entries { + files, _ := os.ReadDir(filepath.Join(store, e.Name())) + for _, f := range files { + if strings.HasSuffix(f.Name(), ".ferrywork") { + t.Errorf("aborted pack left bundle %s in the store", f.Name()) + } + } + } + + // Escape hatch 1: acknowledge, pinned to content. + stdout, stderr, code := alice.sb.Ferry("work", "pack", "--acknowledge", "next/NEXT.md", alice.project) + if code != 0 { + t.Fatalf("acknowledged pack: exit %d\nstdout: %s\nstderr: %s", code, stdout, stderr) + } + if _, ok := containsAllFold(stdout, "acknowledged"); !ok { + t.Errorf("acknowledged pack output:\n%s", stdout) + } + + // Escape hatch 2: exclude the item entirely (needs --allow-empty since + // the note is the required item, and something else must remain to pack). + alice.sb.WriteHomeFile(t, "src/proj/.abcd/.work.local/run-journal.json", `{"runs":[]}`, 0o644) + stdout, stderr, code = alice.sb.Ferry("work", "pack", "--exclude", "next", "--allow-empty", alice.project) + if code != 0 { + t.Fatalf("excluded pack: exit %d\nstderr: %s", code, stderr) + } + if _, ok := containsAllFold(stdout, "not packed (excluded)"); !ok { + t.Errorf("exclusion not surfaced:\n%s", stdout) + } +} + +func TestWorkIdentityGuards_AC_work_shallow_and_worktree_refused(t *testing.T) { + requireBin(t) + store := t.TempDir() + alice := newWorkFixture(t, store, "alicebox") + + // A second commit so a depth-1 clone truncates. + alice.sb.WriteHomeFile(t, "src/proj/main.go", "package main // v2\n", 0o644) + gitProject(t, alice.project, "commit", "-aqm", "second") + + shallow := filepath.Join(alice.sb.Home, "src", "shallow") + gitProject(t, filepath.Dir(shallow), "clone", "-q", "--depth", "1", "file://"+alice.project, shallow) + _, stderr, code := alice.sb.Ferry("work", "pack", shallow) + if code == 0 || !strings.Contains(strings.ToLower(stderr), "shallow") { + t.Errorf("shallow pack: exit %d, stderr:\n%s", code, stderr) + } + + wt := filepath.Join(alice.sb.Home, "src", "wt") + gitProject(t, alice.project, "worktree", "add", "-q", wt) + _, stderr, code = alice.sb.Ferry("work", "pack", wt) + if code == 0 || !strings.Contains(strings.ToLower(stderr), "worktree") { + t.Errorf("worktree pack: exit %d, stderr:\n%s", code, stderr) + } +} + +func TestWorkTakeBack_AC_work_takeback_and_superseded(t *testing.T) { + requireBin(t) + store := t.TempDir() + alice := newWorkFixture(t, store, "alicebox") + if _, se, code := alice.sb.Ferry("work", "pack", alice.project); code != 0 { + t.Fatalf("pack failed: %s", se) + } + bob := alice.cloneInto(t, store, "bobbox") + + // Alice reclaims her own baton: nothing restored, marker cleared. + alice.sb.WriteHomeFile(t, "src/proj/.abcd/.work.local/NEXT.md", "alice kept working\n", 0o644) + stdout, stderr, code := alice.sb.Ferry("work", "receive", alice.project) + if code != 0 { + t.Fatalf("take-back: exit %d\nstderr: %s", code, stderr) + } + if _, ok := containsAllFold(stdout, "taken back", "nothing was restored"); !ok { + t.Errorf("take-back output:\n%s", stdout) + } + note, _ := os.ReadFile(filepath.Join(alice.project, ".abcd", ".work.local", "NEXT.md")) + if string(note) != "alice kept working\n" { + t.Errorf("take-back changed local work: %q", note) + } + + // Bob's receive of the taken-back bundle refuses without --force. + _, stderr, code = bob.sb.Ferry("work", "receive", bob.project) + if code == 0 || !strings.Contains(strings.ToLower(stderr), "taken back") { + t.Errorf("superseded receive: exit %d, stderr:\n%s", code, stderr) + } +} + +func TestWorkStoreGuard_AC_work_store_in_worktree_refused(t *testing.T) { + requireBin(t) + alice := newWorkFixture(t, t.TempDir(), "alicebox") + // Point the store INSIDE the project worktree: refused. + inRepo := filepath.Join(alice.project, "cargo") + if err := os.MkdirAll(inRepo, 0o755); err != nil { + t.Fatal(err) + } + writeWorkConfig(t, alice.sb, inRepo, "alicebox") + _, stderr, code := alice.sb.Ferry("work", "pack", alice.project) + if code == 0 || !strings.Contains(strings.ToLower(stderr), "worktree") { + t.Errorf("in-worktree store: exit %d, stderr:\n%s", code, stderr) + } +} From f0d6ebf0fe82d95ae120dfeceea590c99e84d69c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:36:59 +0100 Subject: [PATCH 12/13] fix: close two security-review observations in the work gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pack now gates file NAMES as well as content (parity with bundle export) — a secret-shaped path component aborts the pack and the refusal withholds the name rather than echoing it back. The cargo manifest validator refuses case-fold duplicate file paths within an item, which case-insensitive APFS would otherwise land last-writer-wins. Assisted-by: Claude:claude-fable-5 --- internal/work/manifest.go | 7 +++++++ internal/work/manifest_test.go | 13 +++++++++++++ internal/work/pack.go | 32 ++++++++++++++++++++++++++------ internal/work/pack_test.go | 26 ++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/internal/work/manifest.go b/internal/work/manifest.go index e473128..c95d1d6 100644 --- a/internal/work/manifest.go +++ b/internal/work/manifest.go @@ -135,10 +135,17 @@ func (m *Manifest) validate() error { if !it.Included && len(it.Files) > 0 { return fmt.Errorf("item %q is excluded but lists files", it.Name) } + fold := map[string]string{} for _, f := range it.Files { if err := validateCargoRel(f.Path); err != nil { return fmt.Errorf("item %q: %w", it.Name, err) } + // Case-insensitive APFS would land case-fold colliding entries + // last-writer-wins; refuse them like the config bundle writer does. + if prev, dup := fold[strings.ToLower(f.Path)]; dup { + return fmt.Errorf("item %q: file %q collides with %q (case-fold)", it.Name, f.Path, prev) + } + fold[strings.ToLower(f.Path)] = f.Path if len(f.SHA256) != 64 || !isLowerHex(f.SHA256) { return fmt.Errorf("item %q: file %q has malformed sha256", it.Name, f.Path) } diff --git a/internal/work/manifest_test.go b/internal/work/manifest_test.go index 6ea0424..905af22 100644 --- a/internal/work/manifest_test.go +++ b/internal/work/manifest_test.go @@ -118,3 +118,16 @@ func TestDecodeManifest_UnknownFieldRefused(t *testing.T) { t.Fatal("manifest with unknown top-level field accepted, want refusal") } } + +func TestDecodeManifest_CaseFoldDuplicatePathsRefused(t *testing.T) { + m := validManifest() + m.Items[0].Files = append(m.Items[0].Files, + ManifestFile{Path: "next.MD", Size: 1, SHA256: strings.Repeat("c", 64)}, + ManifestFile{Path: "NEXT.md", Size: 1, SHA256: strings.Repeat("d", 64)}, + ) + if data, err := m.Encode(); err == nil { + if _, err := DecodeManifest(data); err == nil { + t.Error("case-fold duplicate file paths accepted, want refusal") + } + } +} diff --git a/internal/work/pack.go b/internal/work/pack.go index 548aaef..8a0f0bb 100644 --- a/internal/work/pack.go +++ b/internal/work/pack.go @@ -43,13 +43,16 @@ type HandoverMarker struct { // SecretFinding is one unacknowledged secret-gate hit, named precisely enough // to act on. SHA256 is the flagged content's hash — the pin an -// acknowledgement must carry. +// acknowledgement must carry. PathSecret marks a finding on the file's NAME, +// which the error rendering therefore withholds (echoing it would re-leak +// the secret-shaped token, mirroring bundle export's withheld-path handling). type SecretFinding struct { - Item string - Path string - Rule string - Detail string - SHA256 string + Item string + Path string + Rule string + Detail string + SHA256 string + PathSecret bool } // SecretGateError aborts a pack: nothing was written. This is deliberately @@ -64,6 +67,10 @@ func (e *SecretGateError) Error() string { var b strings.Builder b.WriteString("work: the secret gate stopped the pack; nothing was written:\n") for _, f := range e.Findings { + if f.PathSecret { + fmt.Fprintf(&b, " %s/(name withheld): %s (%s)\n", f.Item, f.Detail, f.Rule) + continue + } fmt.Fprintf(&b, " %s/%s: %s (%s)\n", f.Item, f.Path, f.Detail, f.Rule) } b.WriteString("fix the content at its source, exclude the item (--exclude), or acknowledge the finding to pack it as-is") @@ -158,6 +165,19 @@ func Pack(st *Store, lc Locator, id Identity, state *State, opts PackOptions) (* findings = append(findings, finding) } } + // The file's NAME travels in the manifest and the store, so it is + // gated too — parity with bundle export's path gate. + if pathFs := secret.ScanValue(f.rel); pathFs.HasHigh() { + if state.Acknowledged(f.item, f.rel, f.sha) { + acked = true + } else { + findings = append(findings, SecretFinding{ + Item: f.item, Path: f.rel, Rule: "secret-shaped-path", + Detail: "a path component looks secret-shaped", SHA256: f.sha, + PathSecret: true, + }) + } + } mi.Files = append(mi.Files, ManifestFile{Path: f.rel, Size: int64(len(f.data)), SHA256: f.sha}) } manifestItems = append(manifestItems, mi) diff --git a/internal/work/pack_test.go b/internal/work/pack_test.go index f265359..5c43abe 100644 --- a/internal/work/pack_test.go +++ b/internal/work/pack_test.go @@ -8,6 +8,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" ) @@ -309,3 +310,28 @@ func TestPack_MarkerRecordsContentHashes(t *testing.T) { t.Errorf("marker bundle hash malformed: %q", mk.Bundle) } } + +func TestPack_SecretShapedFileNameRefusedWithoutEcho(t *testing.T) { + fx := newPackFixture(t) + memDir := filepath.Join(fx.home, ".claude", "projects", ClaudeProjectsKey(fx.repo), "memory") + secretName := "sk-ferrytest-FAKE1234567890abcdefghijklmnopqrstuv.md" + writeFileT(t, filepath.Join(memDir, secretName), "innocent content\n") + + _, err := Pack(fx.st, fx.lc, fx.id, fx.state, defaultOpts()) + var sge *SecretGateError + if !errors.As(err, &sge) { + t.Fatalf("err = %v, want *SecretGateError for a secret-shaped file NAME", err) + } + // The refusal must not echo the secret-shaped name back (mirrors bundle + // export's withheld-path handling). + if msg := err.Error(); len(msg) > 0 && strings.Contains(msg, "sk-ferrytest") { + t.Errorf("refusal echoes the secret-shaped name:\n%s", msg) + } + + // --exclude releases the pack. + opts := defaultOpts() + opts.Excludes = []string{ItemAgentMemory} + if _, err := Pack(fx.st, fx.lc, fx.id, fx.state, opts); err != nil { + t.Fatalf("pack with the item excluded: %v", err) + } +} From f8f551f776a45cfa492cf52723f7a13f5798f87d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:38:48 +0100 Subject: [PATCH 13/13] fix: persist the receive record before writing, not after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mid-write receive failure left state.LastReceive nil (or pointing at the PREVIOUS receive's snapshot), so the advertised recovery — `ferry work restore` reverts the partial receive — was wrong exactly when it mattered. The record (snapshot id, bundle, affected paths) is now saved before the first write; the baseline still updates only on success. Found in the pre-PR review pass; regression-tested with a partial landing forced via an unwritable memory target. Assisted-by: Claude:claude-fable-5 --- internal/work/receive.go | 11 +++++++-- internal/work/receive_test.go | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/internal/work/receive.go b/internal/work/receive.go index 4c0e612..d4172fe 100644 --- a/internal/work/receive.go +++ b/internal/work/receive.go @@ -191,6 +191,15 @@ func Receive(st *Store, eng *backup.Engine, lc Locator, id Identity, state *Stat if err != nil { return nil, err } + // Persist the receive record BEFORE writing anything: a mid-write failure + // must leave `ferry work restore` pointing at THIS receive's snapshot — + // not nil, and never at the previous receive's — so the advertised + // recovery genuinely reverts the partial landing. + state.LastReceive = &ReceiveRecord{SnapshotID: snapID, Seq: chosen.Seq, Bundle: chosen.SHA256, At: opts.Now, Paths: affected} + state.RecordWritten(affected...) + if err := state.Save(); err != nil { + return nil, err + } run, err := eng.Begin() if err != nil { return nil, err @@ -210,8 +219,6 @@ func Receive(st *Store, eng *backup.Engine, lc Locator, id Identity, state *Stat } state.Baseline = &Baseline{Op: OpReceive, Seq: chosen.Seq, Bundle: chosen.SHA256, At: opts.Now, Files: manifestHashes(m)} - state.LastReceive = &ReceiveRecord{SnapshotID: snapID, Seq: chosen.Seq, Bundle: chosen.SHA256, At: opts.Now, Paths: affected} - state.RecordWritten(affected...) if err := state.Save(); err != nil { return nil, fmt.Errorf("work: cargo landed but saving local state failed: %w", err) } diff --git a/internal/work/receive_test.go b/internal/work/receive_test.go index c374d62..ac6afb8 100644 --- a/internal/work/receive_test.go +++ b/internal/work/receive_test.go @@ -418,3 +418,48 @@ func TestReceive_FailedPlanReleasesTranscriptLock(t *testing.T) { t.Error("failed receive left the transcript advisory lock behind") } } + +func TestReceive_MidWriteFailureLeavesRevertibleState(t *testing.T) { + h := newHandoffFixture(t) + // Make the memory target directory unwritable: the guarded file items + // (written first, in manifest order) land, then the memory write fails — + // a genuine partial receive. + memDir := filepath.Join(h.home, ".claude", "projects", ClaudeProjectsKey(h.repo), "memory") + if err := os.MkdirAll(memDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(memDir, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(memDir, 0o755) }) + + _, err := h.receive(t, bobOpts()) + if err == nil { + t.Fatal("receive into an unwritable memory target succeeded, want failure") + } + notePath := filepath.Join(h.repo, ".abcd", ".work.local", "NEXT.md") + if _, statErr := os.Stat(notePath); statErr != nil { + t.Fatal("test setup: the partial receive did not land the note before failing") + } + + // The persisted state must already carry THIS receive's snapshot, so the + // advertised recovery (`ferry work restore`) genuinely reverts the + // partial landing. + reloaded, err := LoadStateAt(filepath.Join(h.home, ".local", "state", "ferry"), h.lc.StoreKey) + if err != nil { + t.Fatal(err) + } + if reloaded.LastReceive == nil || reloaded.LastReceive.SnapshotID == "" { + t.Fatalf("LastReceive after mid-write failure = %+v, want the pre-write snapshot recorded", reloaded.LastReceive) + } + + if err := os.Chmod(memDir, 0o755); err != nil { + t.Fatal(err) + } + if _, err := WorkRestore(h.eng, reloaded); err != nil { + t.Fatalf("WorkRestore after partial receive: %v", err) + } + if _, statErr := os.Stat(notePath); statErr == nil { + t.Error("NEXT.md survived the revert of the partial receive") + } +}