Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .abcd/development/plans/2026-07-08-v0.7.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# ferry v0.7.3 — hardening

**Status:** shipped in v0.7.3.
**Owner:** REPPL. **Type:** implementation plan (Diátaxis: one plan, no mixing).

## Goal

Three follow-up fixes flagged during the v0.7.0 config-plugin work, each
self-contained and behaviour-preserving elsewhere.

## Scope

- **Emacs local-only deploy.** The Emacs domain deploys the union of the shared
`emacs/` tree and the per-machine `local/emacs/` overlay, so a file present only
in the overlay (a hand-authored `init.local.el` with no shared counterpart)
deploys as a machine-only file — mirroring the iTerm2 Dynamic Profiles union
walk. The volatile-path exclude filter and symlink refusal apply to both trees;
a file present in both resolves local-wins, including its executable bit.

- **iTerm2 `Restore` running-guard.** `PreferenceDomain.Restore` for iTerm2 honours
the same running-guard as `Apply`: it refuses the `defaults import`/`delete`
while iTerm2 is running (a live iTerm2 rewrites the domain on quit and would
discard the re-import). The backup engine recognises the skip through a
`ResourceRestoreSkipper` interface and continues restoring every other path —
it never aborts a multi-domain restore and never wedges the next `apply`'s
pre-plan rollback. Apple Terminal has no such guard and is unaffected.

- **tmux recogniser coverage.** The tmux secret recogniser covers `setw` /
`set-window-option` values, a value followed by a trailing `#` comment, and an
unquoted value, in addition to the quoted `set`/`set-option` form. Every shape
it cannot isolate still degrades to a safe refusal on capture (no partial
redaction, no whole-line clobber, no leak); the `${ENV}` exemption is preserved.

## Known limitation

- A tmux trailing comment that itself ends in a same-kind quote (e.g.
`set -g @token '<TOKEN>' # note'`) over-selects the span to the final quote, so
on capture the comment is silently dropped. The secret stays fully inside the
replaced span and the quotes stay balanced — no leak, valid syntax — and it is
strictly better than the prior behaviour (which refused the common trailing-
comment case entirely). Tightening the trailer match to preserve such a comment
is a future refinement.

## Verification

- `make build`, `gofmt -l .` empty, `go vet ./...`, `go test ./...` clean; the
full eval suite green with `FERRY_BIN` set (the iTerm2 restore guard was
exercised with iTerm2 actually running on the host).
- New unit + eval coverage for each fix; ruthless review SHIP, security review
PASS on the restore-path change.
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@ called out in a **Breaking** section. See

## [Unreleased]

## [0.7.3] - 2026-07-08

### Fixed

- **Emacs local-only overlay files now deploy.** The Emacs domain deploys the
union of the shared `emacs/` tree and the per-machine `local/emacs/` overlay,
so a file that exists only in the overlay — a hand-authored `init.local.el`
with no shared counterpart, say — deploys to `~/.emacs.d/` as a machine-only
file, like an iTerm2 Dynamic Profile does. The volatile-path exclude filter and
symlink refusal apply to both trees.
- **`ferry restore` refuses the iTerm2 preference domain while iTerm2 is
running.** `restore` now honours the same running-guard as `apply`: because a
running iTerm2 rewrites its preferences on quit and would discard a re-import,
restore skips that one domain (with a notice to quit iTerm2 and re-run) and
restores everything else — it never aborts the wider revert. Apple Terminal is
unaffected.
- **The tmux secret recogniser covers more option shapes.** It now recognises a
credential in a `setw` / `set-window-option` value, a value followed by a
trailing `#` comment, and an unquoted value — in addition to the quoted
`set`/`set-option` form. A shape it cannot isolate still degrades to a safe
refusal on capture, never a partial redaction or a leak, and a `${ENV}`
reference is left verbatim.

## [0.7.2] - 2026-07-08

### Changed
Expand Down
45 changes: 45 additions & 0 deletions cmd/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,13 @@ func runRestore(c *cobra.Command, args []string) (retErr error) {
return err
}

// If iTerm2 is running, the engine SKIPS its preference domain during this
// restore (a live iTerm2 rewrites com.googlecode.iterm2 on quit, so a
// re-import would be silently lost) and restores everything else. Tell the
// user up front how to finish restoring that one domain — the engine's own
// running-guard (terminal.Restore -> ErrITerm2Running) is what enforces it.
warnIfITerm2RestoreSkipped(ctx, args, out)

if len(args) > 0 {
if err := scopedRestore(ctx, args, out); err != nil {
return err
Expand Down Expand Up @@ -328,6 +335,44 @@ func terminalPrefDomainID(scopeName string) (string, bool) {
}
}

// warnIfITerm2RestoreSkipped prints the warn-skip notice when iTerm2 is running
// and its preference domain is part of this restore. The engine's own guard
// (terminal.Restore returns ErrITerm2Running, which backup treats as a clean skip)
// leaves that one domain untouched and restores everything else; this surfaces the
// user-facing notice, since backup holds no writer. darwin-only and best-effort:
// a probe error is ignored (the engine still fails closed on its own probe).
func warnIfITerm2RestoreSkipped(ctx *cmdContext, args []string, out io.Writer) {
if !platform.IsDarwin() {
return
}
// A scoped restore only touches the named domains, so skip the notice unless
// iTerm2 is one of them; a full restore (no args) always includes it.
if len(args) > 0 {
requested := false
for _, a := range args {
if id, ok := terminalPrefDomainID(a); ok && id == terminal.ITerm2Domain {
requested = true
break
}
}
if !requested {
return
}
}
eng, err := ctx.Engine()
if err != nil {
return
}
if !eng.HasBaseline(backup.ResourcePath(terminal.ITerm2Domain)) {
return
}
running, err := terminal.ExecProcessController{}.Running()
if err != nil || !running {
return
}
fmt.Fprintln(out, "restore: iTerm2 is running; skipping its preference domain (a running iTerm2 rewrites its preferences on quit, so the restore would be silently lost). Quit iTerm2 and re-run to restore it — all other paths were restored.")
}

// registerTerminalDomains attaches the iTerm2 + Apple Terminal preference domains
// to the engine so their resource baseline entries restore through the
// Resource.Restore hook (folded into both full Restore and ScopedRestore). The
Expand Down
49 changes: 30 additions & 19 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,14 @@ flags are one-shot), but if you rely on a global setting the starter allowlist
omits, add it to the allowlist so it is carried and preserved.

**Quit iTerm2 first.** A running iTerm2 keeps its preferences in memory and
rewrites the domain on quit, so importing while it runs is silently lost — `apply`
therefore *refuses* the global-preferences import while iTerm2 is running and tells
you to quit it and re-run. After a successful import ferry runs `killall cfprefsd`
so the preferences daemon does not serve a stale cached copy; relaunch iTerm2 for
the change to take effect. (The profiles artefact above has no such constraint — it
is a plain file copy iTerm2 live-reloads.)
rewrites the domain on quit, so mutating it while it runs is silently lost — both
`apply` and `restore` therefore *refuse* to touch the global-preferences domain
while iTerm2 is running and tell you to quit it and re-run. `restore` skips only
that one domain (reverting every other managed path) and prints how to finish, so
it never aborts the wider revert. After a successful import ferry runs
`killall cfprefsd` so the preferences daemon does not serve a stale cached copy;
relaunch iTerm2 for the change to take effect. (The profiles artefact above has no
such constraint — it is a plain file copy iTerm2 live-reloads.)

The `.local` layer applies whole-domain: a committed
`local/iterm2/com.googlecode.iterm2.plist` is imported instead of the shared copy
Expand All @@ -181,13 +183,20 @@ it does not — the tmux analogue of the shell `[ -f … ] && source …` guard.
so the committed `dotfiles/tmux.conf` never carries ferry's own boilerplate, and
per-machine edits route to `local/tmux/tmux.conf.local`.

**Tokens in options.** A secret set in a quoted tmux option — for example
`set -g @token 'ghp_…'` — is caught on capture: only the quoted value is routed to
the out-of-repo secret store and replaced with a `{{ferry.secret …}}` placeholder,
leaving the `set -g @token '…'` syntax and the quotes byte-for-byte intact. `apply`
renders the placeholder back to the real value (deployed `0600`). An environment
reference such as `set -g @token '${TMUX_TOKEN}'` is **not** a literal secret (tmux
expands it at read time), so it is carried to the shared repo verbatim.
**Tokens in options.** A secret set in a tmux option — for example
`set -g @token 'ghp_…'` — is caught on capture: only the value is routed to the
out-of-repo secret store and replaced with a `{{ferry.secret …}}` placeholder,
leaving the surrounding syntax byte-for-byte intact. The recogniser covers all
four option-setting commands (`set`, `set-option`, `setw`, `set-window-option`),
single- or double-quoted **and** bare unquoted values, and tolerates a trailing
`# comment` after the value — for instance `set -g @token 'ghp_…' # CI token` keeps
both the quotes and the comment untouched. `apply` renders the placeholder back to
the real value (deployed `0600`). An environment reference such as
`set -g @token '${TMUX_TOKEN}'` (or unquoted `$TMUX_TOKEN`) is **not** a literal
secret (tmux expands it at read time), so it is carried to the shared repo
verbatim. Any option shape the recogniser cannot cleanly isolate — an unbalanced
quote, or a value trailed by something other than whitespace or a comment — is
never partially rewritten: capture blocks the whole file instead.

## git

Expand Down Expand Up @@ -354,14 +363,16 @@ step between editing `inits/repp.org` in the repo and Emacs re-tangling it on
next load. There is no capture pass: `ferry capture` does not ingest live
`~/.emacs.d/` edits back into the repo.

**Per-machine overlay.** The `.local` layer applies per file: an override at
`local/emacs/<relpath>` wins over the shared `emacs/<relpath>` on the next
`apply`, leaving every other file shared. This is the natural home for a
**Per-machine overlay.** ferry deploys the union of the shared `emacs/` tree and
the per-machine `local/emacs/` overlay tree. The `.local` layer applies per file:
an override at `local/emacs/<relpath>` wins over the shared `emacs/<relpath>` on
the next `apply`, leaving every other file shared. A file present **only** under
`local/emacs/` — with no shared counterpart — deploys as a machine-only file,
exactly like iTerm2's Dynamic Profiles overlay. This is the natural home for a
Customize-written `inits/custom.el` (which `init.el` loads when present) or a
hand-authored `init.local.el` for machine-specific bits (fonts, `exec-path`,
GUI-versus-tty). Like every terminal overlay, the override wins per shared file;
a purely machine-local file is committed under `local/emacs/` alongside a shared
counterpart in `emacs/`.
GUI-versus-tty) that lives on one machine alone. The exclude filter and symlink
refusal apply to both trees.

Emacs files participate in the secret store like dotfiles: a `{{ferry.secret …}}`
placeholder is rendered on apply, a real secret is commit-gated on capture, and a
Expand Down
32 changes: 32 additions & 0 deletions evals/emacs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,38 @@ func TestEmacsLocalOverlayWins(t *testing.T) {
assertFileContains(t, s.HomePath(".emacs.d", "init.el"), "SHARED_INIT")
}

// TestEmacsLocalOnlyFileDeploys proves a file present ONLY under local/emacs/
// (no shared counterpart) deploys as a machine-only file: a hand-authored
// init.local.el with no emacs/init.local.el reaches ~/.emacs.d/init.local.el.
func TestEmacsLocalOnlyFileDeploys(t *testing.T) {
t.Parallel()
s := NewSandbox(t)
s.InitGitRepo(t)
s.SeedSharedManifest(t, emacsManifest)

// A shared file plus a machine-only overlay file with NO shared counterpart.
s.WriteRepoFile(t, filepath.Join("emacs", "init.el"), "SHARED_INIT\n")
s.WriteRepoFile(t, filepath.Join("local", "emacs", "init.local.el"), "MACHINE_ONLY\n")
gitCommitAll(t, s.Repo, "emacs with a local-only file")

if _, errOut, code := s.Ferry("apply"); code != 0 {
t.Fatalf("apply exited %d; stderr:\n%s", code, errOut)
}

// The machine-only file deploys as a regular-file copy alongside the shared one.
localTarget := s.HomePath(".emacs.d", "init.local.el")
assertFileContains(t, localTarget, "MACHINE_ONLY")
assertFileContains(t, s.HomePath(".emacs.d", "init.el"), "SHARED_INIT")

fi, err := os.Lstat(localTarget)
if err != nil {
t.Fatalf("lstat local-only target: %v", err)
}
if fi.Mode()&os.ModeSymlink != 0 || !fi.Mode().IsRegular() {
t.Errorf("%s must be a regular-file copy (mode %v)", localTarget, fi.Mode())
}
}

// TestEmacsStatusCleanThenDrift proves status reports clean right after apply
// (with no spurious de-scope warning) and drift after a live edit — the
// repo-authoritative reconcile (apply would skip a live edit).
Expand Down
61 changes: 61 additions & 0 deletions internal/backup/backup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,67 @@ func (f *fakeResource) Restore(blob []byte, absent bool) error {
return nil
}

// skipResourceErr is a Resource.Restore error that reports itself as a clean
// resource-restore SKIP (mirroring terminal.ErrITerm2Running when iTerm2 is
// running), so the engine must continue the surrounding restore instead of
// aborting. It satisfies ResourceRestoreSkipper without importing terminal.
type skipResourceErr struct{}

func (skipResourceErr) Error() string { return "resource declined restore (running)" }
func (skipResourceErr) ResourceRestoreSkipped() bool { return true }

// decliningResource is a Resource whose Restore always DECLINES with a skip error,
// recording how many times it was attempted.
type decliningResource struct {
domain string
attempts int
}

func (d *decliningResource) Domain() string { return d.domain }
func (d *decliningResource) Backup() ([]byte, bool, error) { return []byte("PREFS"), false, nil }
func (d *decliningResource) Restore(_ []byte, _ bool) error { d.attempts++; return skipResourceErr{} }

// TestRestoreSkipsDecliningResourceAndContinues proves the running-guard contract
// at the engine level: a resource that DECLINES its restore (skip error) does NOT
// abort the multi-path restore — every other managed path is still reverted, the
// overall restore returns no error, and the resource restore was attempted.
func TestRestoreSkipsDecliningResourceAndContinues(t *testing.T) {
e, home := newEngine(t)

// A registered resource that will decline restore, captured into the baseline.
res := &decliningResource{domain: "com.googlecode.iterm2"}
e.Register(res)

// A managed file whose baseline must still be restored despite the resource skip.
target := filepath.Join(home, "f")
mustWrite(t, target, []byte("ORIGINAL"), 0o600)

r, err := e.Begin()
if err != nil {
t.Fatal(err)
}
if err := e.BackupResource(r, res.domain); err != nil {
t.Fatal(err)
}
if err := e.BackupAndWrite(r, target, []byte("CHANGED"), 0o600); err != nil {
t.Fatal(err)
}
if err := r.Commit(); err != nil {
t.Fatal(err)
}

// Full restore: the resource declines, but that must be a clean skip.
if _, err := e.Restore(); err != nil {
t.Fatalf("Restore aborted on a declining resource instead of skipping it: %v", err)
}
if res.attempts < 1 {
t.Errorf("resource restore was not attempted (attempts=%d)", res.attempts)
}
if got, _ := os.ReadFile(target); string(got) != "ORIGINAL" {
t.Errorf("file baseline not restored past the resource skip: live = %q, want ORIGINAL", got)
}
}

func TestBackupResourceCapturesAndRestoreReapplies(t *testing.T) {
e, _ := newEngine(t)
res := &fakeResource{domain: "com.googlecode.iterm2", state: []byte("ORIGINAL-PREFS")}
Expand Down
7 changes: 7 additions & 0 deletions internal/backup/journal.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,13 @@ func (e *Engine) rollbackRun(runID string) error {
}
}
if err := e.applyState(ch.Prior, blob); err != nil {
// A resource declined its restore (e.g. iTerm2 running): SKIP this change
// and keep rolling back the rest. A running iTerm2 rewrites its domain on
// quit anyway, so we cannot re-import it now — and letting this abort would
// WEDGE the next apply, which runs RollbackIncomplete before it plans.
if isResourceRestoreSkip(err) {
continue
}
return err
}
}
Expand Down
26 changes: 26 additions & 0 deletions internal/backup/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ func (e *Engine) restorePaths(absPaths []string) (string, error) {
refused = append(refused, fmt.Errorf("restore refused for %s: %w", p, err))
continue
}
// A registered resource DECLINED its restore (e.g. iTerm2 running, where
// re-importing the live domain would be silently lost). This is a clean
// SKIP, not a failure: leave the domain as-is and CONTINUE restoring the
// other paths rather than aborting the whole revert. cmd surfaces the
// user-facing notice (it holds the writer and the same running probe).
if isResourceRestoreSkip(err) {
continue
}
return snapID, err
}
}
Expand All @@ -118,6 +126,24 @@ func (e *Engine) applyState(state PathState, blob []byte) error {
return restoreState(state, blob)
}

// ResourceRestoreSkipper is implemented by an error a Resource.Restore returns to
// signal it DECLINED the restore (not failed) — e.g. the iTerm2 domain when iTerm2
// is running, where importing/deleting the live domain would be silently lost. The
// engine treats such an error as a CLEAN SKIP: the resource is left as-is and the
// surrounding multi-path restore / snapshot-undo / incomplete-run rollback
// CONTINUES instead of aborting. It is a structural interface so the engine stays
// decoupled from the terminal package that raises it (no import either way).
type ResourceRestoreSkipper interface {
ResourceRestoreSkipped() bool
}

// isResourceRestoreSkip reports whether an applyState error is a resource DECLINE
// (a clean skip) rather than a genuine failure.
func isResourceRestoreSkip(err error) bool {
var s ResourceRestoreSkipper
return errors.As(err, &s) && s.ResourceRestoreSkipped()
}

// restoreResource drives a registered preference-domain Resource's own restore.
// When absent is true the baseline recorded the domain as not existing pre-ferry,
// so the resource REMOVES/clears it (e.g. `defaults delete`); otherwise blob (the
Expand Down
6 changes: 6 additions & 0 deletions internal/backup/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ func (e *Engine) RestoreSnapshot(snapID string) error {
}
}
if err := e.applyState(state, blob); err != nil {
// A resource declined its restore (e.g. iTerm2 running): SKIP this entry
// and continue undoing the rest rather than aborting the whole snapshot
// re-apply. A running iTerm2 would silently drop the re-import anyway.
if isResourceRestoreSkip(err) {
continue
}
return err
}
}
Expand Down
Loading
Loading