From 94942c406c0a55ab38541d5622300e26f2b6a869 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:50:47 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20v0.7.0=20=E2=80=94=20config=20plugi?= =?UTF-8?q?ns=20on=20a=20converged=20domain=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converge ferry's extension mechanisms onto one registry (fn-5), then add seven config plugins and the observability half on top of it. fn-5 domain convergence: - Replace the isZsh oracle and the hardcoded {"iterm2","terminal"} literals with a single registry (internal/domains, cmd/registry.go) exposing FileDomain and ResourceDomain; collapse the four file plan arms into one kindFile path. - Preserve the two-strip contract byte-for-byte (its trigger is now data-driven, not name-sniffing); delete the dead plugin.Deploy/DeploySpec/OverlayKind scaffolding. A byte-stable zsh migration eval guards the cutover. Config plugins: - git (~/.gitconfig): a hand-written git-INI parser with a machine-identity firewall — user.email/name/signingkey, gpg.program, credential.helper, credential..username, and every [includeIf] block are forced to the never-shared ~/.gitconfig.local layer; a native [include] overlay; URL and http.extraHeader token redaction. - npm: ~/.npmrc carried with registry-token redaction; npm globals as a deps manager that coexists with brew/apt (names-only, install/reconcile-only). - tmux (~/.tmux.conf): a per-machine .local sidecar via a source-file directive; quoted-option (set -g @token '…') secret redaction. - macOS key bindings (~/Library/KeyBindings/DefaultKeyBinding.dict): a repo- authoritative single file with plutil/bplist format hygiene. - Emacs (~/.emacs.d): a repo-authoritative file tree with a volatile-path exclude filter and a per-file local/emacs overlay. - iTerm2: Dynamic Profiles JSON as a file-tree domain, plus global prefs via defaults import with a key allowlist and an "iTerm2 running" guard. - Homebrew: surface Brewfile drift in status and reconcile the deps scope gating (install/reconcile-only; no cleanup). Observability: - doctor now observes the managed-target invariants (no-symlink / ~/.ssh / containment) read-only; export labels its reproducible bundle SHA256. Secrets: a generic column-offset span primitive (secret.SwapColumns) and an npm auth-line detector back the git/tmux/npm token handling; every credential surface has a capture eval proving the secret never reaches the shared repo, and git has an eval proving identity is never shared across machines. All unit tests, the full eval suite (FERRY_BIN), race tests, and the docs-currency release gates pass. BREAKING CHANGE: iTerm2's custom-prefs-folder mechanism is retired. `iterm2 = true` now imports an allowlist-filtered plist via `defaults import` (apply refuses while iTerm2 is running) instead of pointing PrefsCustomFolder at the repo; profiles move to the separate iterm2-profiles Dynamic Profiles domain. See the CHANGELOG Breaking section. --- .gitattributes | 6 + .work/DECISIONS.md | 6 + CHANGELOG.md | 160 ++++ cmd/agents_plan.go | 2 +- cmd/apply.go | 1020 +++++++++------------- cmd/apply_secret_perm_test.go | 8 +- cmd/apply_secret_stage_test.go | 17 +- cmd/apply_wholefile_secret_stage_test.go | 19 +- cmd/capture.go | 173 ++-- cmd/capture_render.go | 66 +- cmd/capture_render_test.go | 21 +- cmd/directive.go | 143 +++ cmd/directive_test.go | 142 +++ cmd/doctor.go | 132 +++ cmd/emacs_plan.go | 128 +++ cmd/export.go | 6 +- cmd/gitconfig.go | 48 + cmd/guided_apply.go | 8 +- cmd/iterm2profiles_plan.go | 121 +++ cmd/keybindings_plan.go | 105 +++ cmd/registry.go | 291 ++++++ cmd/registry_test.go | 116 +++ cmd/restore.go | 13 +- cmd/status.go | 103 ++- cmd/terminals_plan.go | 4 +- cmd/wizard.go | 2 +- cmd/wizard_test.go | 3 - docs/plans/2026-07-06-v0.7.0.md | 7 +- docs/reference/cli/ferry_export.md | 3 +- docs/reference/commands.md | 4 +- docs/reference/configuration.md | 347 +++++++- evals/deps_track_test.go | 171 ++++ evals/doctor_test.go | 103 +++ evals/emacs_test.go | 165 ++++ evals/fn5_migration_test.go | 104 +++ evals/git_test.go | 408 +++++++++ evals/guided_apply_test.go | 7 +- evals/iterm2_profiles_test.go | 148 ++++ evals/iterm2_staging_test.go | 241 ++--- evals/keybindings_test.go | 177 ++++ evals/npmrc_test.go | 199 +++++ evals/tmux_test.go | 220 +++++ internal/bundle/bundle_test.go | 42 + internal/deps/npm.go | 301 +++++++ internal/deps/npm_status_test.go | 315 +++++++ internal/deps/status.go | 150 ++++ internal/domains/domains.go | 143 +++ internal/domains/gitfixture_test.go | 223 +++++ internal/dotfile/apply.go | 47 +- internal/dotfile/doc.go | 7 +- internal/dotfile/dotfile.go | 2 +- internal/emacs/doc.go | 34 + internal/emacs/emacs_test.go | 206 +++++ internal/emacs/exclude.go | 65 ++ internal/emacs/plan.go | 244 ++++++ internal/gitconfig/analyze.go | 190 ++++ internal/gitconfig/analyze_test.go | 221 +++++ internal/gitconfig/parse.go | 322 +++++++ internal/gitconfig/parse_test.go | 100 +++ internal/gitconfig/secret.go | 107 +++ internal/gitconfig/secret_test.go | 107 +++ internal/iterm2profiles/doc.go | 31 + internal/iterm2profiles/lint.go | 55 ++ internal/iterm2profiles/plan.go | 262 ++++++ internal/iterm2profiles/plan_test.go | 171 ++++ internal/keybindings/doc.go | 25 + internal/keybindings/keybindings_test.go | 195 +++++ internal/keybindings/lint.go | 49 ++ internal/keybindings/plan.go | 159 ++++ internal/platform/platform.go | 12 + internal/plugin/plugin.go | 17 - internal/plugin/plugin_test.go | 1 - internal/plugin/zsh/zsh.go | 7 - internal/secret/scan.go | 36 + internal/secret/scan_test.go | 56 ++ internal/secret/spans.go | 25 +- internal/secret/subvalue.go | 52 ++ internal/secret/subvalue_test.go | 264 ++++++ internal/termcfg/apply.go | 37 +- internal/termcfg/apply_secret_test.go | 25 +- internal/terminal/allowlist.go | 232 +++++ internal/terminal/allowlist_test.go | 103 +++ internal/terminal/apply.go | 12 +- internal/terminal/doc.go | 9 +- internal/terminal/domain.go | 122 ++- internal/terminal/plan.go | 2 +- internal/terminal/process.go | 77 ++ internal/terminal/terminal_test.go | 110 ++- internal/tmux/secret.go | 80 ++ internal/tmux/secret_test.go | 70 ++ 90 files changed, 9270 insertions(+), 1019 deletions(-) create mode 100644 .gitattributes create mode 100644 cmd/directive.go create mode 100644 cmd/directive_test.go create mode 100644 cmd/emacs_plan.go create mode 100644 cmd/gitconfig.go create mode 100644 cmd/iterm2profiles_plan.go create mode 100644 cmd/keybindings_plan.go create mode 100644 cmd/registry.go create mode 100644 cmd/registry_test.go create mode 100644 evals/deps_track_test.go create mode 100644 evals/emacs_test.go create mode 100644 evals/fn5_migration_test.go create mode 100644 evals/git_test.go create mode 100644 evals/iterm2_profiles_test.go create mode 100644 evals/keybindings_test.go create mode 100644 evals/npmrc_test.go create mode 100644 evals/tmux_test.go create mode 100644 internal/deps/npm.go create mode 100644 internal/deps/npm_status_test.go create mode 100644 internal/deps/status.go create mode 100644 internal/domains/domains.go create mode 100644 internal/domains/gitfixture_test.go create mode 100644 internal/emacs/doc.go create mode 100644 internal/emacs/emacs_test.go create mode 100644 internal/emacs/exclude.go create mode 100644 internal/emacs/plan.go create mode 100644 internal/gitconfig/analyze.go create mode 100644 internal/gitconfig/analyze_test.go create mode 100644 internal/gitconfig/parse.go create mode 100644 internal/gitconfig/parse_test.go create mode 100644 internal/gitconfig/secret.go create mode 100644 internal/gitconfig/secret_test.go create mode 100644 internal/iterm2profiles/doc.go create mode 100644 internal/iterm2profiles/lint.go create mode 100644 internal/iterm2profiles/plan.go create mode 100644 internal/iterm2profiles/plan_test.go create mode 100644 internal/keybindings/doc.go create mode 100644 internal/keybindings/keybindings_test.go create mode 100644 internal/keybindings/lint.go create mode 100644 internal/keybindings/plan.go create mode 100644 internal/secret/subvalue.go create mode 100644 internal/secret/subvalue_test.go create mode 100644 internal/terminal/allowlist.go create mode 100644 internal/terminal/allowlist_test.go create mode 100644 internal/terminal/process.go create mode 100644 internal/tmux/secret.go create mode 100644 internal/tmux/secret_test.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dbed211 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# The macOS Cocoa key-bindings dict is carried as a readable old-style +# (NeXT/ASCII) text property list, never a binary plist. Mark it `text` so git +# treats it as text (line-ending normalisation, textual diffs) and an editor +# saving it as bplist00 binary shows up as a reviewable change rather than an +# opaque blob. ferry additionally refuses a bplist00 source at apply time. +keybindings/DefaultKeyBinding.dict text diff --git a/.work/DECISIONS.md b/.work/DECISIONS.md index 1d6e1a1..335eeeb 100644 --- a/.work/DECISIONS.md +++ b/.work/DECISIONS.md @@ -14,3 +14,9 @@ a `docs/decisions/` ADR when it shapes architecture or is expensive to reverse. 2026-07-06 Record placement (A1): DROP the abcd 7-folder Record; keep docs/{decisions,plans,research} only. No development/ tree, no scaffoldDocsDirs extension. Rejected: docs/ baseline w/ 4 new folders, top-level development/. 2026-07-06 CLI-ref (A5): use cobra/doc GenMarkdownTree; accept go-md2man+blackfriday as indirect requires, isolated in tools/gendocs (never linked into ferry binary). Rejected: hand-rolled walker. 2026-07-06 Extras: add root SECURITY.md. Rejected: seed Record content, extend scaffold target repos to new Record dirs. +- 2026-07-07 v0.7.0 Phase B tmux: carried as an include-sidecar dotfile on the EXISTING dotfiles FileDomain (declare `.tmux.conf`; `dotfilesFileDomain.Overlay("tmux.conf")` → OverlayIncludeSidecar) — no fn-5 plugin-owned dispatch needed. The include-directive SYNTAX is generalised to a per-bare-name `directiveSpec` (cmd/directive.go: marker + render + namesFile), zsh kept BYTE-IDENTICAL. The two-strip contract stays TWO distinct functions: cmd `stripSourceDirective` (file-keyed, spec-driven) and internal/dotfile `isFerryOverlayInclude` (shape-keyed) — the latter extended to also recognise `source-file -q ~/` so the empty-over-substantial overlay-bypass guard still strips ferry's tmux boilerplate. Overlay dir mapping centralised in `overlayDomainDir` (zsh→zsh, tmux.conf→tmux). tmux secret recogniser lives in NEW internal/tmux (SecretSpans: column span of a quoted `set -g @token '…'` value, gated by IsNonPlaceholderSecret + ScanText High), CALLING secret.SwapColumns — never teaching internal/secret tmux syntax. Capture routes tmux through the span-store path (spanRoutable/captureSpanExtractor) instead of whole-file captureBlocked. DECISION: a tmux secret NOT in a `set -g @token` shape (e.g. a stray PEM pasted into tmux.conf) is refused READ-ONLY rather than whole-file-stored — safe (never leaks) but the user handles it out of band. Rejected: falling back to line-grained FlaggedSpans for tmux (would clobber the `set` prefix on an @token line). +- 2026-07-07 v0.7.0 Phase B iTerm2: TWO artefacts. (A) Dynamic Profiles = new termcfg-shaped FileDomain `iterm2-profiles` (internal/iterm2profiles) fanning `iterm2/DynamicProfiles/*.json` → `~/Library/Application Support/iTerm2/DynamicProfiles/`; Captures()=false, repo-authoritative, frozen GUID (bytes copied verbatim, no capture pass so naturally satisfied). Per-file overlay `local/iterm2-profiles/` wins AND a local-only file deploys as a machine-only child profile (union walk, unlike emacs's override-only). Validation: pure-Go json.Valid on every platform PLUS darwin `plutil -convert xml1 -o /dev/null` — NOT `plutil -lint`, which guesses the old-style parser for `{`-leading content and REJECTS valid JSON (verified). This is why iterm2profiles keeps its OWN linter rather than reusing keybindings' (whose old-style dict genuinely needs -lint). (B) Global plist = D4 flip: NewITerm2(exportBlob, runner, ProcessController) imports via `defaults import` like Apple Terminal, RETIRING PrefsCustomFolder/LoadPrefsFromCustomFolder + the rendered-staging folder. Running-guard: ExecProcessController.Running() via `pgrep -x iTerm2` — apply REFUSES (ErrITerm2Running, clean skip, no rollback) when running because iTerm2 rewrites the domain on quit; after a not-running import, best-effort `killall cfprefsd`. Capture/status filter via terminal.FilterAllowlist (encoding/xml top-level-dict allowlist, sorted+whitespace-stripped → deterministic+idempotent) REPLACING StripITerm2ControlKeys — allowlist means volatile keys (NoSync*/geometry/retired pointer) can never reach the repo. KNOWN: `defaults import` REPLACES the whole domain, so a non-allowlisted global key is reset on apply (volatile ones regenerate; documented "extend the allowlist"). KNOWN wart: an apply that skips-because-running still registers+backups the resource (running-check is apply-time inside terminal.Apply), so prefApplied flips true — harmless (baseline = current state). Rejected: checking running at build/plan time (would make `diff` shell out to pgrep and vary preview by whether iTerm2 runs). +- 2026-07-07 v0.7.0 Phase B git (FLAGSHIP): `~/.gitconfig` rides the EXISTING dotfiles FileDomain as an include-sidecar dotfile (declare `.gitconfig`; `dotfilesFileDomain.Overlay("gitconfig")` → OverlayIncludeSidecar). NEW multi-line `gitDirective` in cmd/directive.go: `directiveSpec` gained an optional `header` field so the file-keyed strip drops git's TWO-line `[include]` block (header + `path=~/…`) — zsh/tmux leave header "" and stay BYTE-IDENTICAL. Shape-keyed half: internal/dotfile `stripFerryOverlayDirective` gained a git 2-line branch (`isFerryGitIncludePath`). Identity firewall = internal/gitconfig.SharedContent (hand-written byte-faithful git-INI parser proven Reassemble(Parse(x))==x by fuzz): drops user.email/name/signingkey, gpg.program, credential.helper, and whole `[includeIf …]` blocks, dropping now-empty identity sections; NO-OP (byte-for-byte) on a clean config so the round-trip stays byte-stable. Enforced on BOTH deploy-compose (planDotfiles include branch + effectiveZshShared) AND shared-capture write (captureOne RouteShared) via cmd sharedGitTransform — defense in depth: even a mis-committed identity key never reaches shared. signingkey is identity-NOT-secret (plaintext local, never the secret store). Secrets: internal/gitconfig.SecretSpans (column-grained token isolation for url.*.insteadOf URL userinfo + http.extraHeader Bearer/Basic), CALLING secret.SwapColumns/ScanText/IsNonPlaceholderSecret — never teaching internal/secret git syntax; wired via captureSpanExtractor/spanRoutable exactly like tmux. credential.helper=store WARNED (gitCredentialHelperWarnings) + stripped (it is identity); ~/.git-credentials never enumerated/read. Rejected: a separate git FileDomain (freeze fixture's toy shape) — the task chose the include-sidecar path so git reuses the whole dotfiles plan/capture/secret machinery. +- 2026-07-07 git plugin security review = APPROVE-WITH-NOTES. Fixed F1 (LOW/MED, real leak of a DECLARED identity key): an unclosed `[user` header made the following `email=` a section-less Junk line that SharedContent kept → identity in shared. Fix: parseSectionHeader now parses leniently WITHOUT a closing `]` (git rejects such a file, but ferry must still firewall it) so `[user` classifies as the user section and its keys drop; a bare `[]` resets section context to "" so a following key is never mis-attributed. Regression tests added (TestSharedContentMalformedHeaderNoLeak + round-trip-unaffected). Fixed F4 (quoted `helper="store"` missed the warning — valueFirstToken now trims quotes) and F3 (comment mismatch on per-host credential.helper). FLAGGED-NOT-FIXED F2: `credential..username` reaches shared — it is account identity but NOT in the signed-off 5-key identity set (plan §3.1: user.email/name/signingkey, gpg.program, credential.helper); leaving the declared contract unchanged rather than silently expanding scope. Owner should decide whether to add credential.username. +- 2026-07-07 git plugin ruthless-review: fixed 2 STOP-class identity leaks in the git-INI parser grammar. F1 INLINE assignment: git parses `[user] email = x` and `[user]email=x` as header+assignment; parser now SPLITS such a physical line into a Section Line + a KeyValue Line (byte-exact across the two Raw fields, so Reassemble(Parse(x))==x still holds), so the inline identity key is classified and dropped, and CredentialHelperStore catches inline `[credential] helper = store`. F2 backslash VALUE-CONTINUATION: git joins a value across lines ending in an ODD run of `\`; parser now tracks continuation state and marks the continuation physical line as a KeyValue INHERITING the owning key (dropped with it) instead of a fresh non-identity key. F3 (minor): extraHeaderRe token group `([^\s"']+)` stops before a trailing quote (no unbalanced-quote placeholder). F4 (cleanup): deleted dead test-only LocalIdentityContent; unexported IsIdentityKey→isIdentityKey (wired internally in identityDropSet). New tests: TestSharedContentInlineHeaderNoLeak, TestCredentialHelperStoreInline, TestSharedContentBackslashContinuationNoLeak (unit), eval TestGit_InlineAndContinuationIdentityStrippedOnDeploy; round-trip fuzz corpus gained inline+continuation seeds. FLAGGED-STILL-OPEN from prior security review: credential..username (F2 there) still reaches shared — owner decision, outside the signed-off 5-key set. +- 2026-07-08 git plugin: owner APPROVED adding credential.username to the identity firewall (resolves the earlier flagged F2). Added "credential.username" to identityKeys in internal/gitconfig/analyze.go — FullKey collapses the subsection, so it drops bare `[credential] username` AND per-host `[credential "https://host"] username` on both deploy (SharedContent) and shared-capture, kept plaintext-local (NOT secret-store-routed; it is account identity, not a secret). Tests: TestSharedContentDropsCredentialUsername (unit, canonical+inline+per-host), extended eval TestGit_IdentityNeverShared to seed a per-host username and assert it reaches only ~/.gitconfig.local. Docs: CHANGELOG [0.7.0] git bullet + docs/reference/configuration.md identity list updated. Identity set is now the SIX keys: user.email/name/signingkey, gpg.program, credential.helper, credential.username (+ all [includeIf] blocks). diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fdf1de..401006f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,124 @@ called out in a **Breaking** section. See ## [Unreleased] +## [0.7.0] - 2026-07-08 + ### Added +- **git (`~/.gitconfig`) is now a carried dotfile with a native `[include]` + overlay and a machine-identity firewall.** Declare `".gitconfig"` in the + `dotfiles` list and ferry carries `dotfiles/gitconfig` to `~/.gitconfig`. Like + the zsh and tmux files it is an include-sidecar dotfile, but the injected + directive is git's own two-line `[include]` block (`path = ~/.gitconfig.local`) + appended last, so git last-wins-merges the machine-local file — a native + equivalent of ferry's overlay. Machine **identity is never shared**: + `user.email`, `user.name`, `user.signingkey`, `gpg.program`, + `credential.helper`, `credential..username`, and every + `[includeIf "gitdir:…"]` block are forced to the + never-shared `~/.gitconfig.local` layer, so one machine's commit identity can + never reach the shared `~/.gitconfig` or the shared repo — even a source that + mistakenly commits identity has it stripped on deploy. A literal token embedded + in a `url.*.insteadOf` value (`https://@host/`, `https://oauth2:@…`) + or after `Bearer`/`Basic` in an `http.extraHeader` is caught on capture: only + the token is routed to the out-of-repo secret store and replaced with a + `{{ferry.secret …}}` placeholder — the surrounding URL scheme/host and the + `Authorization: Bearer ` prefix stay byte-identical — and rendered back on + deploy. A `${GIT_TOKEN}` env-ref is left in the repo verbatim. + `credential.helper = store` (which writes plaintext `~/.git-credentials`) is + warned about and never carried; ferry never reads or writes `~/.git-credentials`. + +- **npm's `~/.npmrc` is now a carried dotfile with registry-token redaction.** + Declare `".npmrc"` in the `dotfiles` list and ferry carries `dotfiles/npmrc` + to `~/.npmrc` as a plain whole-file dotfile (no include sidecar). Only the + user-level `~/.npmrc` is carried, never a per-project one. A registry auth line + — `//host/:_authToken=…`, `:_auth=…`, or `:_password=…` — is recognised by the + secret scanner as a high-confidence credential, so a literal token is blocked + from the shared repo on capture: route it to the out-of-repo secret store and a + `{{ferry.secret …}}` placeholder is committed in its place, which `apply` + renders back to the real token (deployed `0600`) so npm reads it. The + recommended pattern is an environment reference (`_authToken=${NPM_TOKEN}`), + which npm expands at read time and which ferry carries to the shared repo + verbatim; the token then never lives in the config at all. The token is never + written as plaintext into the committed repo. + +- **tmux is now a carried dotfile with a per-machine `.local` sidecar.** Declare + `".tmux.conf"` in the `dotfiles` list and ferry carries `dotfiles/tmux.conf` + to `~/.tmux.conf`. Like the zsh shell files, it is an include-sidecar dotfile: + when a per-machine overlay exists at `local/tmux/tmux.conf.local`, `apply` + appends `source-file -q ~/.tmux.conf.local` last (so the sidecar wins) and + materialises `~/.tmux.conf.local`; `capture` strips that injected directive + before writing the shared source and routes per-machine edits to the sidecar. + A secret inside a quoted tmux option (`set -g @token '…'`) 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 quotes byte-identical — and rendered back on deploy. A `${ENV}` + reference is left in the repo verbatim. + +- **macOS key bindings are now a managed domain.** Enable `keybindings = true` + under `[manage]` and ferry carries `keybindings/DefaultKeyBinding.dict` from the + config repo to `~/Library/KeyBindings/DefaultKeyBinding.dict` as a regular-file + copy reconciled by hash. The domain is repo-authoritative (edit the repo copy + and `apply` deploys it; a live edit shows as drift and `apply` skips it) with no + per-machine `.local` overlay. The source must stay the readable old-style + (NeXT/ASCII) text property list: ferry validates it with `plutil -lint` on macOS + and refuses a binary plist (`bplist00` header), a UTF-8 byte-order mark, or + non-UTF-8 bytes before deploying, and never runs `plutil -convert` on it. + Bindings load at app launch, so relaunch an affected app to pick up changes. + +- **The Emacs configuration tree is now a managed domain.** Enable `emacs = true` + under `[manage]` and ferry carries the config repo's `emacs/` tree to + `~/.emacs.d/`, fanned out file by file as regular-file copies reconciled by hash + (ferry never symlinks `~/.emacs.d/`). Volatile, machine-generated paths are + pruned and never deployed even if present: `elpa/`, `eln-cache/`, `*.elc`, the + tangled `inits/repp.el`, `auto-save-list/`, `transient/`, `url/`, + `network-security.data`, and session state (`recentf`, `savehist`, `saveplace`). + The domain is repo-authoritative (edit the repo source and `apply` deploys it; a + live edit shows as drift and `apply` skips it) with no capture pass. The + per-machine `.local` overlay applies per file: `local/emacs/` wins over + the shared `emacs/`, the home for a Customize-written `inits/custom.el` + or a hand-authored `init.local.el`. Emacs files participate in the secret store + like dotfiles. Note `~/.emacs.d` shadows the XDG `~/.config/emacs`. + +- **iTerm2 Dynamic Profiles are now a managed domain.** Enable + `iterm2-profiles = true` under `[manage]` and ferry carries the config repo's + `iterm2/DynamicProfiles/*.json` to + `~/Library/Application Support/iTerm2/DynamicProfiles/`, fanned out file by file + as regular-file copies reconciled by hash (ferry never symlinks them). The + domain is repo-authoritative (edit the JSON and `apply` deploys it; a live edit + shows as drift and `apply` skips it) with no capture pass; set + `"Rewritable": false` in the profile so iTerm2 will not rewrite it either. A + profile's `"Guid"` is a **frozen identity** — ferry copies the JSON byte-for-byte + and never generates or rewrites a GUID. Each file is validated (as JSON on every + platform, and through `plutil` on macOS) before it lands, because one malformed + file disables all of iTerm2's dynamic profiles; a file that fails is warned about + and skipped. The per-machine `.local` overlay applies per file: + `local/iterm2-profiles/.json` wins over the shared copy, and a file present + only there deploys as a machine-only profile (e.g. a child profile referencing a + shared parent's GUID). Profiles participate in the secret store like dotfiles. + +- **`ferry doctor` now observes ferry's managed-target invariants** (read-only): + it reports whether any deployed target is a symlink (ferry deploys regular-file + copies, never symlinks), whether any managed target resolves under `~/.ssh`, + and whether every managed target resolves inside `$HOME`. A genuine breach — + a symlink where a copy belongs, an escape from `$HOME`, or a target under + `~/.ssh` — is reported `[fail]` and makes doctor exit non-zero; a machine with + nothing managed yet is advisory. The check is stat/lstat-only and never reads + file contents. + +- **npm globals are a new managed deps domain.** Enable `npm-globals = true` + under `[manage]` and ferry carries the machine's globally-installed npm package + **names** (never versions) in `deps/npm-globals.txt`. `ferry capture` re-dumps + the list from `npm ls -g` (sorted, names-only, npm itself excluded); + `ferry apply --deps` reconciles it with `npm i -g`; `ferry status` reports drift + (packages installed but unrecorded, or recorded but not installed). npm globals + are **install/reconcile-only** — ferry never uninstalls them and `restore + --packages` leaves them intact. npm is detected independently of the OS package + manager, so it runs **alongside** Homebrew/apt, not instead of it; when npm is + absent the domain skips cleanly. Entries are validated as plain package names, + so a git-URL, tarball-URL, local-path, or version-tagged spec in the list is + refused before `npm i -g` runs. The `~/.npmrc` config file and its registry + auth token are carried separately, as a dotfile (see below). + - A "Built with Claude Code" attribution badge in the README, and an **Attribution** policy in `AGENTS.md`: the badge is the one sanctioned place to name the AI assistant, user-facing prose stays host-agnostic, and commits @@ -21,10 +137,54 @@ called out in a **Breaking** section. See ### Changed +- **`ferry status` now reports Homebrew Brewfile drift.** When `brew = true` is + managed, status compares the live `brew bundle dump` against the repo Brewfile + (shared plus per-machine overlay) and reports how many entries would be captured + or installed — READ-ONLY: it never installs a package and never rewrites the + Brewfile. The comparison is by package identity (directive plus name), so a + benign option or version difference does not read as drift. This reconciles the + deps managed surface: `status` and `capture` both key on `[manage] brew`, while + `apply --deps` remains the explicit opt-in that performs the install. Homebrew + stays install/reconcile-only — no `brew bundle cleanup` path exists. + +- **`ferry export` labels its bundle SHA256 as reproducible.** The digest is a + deterministic function of the tracked sources — exporting the same sources + always yields the same SHA (no timestamps or randomness are bundled) — so a + recipient can recompute it and confirm a bundle was produced from the same + inputs. The output line and `--help` text now say so; `import --expect-sha256` + remains the verification path. + +- **The iTerm2 global preferences (`iterm2 = true`) now use an import-blob model + with an allowlist.** `capture` exports the live `com.googlecode.iterm2` domain + and keeps **only** an allowlisted set of stable, machine-agnostic global keys + (quit/close prompts, tab and window chrome behaviour, dimming, clipboard + behaviour, the auto-update preference, the default-profile pointer), dropping + everything else — so volatile machine state (window geometry, one-shot `NoSync…` + flags) can never reach the repo. `apply` imports the committed filtered plist + (`iterm2/com.googlecode.iterm2.plist`) via `defaults import`. Because a running + iTerm2 rewrites the domain on quit, `apply` **refuses the import while iTerm2 is + running** and asks you to quit it and re-run; after a successful import ferry runs + `killall cfprefsd` so the daemon does not serve a stale cache (relaunch iTerm2 to + see the change). The `.local` layer applies whole-domain + (`local/iterm2/com.googlecode.iterm2.plist`). + - The default built-in harness registry ships a trimmed set of coding CLIs; any additional harness is declared via `[agents.harness.]` — data, not a code change. +### Breaking + +- **iTerm2's custom-prefs-folder mechanism is retired** (v0.7.0 D4). Earlier + releases deployed `iterm2 = true` by pointing iTerm2's `PrefsCustomFolder` at the + repo folder via `defaults write`; ferry now imports an allowlist-filtered plist + with `defaults import` instead, and `apply` refuses while iTerm2 is running. An + existing committed `iterm2/com.googlecode.iterm2.plist` still applies, but it is + now imported into the live domain (quit iTerm2 first) rather than loaded from a + folder, and the next `capture` reduces it to the allowlisted global keys. Re-run + `ferry capture` (with iTerm2 quit for a subsequent `apply`) to record the filtered + plist. Profiles now belong in the separate `iterm2-profiles` domain as Dynamic + Profiles JSON. + ## [0.6.0] - 2026-07-07 ### Security diff --git a/cmd/agents_plan.go b/cmd/agents_plan.go index 6ba8c12..70a5196 100644 --- a/cmd/agents_plan.go +++ b/cmd/agents_plan.go @@ -63,7 +63,7 @@ func planAgents(ctx *cmdContext, home string, lastApplied *dotfile.Store) ([]pla return nil, nil, cerr } items = append(items, planItem{ - kind: kindAgents, + kind: kindFile, domain: ai.Label, target: ai.Target, content: ai.Content, diff --git a/cmd/apply.go b/cmd/apply.go index d15ce94..a890959 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -16,6 +16,9 @@ import ( "github.com/REPPL/ferry/internal/backup" "github.com/REPPL/ferry/internal/deps" "github.com/REPPL/ferry/internal/dotfile" + "github.com/REPPL/ferry/internal/emacs" + "github.com/REPPL/ferry/internal/iterm2profiles" + "github.com/REPPL/ferry/internal/keybindings" "github.com/REPPL/ferry/internal/paths" "github.com/REPPL/ferry/internal/platform" "github.com/REPPL/ferry/internal/secret" @@ -39,24 +42,32 @@ func init() { type planKind int const ( - kindDotfile planKind = iota // a file-domain target reconciled by copy - kindOverlay // a .local overlay materialised to its home path + // kindFile is the converged FileDomain item: any regular-file target under + // $HOME reconciled by content -> target -> dotfile.ApplyContentDeferred. It + // replaces the pre-fn-5 kindDotfile/kindOverlay/kindAgents/kindTerminal + // quartet; the owning domain (planItem.fileDomain) selects the per-domain + // report wording that used to be keyed on the kind. + kindFile planKind = iota // a FileDomain target (dotfile/overlay/agents/terminal config) kindPreference // a native macOS preference (plist/defaults) domain - kindAgents // an agents-domain target (derived content, repo-authoritative) - kindTerminal // a config-file terminal target (carried like a dotfile) ) // planItem is one unit of work apply would perform. It carries the fully // resolved, secret-rendered content so diff and apply share identical planning. type planItem struct { - kind planKind - domain string // human label (e.g. "zsh", "iterm2", ".zshrc") - target dotfile.Target // dotfile/overlay targets only - content []byte // effective content to materialise (post-render) - action string // computed action for reporting (created/updated/noop/conflict/skipped/preview) - skip bool // true when a missing secret forces a skip - missing []string // refs that were missing (when skip) - note string // free-form note (de-scope warnings, preference TODO) + kind planKind + // fileDomain is the owning FileDomain's scope name ("dotfiles", "agents", + // "terminals") for a kindFile item. It selects the per-domain report wording + // (capture guidance vs repo-authoritative vs repo-source) and the + // agents-target recording that used to be keyed on the collapsed kinds. Empty + // for kindPreference. + fileDomain string + domain string // human label (e.g. "zsh", "iterm2", ".zshrc") + target dotfile.Target // dotfile/overlay targets only + content []byte // effective content to materialise (post-render) + action string // computed action for reporting (created/updated/noop/conflict/skipped/preview) + skip bool // true when a missing secret forces a skip + missing []string // refs that were missing (when skip) + note string // free-form note (de-scope warnings, preference TODO) // risky/riskReason are the guided-apply risk gate's per-item verdict, computed // during planning by assessRisk. A risky change halts for confirmation in the @@ -100,16 +111,6 @@ type planItem struct { // apply Registers + BackupResources + Applies it. nil for non-preference kinds. prefDomain *terminal.PreferenceDomain - // stagePlistPath / stageContent carry the iTerm2 RENDERED STAGING write. When - // stagePlistPath is non-empty (iterm2 only), mutate writes stageContent — the - // repo plist with its {{ferry.secret ...}} placeholders already substituted — - // into the ferry-owned staging folder (com.googlecode.iterm2.plist) via the - // backup engine BEFORE terminal.Apply points PrefsCustomFolder at that folder. - // So iTerm2 loads the rendered plist, never the raw repo one. Empty on the - // read-only preview path and for non-iterm2 kinds. - stagePlistPath string - stageContent []byte - // prefApplied is the observable "already applied" signal for a kindPreference // item: true when an immutable baseline exists for this domain's resource path // (HasBaseline(ResourcePath(prefID))), i.e. ferry applied it on this machine @@ -237,49 +238,39 @@ func buildPlanWithEngine(ctx *cmdContext, eng *backup.Engine) (items []planItem, return nil, nil, err } - if ctx.Scope.IsManaged("dotfiles") { - ditems, dwarn, derr := planDotfiles(ctx, home, secretStore, lastApplied) - if derr != nil { - return nil, nil, derr - } - items = append(items, ditems...) - warnings = append(warnings, dwarn...) - } - - // Agents domain: the harness registry × instruction sources × optional - // devtree × asset trees, expanded to 1:1 (content, target) items by - // planAgents and reconciled through the same three-way machinery as - // dotfiles. Gated behind `[manage] agents = true` (default off). When the - // domain is de-scoped, previously applied targets collapse into one - // de-scope warning (files left untouched). - if ctx.Scope.IsManaged("agents") { - aitems, awarn, aerr := planAgents(ctx, home, lastApplied) - if aerr != nil { - return nil, nil, aerr + reg := buildRegistry(ctx) + + // FileDomain fan-out (fn-5): plan each in-scope FileDomain through the + // converged registry, in the registry's LOAD-BEARING order (dotfiles, agents, + // terminals), so item AND warning ORDERING matches the pre-fn-5 dispatch + // byte-for-byte. Each managed domain's rich planItems — secrets rendered, + // three-way state classified, risk assessed — is produced by its per-domain + // planner via the filePlanner upcast (the frozen domains.FileItem cannot carry + // state/skip/risk). An out-of-scope domain emits its own de-scope warnings + // instead; dotfiles DEFER theirs to the END of the plan (after the preference + // domains) via descopeDotfileWarnings, so descopeUnmanaged returns nil for it. + // This replaces the hardcoded IsManaged("dotfiles"/"agents"/"terminals") + // sequence — the agents/terminals per-domain gating notes still hold: each is + // gated behind its own `[manage]` bool (default off) and collapses de-scoped + // targets into de-scope warnings. + for _, fd := range reg.FileDomains { + fp, ok := fd.(filePlanner) + if !ok { + continue } - items = append(items, aitems...) - warnings = append(warnings, awarn...) - } else { - warnings = append(warnings, descopeAgentsWarnings(lastApplied, nil, false)...) - } - - // Config-file terminal domain: the built-in terminal registry × the repo's - // terminals/ configs, expanded to 1:1 (content, target) items by - // planTerminals and reconciled through the same three-way machinery as - // dotfiles (they ARE carried like dotfiles: capturable, .local overlay wins). - // Gated behind `[manage] terminals = true` (default off). When de-scoped, - // previously applied targets collapse into one de-scope warning (files left - // untouched). Distinct from the macOS `terminal`/`iterm2` preference domains - // below. - if ctx.Scope.IsManaged("terminals") { - titems, twarn, terr := planTerminals(ctx, home, secretStore, lastApplied) - if terr != nil { - return nil, nil, terr + if ctx.Scope.IsManaged(fd.Name()) { + fitems, fwarn, ferr := fp.planItems(ctx, home, secretStore, lastApplied) + if ferr != nil { + return nil, nil, ferr + } + for i := range fitems { + fitems[i].fileDomain = fd.Name() + } + items = append(items, fitems...) + warnings = append(warnings, fwarn...) + } else { + warnings = append(warnings, fp.descopeUnmanaged(ctx, lastApplied)...) } - items = append(items, titems...) - warnings = append(warnings, twarn...) - } else { - warnings = append(warnings, descopeTerminalConfigWarnings(lastApplied, nil, false)...) } // Terminal preference domains: represent in-scope iterm2 / Apple Terminal as @@ -297,27 +288,26 @@ func buildPlanWithEngine(ctx *cmdContext, eng *backup.Engine) (items []planItem, // and never shells out. The already-applied baseline probe is darwin-only (the // engine baseline is a macOS apply artifact); on Linux the entry is a pure // no-op skip that apply never mutates (terminal.Apply no-ops via ErrNotDarwin). - for _, domain := range []string{"iterm2", "terminal"} { + for _, rd := range reg.ResourceDomains { + domain := rd.Name() if !ctx.Scope.IsManaged(domain) { continue } - d, stage, derr := buildTerminalDomain(ctx.RepoPath, domain, secretStore) + d, missing, derr := buildTerminalDomain(ctx.RepoPath, domain, secretStore) if derr != nil { - // The repo-side prefs folder / export blob is a symlink, escapes the repo, - // or resolves under ~/.ssh / a system location. REFUSE the domain rather than - // hand a poisoned folder to `defaults write PrefsCustomFolder` — skip it with - // a clear notice instead of persisting a symlinked prefs folder. + // The repo-side export blob is a symlink, escapes the repo, or resolves under + // ~/.ssh / a system location. REFUSE the domain rather than `defaults import` + // a poisoned blob — skip it with a clear notice. warnings = append(warnings, refusalWarning(domain, derr)) continue } - // Secret render-or-SKIP parity with dotfiles: the plist/export bytes this - // domain would deploy carry unrendered {{ferry.secret ...}} placeholders whose - // secret is MISSING from the store. SKIP the whole terminal domain (live config - // left intact) rather than point iTerm2 at — or `defaults import` — an - // unrendered placeholder, which would expose it to the terminal preference - // mechanism. With the secret PRESENT the bytes are rendered and applied. - if len(stage.Missing) > 0 { - warnings = append(warnings, fmt.Sprintf("%-22s skipped (missing secret: %s)", domain, strings.Join(stage.Missing, ", "))) + // Secret render-or-SKIP parity with dotfiles: the export bytes this domain + // would deploy carry unrendered {{ferry.secret ...}} placeholders whose secret + // is MISSING from the store. SKIP the whole terminal domain (live config left + // intact) rather than `defaults import` an unrendered placeholder. With the + // secret PRESENT the bytes are rendered and applied. + if len(missing) > 0 { + warnings = append(warnings, fmt.Sprintf("%-22s skipped (missing secret: %s)", domain, strings.Join(missing, ", "))) continue } // Observable "already applied": a recorded immutable baseline for this @@ -339,13 +329,11 @@ func buildPlanWithEngine(ctx *cmdContext, eng *backup.Engine) (items []planItem, } } items = append(items, planItem{ - kind: kindPreference, - domain: domain, - action: "preview", - prefDomain: d, - prefApplied: applied, - stagePlistPath: stage.plistPath, - stageContent: stage.content, + kind: kindPreference, + domain: domain, + action: "preview", + prefDomain: d, + prefApplied: applied, }) } @@ -361,10 +349,12 @@ func buildPlanWithEngine(ctx *cmdContext, eng *backup.Engine) (items []planItem, // planDotfiles builds the per-target plan for the dotfiles domain, honouring the // PER-DOMAIN overlay strategy (PLAN.md "Per-domain overlay strategy"): -// - zsh (include-style): build the target with IncludeSidecarTarget, always -// deploy the SHARED file (with `source ~/.zshrc.local` appended last so the -// per-machine sidecar wins), and materialise the sidecar (~/.zshrc.local). -// - generic dotfiles (whole-file replace, e.g. .gitconfig): deploy the LOCAL +// - include-style (zsh/tmux/git): build the target with IncludeSidecarTarget, +// always deploy the SHARED file (with the format's include directive appended +// last so the per-machine sidecar wins — shell `source`, tmux `source-file`, +// git `[include]`), and materialise the sidecar (~/..local). For git the +// shared bytes are additionally identity-firewalled before the include. +// - generic dotfiles (whole-file replace, e.g. .vimrc): deploy the LOCAL // copy (local//) INSTEAD OF shared when one exists (local wins), // else the shared content — the chosen bytes are composed into the effective // content and deployed via the shared apply core (dotfile.ApplyContentDeferred). @@ -383,7 +373,7 @@ func planDotfiles(ctx *cmdContext, home string, secretStore *secret.Store, lastA for _, name := range ctx.Scope.DeclaredDotfiles() { bare := strings.TrimPrefix(name, ".") - zsh := isZsh(bare) + zsh := usesIncludeSidecar(bare) // SECURITY BOUNDARY: build the validated target. zsh is include-style; every // other dotfile is whole-file-replace. An ~/.ssh / traversal name is refused @@ -432,10 +422,18 @@ func planDotfiles(ctx *cmdContext, home string, secretStore *secret.Store, lastA if err != nil { return nil, nil, err } - content := raw + // git identity firewall: strip every identity key + [includeIf …] block + // from the SHARED bytes before ferry's include directive is appended, so + // a machine's commit identity can never deploy into the shared ~/.gitconfig + // (a no-op for every other include-style dotfile, and for a clean git + // source). Identity lives only in the ~/..local sidecar below. + base := sharedGitTransform(bare, raw) + content := base if hasOverlay { - content = appendSourceDirective(raw, "."+bare+".local") + content = appendSourceDirective(base, "."+bare+".local", directiveSpecFor(bare)) } + // Warn when git's credential.helper is `store` (plaintext ~/.git-credentials). + warnings = append(warnings, gitCredentialHelperWarnings(bare, raw)...) rendered, missing, skip, err := renderSecrets(secretStore, content) if err != nil { @@ -443,7 +441,7 @@ func planDotfiles(ctx *cmdContext, home string, secretStore *secret.Store, lastA } if skip { items = append(items, planItem{ - kind: kindDotfile, domain: "." + bare, target: t, + kind: kindFile, domain: "." + bare, target: t, action: "skipped", skip: true, missing: missing, }) continue @@ -454,7 +452,7 @@ func planDotfiles(ctx *cmdContext, home string, secretStore *secret.Store, lastA return nil, nil, err } items = append(items, planItem{ - kind: kindDotfile, domain: "." + bare, target: t, content: rendered, + kind: kindFile, domain: "." + bare, target: t, content: rendered, state: state, secretRouted: secretRouted, risky: risky, riskReason: reason, }) @@ -478,7 +476,7 @@ func planDotfiles(ctx *cmdContext, home string, secretStore *secret.Store, lastA } if oskip { items = append(items, planItem{ - kind: kindOverlay, domain: "." + bare + ".local", target: ot, + kind: kindFile, domain: "." + bare + ".local", target: ot, action: "skipped", skip: true, missing: omissing, }) continue @@ -489,7 +487,7 @@ func planDotfiles(ctx *cmdContext, home string, secretStore *secret.Store, lastA return nil, nil, err } items = append(items, planItem{ - kind: kindOverlay, domain: "." + bare + ".local", + kind: kindFile, domain: "." + bare + ".local", target: ot, content: orendered, state: ostate, secretRouted: oSecretRouted, risky: orisky, riskReason: oreason, }) @@ -520,7 +518,7 @@ func planDotfiles(ctx *cmdContext, home string, secretStore *secret.Store, lastA } if skip { items = append(items, planItem{ - kind: kindDotfile, domain: "." + bare, target: t, + kind: kindFile, domain: "." + bare, target: t, action: "skipped", skip: true, missing: missing, }) continue @@ -531,7 +529,7 @@ func planDotfiles(ctx *cmdContext, home string, secretStore *secret.Store, lastA return nil, nil, err } items = append(items, planItem{ - kind: kindDotfile, domain: "." + bare, target: t, content: rendered, + kind: kindFile, domain: "." + bare, target: t, content: rendered, state: state, secretRouted: secretRouted, risky: risky, riskReason: reason, }) } @@ -570,10 +568,15 @@ func classifyItem(t dotfile.Target, content []byte, secretRouted bool, store *do // local route, into a self-sourcing sidecar). raw is the shared repo source // bytes; hasOverlay is whether local/zsh/.local exists (resolveOverlaySource). func effectiveZshShared(raw []byte, bare string, hasOverlay bool) []byte { + // git identity firewall: the effective SHARED bytes have identity stripped + // (matching the deploy composition in planDotfiles), so capture classifies and + // diffs against the same firewalled content and never offers an identity line + // as a shared hunk. A no-op for zsh/tmux and for a clean git source. + base := sharedGitTransform(bare, raw) if !hasOverlay { - return raw + return base } - return appendSourceDirective(raw, "."+bare+".local") + return appendSourceDirective(base, "."+bare+".local", directiveSpecFor(bare)) } // effectiveSource resolves the source apply would actually DEPLOY for a @@ -798,29 +801,6 @@ func mutate(eng *backup.Engine, b dotfile.Backuper, backupResource func(domain s fmt.Fprintf(out, " %-22s skipped (macOS only)\n", it.domain) continue } - // iTerm2 RENDERED STAGING: materialise the secret-rendered repo plist into - // the ferry-owned staging folder (com.googlecode.iterm2.plist) BEFORE Apply - // points PrefsCustomFolder at it, so iTerm2 loads the rendered plist and - // never the raw {{ferry.secret}} one. The write goes through the backup - // engine (b) — tracked in the journal + reversible on rollback, just like a - // dotfile. The folder is created 0700 (StateDir convention) and the plist - // 0600 (it carries substituted secret values). A refused/missing leaf or a - // missing secret already SKIPPED this domain at build time, so stagePlistPath - // is set only when there is a rendered plist to write. - if it.stagePlistPath != "" { - // Symlink-harden the staging dir under StateDir before writing: a - // rendered plist must not be written through a symlinked store - // component. Lexical; never touches ~/.ssh. - if err := paths.HardenStoreDir(filepath.Dir(it.stagePlistPath)); err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(it.stagePlistPath), 0o700); err != nil { - return fmt.Errorf("stage %s rendered plist dir: %w", it.domain, err) - } - if err := b.BackupAndWrite(it.stagePlistPath, it.stageContent, 0o600); err != nil { - return fmt.Errorf("stage %s rendered plist: %w", it.domain, err) - } - } // Register so restore (and incomplete-run rollback) can find this // resource, then export its CURRENT state into baseline-if-first + // this run's journal FIRST (export-before-mutate). The captured blob @@ -853,6 +833,15 @@ func mutate(eng *backup.Engine, b dotfile.Backuper, backupResource func(domain s fmt.Fprintf(out, " %-22s skipped (macOS only)\n", it.domain) continue } + if res.Skipped && errors.Is(res.Err, terminal.ErrITerm2Running) { + // iTerm2 is running: importing its global plist now would be silently + // lost (iTerm2 rewrites the domain on quit; cfprefsd caches it). SKIP + // the domain, leaving live config intact — nothing was imported, so + // there is NOTHING to roll back (rolling back would itself `defaults + // import` into the running iTerm2). Warn loudly with the fix. + fmt.Fprintf(out, " %-22s skipped: quit iTerm2, then re-run `ferry apply` (a running iTerm2 would overwrite the imported preferences on quit)\n", it.domain) + continue + } if res.Err != nil { // A partial preference mutation must be reverted IMMEDIATELY: re-import // the captured pre-mutation export via the resource's own Restore hook @@ -871,111 +860,53 @@ func mutate(eng *backup.Engine, b dotfile.Backuper, backupResource func(domain s fmt.Fprintf(out, " %-22s note: %s\n", "", res.Note) } continue - case kindAgents: - // Agents targets deploy their DERIVED in-memory content through the - // same Backuper/journal as dotfiles (agents.ApplyItem), with the - // domain's repo-authoritative wording on refusals: a live edit is - // never captured back in v1, so the fix is the repo copy (or --force). - // - // Every planned agents target is also collected for the persisted - // target record (agents-targets.json, unioned post-commit): scoped - // restore reverts from that record, so a later de-scope — or a - // deleted repo — can never hide a previously applied target. - agentsTargets[it.target.Name] = it.target.Home - res, err := agents.ApplyItem(agents.Item{ - Key: it.target.Name, - Label: it.domain, - Target: it.target, - Content: it.content, - Exec: it.execBit, - }, lastApplied, b, force) - if err != nil { - var conflict *dotfile.ConflictError - if errors.As(err, &conflict) { - conflicts = append(conflicts, it.domain) - fmt.Fprintf(out, " %-22s CONFLICT: edited live AND in the repo; not overwritten (agents targets are repo-authoritative — update the repo copy, or `ferry apply --force`)\n", it.domain) - continue - } - // The empty-over-substantial data-loss guard aborts the run, same - // as a dotfile target (the run rolls back inline). - return err - } - if res.ForcedEmptyOverSubstantial { - fmt.Fprintf(out, "warning: --force replaced %s (a substantial existing file) with an empty/blank repo source — real config content was overwritten (backed up; run `ferry restore` to recover)\n", res.ForcedPath) - } - if res.Action == dotfile.ActionSkipped { - fmt.Fprintf(out, " %-22s skipped (edited live; agents targets are repo-authoritative — update the repo copy, or `ferry apply --force`)\n", it.domain) - continue + case kindFile: + // Converged FileDomain arm (fn-5): every regular-file target — + // dotfile/overlay, agents, config-file terminal — deploys its effective + // in-memory content through the ONE shared apply core + // (dotfile.ApplyContentDeferred): the three-way decision table, + // first-touch adoption, conflict refusal, the empty-over-substantial + // data-loss guard, and the Backuper-mediated atomic write. The owning + // domain (it.fileDomain) selects the report wording that used to be keyed + // on the separate kinds; the deploy mechanics are identical. + + // Every planned agents target is collected for the persisted target record + // (agents-targets.json, unioned post-commit): scoped restore reverts from + // that record, so a later de-scope — or a deleted repo — can never hide a + // previously applied target. Dotfiles/terminals restore from the backup + // baseline instead. Agents items never carry a missing-secret skip. + if it.fileDomain == "agents" { + agentsTargets[it.target.Name] = it.target.Home } - deferred = append(deferred, res) - it.action = string(res.Action) - fmt.Fprintf(out, " %-22s %s\n", it.domain, res.Action) - continue - case kindTerminal: // A missing referenced secret SKIPPED rendering during planning (never - // deploy a literal {{ferry.secret}} to the terminal's live config); report - // it exactly like a skipped secret-routed dotfile and move on. + // deploy a literal {{ferry.secret}} to a live config); report it and move + // on. Agents items are never secret-routed, so skip is ALWAYS false for + // them and this shared guard is inert — which is why recording the agents + // target (above) BEFORE this guard is currently safe. If agents ever gains + // secret routing, a missing secret would skip here AFTER the target was + // already recorded, so revisit the record-then-skip ordering then. if it.skip { fmt.Fprintf(out, " %-22s skipped (missing secret: %s)\n", it.domain, strings.Join(it.missing, ", ")) continue } - // Config-file terminal targets deploy their overlay-or-shared Content - // through the same Backuper/journal as dotfiles (termcfg.ApplyItem). - // Unlike dotfiles, though, capture has NO config-file terminal pass - // (cmd/capture.go handles only declared dotfiles and the iterm2/terminal - // PREFERENCE domains), so a drifted/conflicting terminal target is NOT a - // capture candidate: the guidance points at updating the repo source or - // `ferry apply --force`, never `ferry capture`. - res, err := termcfg.ApplyItem(termcfg.Item{ - Key: it.target.Name, - Label: it.domain, - Target: it.target, - Content: it.content, - Exec: it.execBit, - SecretRouted: it.secretRouted, - }, lastApplied, b, force) - if err != nil { - var conflict *dotfile.ConflictError - if errors.As(err, &conflict) { - conflicts = append(conflicts, it.domain) - fmt.Fprintf(out, " %-22s CONFLICT: local edits AND repo change; not overwritten (update the repo source to match, or `ferry apply --force`)\n", it.domain) - continue - } - // The empty-over-substantial data-loss guard aborts the run, same - // as a dotfile target (the run rolls back inline). - return err - } - if res.ForcedEmptyOverSubstantial { - fmt.Fprintf(out, "warning: --force replaced %s (a substantial existing file) with an empty/blank repo source — real config content was overwritten (backed up; run `ferry restore` to recover)\n", res.ForcedPath) - } - if res.Action == dotfile.ActionSkipped { - fmt.Fprintf(out, " %-22s skipped (local edits; update the repo source to match, or `ferry apply --force`)\n", it.domain) - continue - } - // res.SecretRouted was already stamped by the apply core (via ApplyItem) - // from the plan's secret-routing flag, so CommitLastApplied records only - // this terminal target's hash, never the plaintext bytes. - deferred = append(deferred, res) - it.action = string(res.Action) - fmt.Fprintf(out, " %-22s %s\n", it.domain, res.Action) - continue - case kindDotfile, kindOverlay: - if it.skip { - fmt.Fprintf(out, " %-22s skipped (missing secret: %s)\n", it.domain, strings.Join(it.missing, ", ")) - continue + // Fresh-write mode: 0755 for an executable source (agents hooks / terminal + // helper scripts), else 0644; an existing destination's mode is preserved + // by the core, and secretRouted clamps the written mode to owner-only and + // records hash-only last-applied. + perm := dotfile.DefaultPerm() + if it.execBit { + perm = 0o755 } - res, err := applyTarget(it, lastApplied, b, force) + res, err := dotfile.ApplyContentDeferred(it.target, it.content, perm, lastApplied, b, force, it.secretRouted) if err != nil { var conflict *dotfile.ConflictError if errors.As(err, &conflict) { conflicts = append(conflicts, it.domain) - fmt.Fprintf(out, " %-22s CONFLICT: uncaptured local edits; not overwritten (run `ferry capture`, or `ferry apply --force`)\n", it.domain) + fmt.Fprintf(out, " %-22s %s\n", it.domain, fileConflictMessage(it.fileDomain)) continue } - // The empty-over-substantial data-loss guard: a default apply refuses - // to zero a substantial live file with an empty/near-empty repo source. - // This is a hard abort (not a skip) so the run rolls back and exits - // non-zero; the error names the file and both sides of the hazard. + // The empty-over-substantial data-loss guard aborts the run (rolled + // back inline by the caller); a conflict is reported and skipped above. return err } // --force pushed an empty/near-empty repo source OVER a substantial live @@ -985,9 +916,18 @@ func mutate(eng *backup.Engine, b dotfile.Backuper, backupResource func(domain s if res.ForcedEmptyOverSubstantial { fmt.Fprintf(out, "warning: --force replaced %s (a substantial existing file) with an empty/blank repo source — real config content was overwritten (backed up; run `ferry restore` to recover)\n", res.ForcedPath) } - // res.SecretRouted was already stamped by the apply core (via applyTarget -> - // ApplyContentDeferred) from the plan's secret-routing flag, so - // CommitLastApplied records only this target's hash, never the plaintext bytes. + // Agents and config-file terminal targets carry the domain's own wording + // when apply SKIPS a locally-drifted target (repo-authoritative); a dotfile + // skip has no special wording and falls through to the generic action line. + if res.Action == dotfile.ActionSkipped { + if msg := fileSkippedMessage(it.fileDomain); msg != "" { + fmt.Fprintf(out, " %-22s %s\n", it.domain, msg) + continue + } + } + // res.SecretRouted was already stamped by the apply core from the plan's + // secret-routing flag, so CommitLastApplied records only this target's + // hash, never the plaintext bytes. deferred = append(deferred, res) it.action = string(res.Action) fmt.Fprintf(out, " %-22s %s\n", it.domain, res.Action) @@ -1023,33 +963,37 @@ func mutate(eng *backup.Engine, b dotfile.Backuper, backupResource func(domain s return nil } -// applyTarget materialises one dotfile/overlay through the dotfile domain using -// the DEFERRED apply: it writes the file (backed up first, atomically) but does -// NOT persist last-applied — the recorded hash rides on Result.PendingHash for the -// caller to commit via CommitLastApplied AFTER the journal commit. Every target — -// whole-file-replace (generic, e.g. .gitconfig) and include-style (zsh sidecar) -// alike — deploys the effective bytes IN MEMORY via the shared apply core the -// agents domain uses, so no temp file is ever staged and a crash cannot leave the -// ALREADY-RENDERED plaintext of a secret-routed dotfile behind in $TMPDIR. -// it.content is the effective source: the local-wins decision (whole-file overlay) -// and zsh source-last composition are already baked in during planning, and any -// {{ferry.secret}} tokens are already rendered, so behaviour is preserved without -// re-selecting a source at write time. -func applyTarget(it *planItem, store *dotfile.Store, b dotfile.Backuper, force bool) (dotfile.Result, error) { - t := it.target - - // DEFERRED semantics (last-applied via CommitLastApplied post-commit) keep - // last-applied from advancing ahead of a rolled-back file. freshPerm governs only - // a first-ever write — an existing home destination's mode is preserved by the - // shared core. Secret routing is declared to the core (it.secretRouted): for such - // a target the core strips group/other from the written mode (0600), even on an - // update to an existing file, so the rendered plaintext credential is never - // group-/world-readable, and it records only the content hash. - res, err := dotfile.ApplyContentDeferred(t, it.content, dotfile.DefaultPerm(), store, b, force, it.secretRouted) - if err != nil { - return dotfile.Result{}, err +// fileConflictMessage renders the per-domain CONFLICT report line body for a +// FileDomain target apply refused to overwrite (edited live AND in the repo). +// The wording is the domain's remedy: dotfiles point at `ferry capture`, while +// agents and config-file terminals are repo-authoritative (no capture pass) so +// they point at updating the repo copy/source. It is exactly the text the +// pre-fn-5 per-kind mutate arms printed, now selected by owning domain. +func fileConflictMessage(fileDomain string) string { + switch fileDomain { + case "agents": + return "CONFLICT: edited live AND in the repo; not overwritten (agents targets are repo-authoritative — update the repo copy, or `ferry apply --force`)" + case "terminals", "keybindings", "emacs": + return "CONFLICT: local edits AND repo change; not overwritten (update the repo source to match, or `ferry apply --force`)" + default: + return "CONFLICT: uncaptured local edits; not overwritten (run `ferry capture`, or `ferry apply --force`)" + } +} + +// fileSkippedMessage renders the per-domain "skipped (locally drifted)" report +// line body for a FileDomain target apply left for its repo-authoritative +// remedy. Dotfiles have NO special wording — a locally-drifted dotfile is a +// capture candidate that falls through to the generic action line — so this +// returns "" for them. +func fileSkippedMessage(fileDomain string) string { + switch fileDomain { + case "agents": + return "skipped (edited live; agents targets are repo-authoritative — update the repo copy, or `ferry apply --force`)" + case "terminals", "keybindings", "emacs": + return "skipped (local edits; update the repo source to match, or `ferry apply --force`)" + default: + return "" } - return res, nil } // applyDeps runs the gated dependency install and persists the recorded @@ -1058,18 +1002,49 @@ func applyTarget(it *planItem, store *dotfile.Store, b dotfile.Backuper, force b func applyDeps(ctx *cmdContext, out io.Writer) error { depsDir := filepath.Join(ctx.RepoPath, "deps") result, err := deps.Install(depsDir, deps.ExecRunner{}) - if err != nil { - if errors.Is(err, deps.ErrNoPackageManager) { - fmt.Fprintln(out, "deps: no package manager present; skipping dependency install") - return nil - } + switch { + case errors.Is(err, deps.ErrNoPackageManager): + // No OS package manager: report and fall through to npm globals, which are + // ORTHOGONAL (a machine can have npm but no brew/apt). + fmt.Fprintln(out, "deps: no package manager present; skipping dependency install") + case err != nil: return fmt.Errorf("install dependencies: %w", err) + default: + installed := result.RecordedInstalledSet() + if err := persistInstalledSet(installed); err != nil { + return fmt.Errorf("record installed packages: %w", err) + } + fmt.Fprintf(out, "deps: installed %d package(s)\n", len(installed)) } - installed := result.RecordedInstalledSet() - if err := persistInstalledSet(installed); err != nil { - return fmt.Errorf("record installed packages: %w", err) + + // npm globals: a SEPARATE, coexisting manager beside brew/apt (NOT another + // DetectPackageManager choice — npm is detected independently by presence on + // PATH, so a brew machine still reconciles npm). Gated on the npm-globals domain + // being managed; install/reconcile-only (never uninstalls, so nothing is + // recorded for `restore --packages`, mirroring the Homebrew cleanup-out rule). + if ctx.Scope.IsManaged("npm-globals") { + if err := applyNpmGlobals(ctx, out); err != nil { + return err + } } - fmt.Fprintf(out, "deps: installed %d package(s)\n", len(installed)) + return nil +} + +// applyNpmGlobals reconciles this machine's global npm packages to the committed +// deps/npm-globals.txt via `npm i -g`. A missing npm is a clean skip (mirrors the +// no-package-manager report): npm is optional and orthogonal to brew/apt. It never +// uninstalls, so there is nothing to persist for restore. +func applyNpmGlobals(ctx *cmdContext, out io.Writer) error { + if !platform.HasNpm() { + fmt.Fprintln(out, "npm-globals: npm not present; skipping global package install") + return nil + } + depsDir := filepath.Join(ctx.RepoPath, "deps") + names, err := deps.InstallNpmGlobals(depsDir, deps.ExecRunner{}) + if err != nil { + return fmt.Errorf("install npm globals: %w", err) + } + fmt.Fprintf(out, "npm-globals: reconciled %d global package(s)\n", len(names)) return nil } @@ -1129,76 +1104,84 @@ func printPlan(out io.Writer, plan []planItem) { fmt.Fprintf(out, " %-22s [preference domain] %s — %s\n", it.domain, pe.Domain, pe.Summary) } } - case kindAgents: - // Agents targets share the dotfile states but carry the domain's - // repo-authoritative guidance: a live edit is fixed in the repo copy - // (capture never ingests these in v1), so the pointers differ. - switch it.state { - case dotfile.StateClean: - fmt.Fprintf(out, " %-22s %s (already in sync)\n", it.domain, colour(colGreen, "clean")) - case dotfile.StateMissing: - create++ - fmt.Fprintf(out, " %-22s %s\n", it.domain, colour(colYellow, "would create")) - case dotfile.StateRepoAhead: - update++ - fmt.Fprintf(out, " %-22s %s\n", it.domain, colour(colYellow, "would update")) - case dotfile.StateLocallyDrifted: - fmt.Fprintf(out, " %-22s %s (edited live; repo-authoritative — update the repo copy, or `ferry apply --force`)\n", it.domain, colour(colYellow, "would skip")) - case dotfile.StateConflict: - conflict++ - fmt.Fprintf(out, " %-22s %s (edited live AND in the repo; update the repo copy, or `ferry apply --force`)\n", it.domain, colour(colRed, "conflict")) - default: - fmt.Fprintf(out, " %-22s %s\n", it.domain, it.state) - } - case kindTerminal: - // A missing referenced secret blocks this terminal target (render-or-SKIP); - // surface it, exactly like a blocked dotfile, rather than a state line. - if it.skip { - fmt.Fprintf(out, " %-22s %s (missing secret: %s)\n", it.domain, colour(colYellow, "blocked"), strings.Join(it.missing, ", ")) - continue - } - // Config-file terminal targets share the dotfile states but, unlike - // dotfiles, capture has NO config-file terminal pass, so a drift/conflict - // is NOT a capture candidate: the guidance points at updating the repo - // source or `ferry apply --force`, never `ferry capture`. - switch it.state { - case dotfile.StateClean: - fmt.Fprintf(out, " %-22s %s (already in sync)\n", it.domain, colour(colGreen, "clean")) - case dotfile.StateMissing: - create++ - fmt.Fprintf(out, " %-22s %s\n", it.domain, colour(colYellow, "would create")) - case dotfile.StateRepoAhead: - update++ - fmt.Fprintf(out, " %-22s %s\n", it.domain, colour(colYellow, "would update")) - case dotfile.StateLocallyDrifted: - fmt.Fprintf(out, " %-22s %s (local edits; update the repo source to match, or `ferry apply --force`)\n", it.domain, colour(colYellow, "would skip")) - case dotfile.StateConflict: - conflict++ - fmt.Fprintf(out, " %-22s %s (modified locally AND in the repo; update the repo source, or `ferry apply --force`)\n", it.domain, colour(colRed, "conflict")) - default: - fmt.Fprintf(out, " %-22s %s\n", it.domain, it.state) - } - default: - if it.skip { - fmt.Fprintf(out, " %-22s %s (missing secret: %s)\n", it.domain, colour(colYellow, "blocked"), strings.Join(it.missing, ", ")) - continue - } - switch it.state { - case dotfile.StateClean: - fmt.Fprintf(out, " %-22s %s (already in sync)\n", it.domain, colour(colGreen, "clean")) - case dotfile.StateMissing: - create++ - fmt.Fprintf(out, " %-22s %s\n", it.domain, colour(colYellow, "would create")) - case dotfile.StateRepoAhead: - update++ - fmt.Fprintf(out, " %-22s %s\n", it.domain, colour(colYellow, "would update")) - case dotfile.StateLocallyDrifted: - fmt.Fprintf(out, " %-22s %s (uncaptured local edits; run `ferry capture`)\n", it.domain, colour(colYellow, "would skip")) - case dotfile.StateConflict: - conflict++ - fmt.Fprintf(out, " %-22s %s (modified locally AND in the repo; `ferry capture` or `ferry apply --force`)\n", it.domain, colour(colRed, "conflict")) + case kindFile: + // Converged FileDomain rendering (fn-5): all file targets share the + // three-way state lines; the owning domain (it.fileDomain) selects the + // locally-drifted / conflict guidance that used to be keyed on the kind — + // dotfiles point at `ferry capture`, agents and config-file terminals are + // repo-authoritative (no capture pass). + switch it.fileDomain { + case "agents": + // Agents targets carry the domain's repo-authoritative guidance: a live + // edit is fixed in the repo copy (capture never ingests these in v1). + switch it.state { + case dotfile.StateClean: + fmt.Fprintf(out, " %-22s %s (already in sync)\n", it.domain, colour(colGreen, "clean")) + case dotfile.StateMissing: + create++ + fmt.Fprintf(out, " %-22s %s\n", it.domain, colour(colYellow, "would create")) + case dotfile.StateRepoAhead: + update++ + fmt.Fprintf(out, " %-22s %s\n", it.domain, colour(colYellow, "would update")) + case dotfile.StateLocallyDrifted: + fmt.Fprintf(out, " %-22s %s (edited live; repo-authoritative — update the repo copy, or `ferry apply --force`)\n", it.domain, colour(colYellow, "would skip")) + case dotfile.StateConflict: + conflict++ + fmt.Fprintf(out, " %-22s %s (edited live AND in the repo; update the repo copy, or `ferry apply --force`)\n", it.domain, colour(colRed, "conflict")) + default: + fmt.Fprintf(out, " %-22s %s\n", it.domain, it.state) + } + case "terminals", "keybindings", "emacs": + // A missing referenced secret blocks this terminal target (render-or-SKIP); + // surface it, exactly like a blocked dotfile, rather than a state line. + // (keybindings carries no secrets, so it.skip is always false there.) + if it.skip { + fmt.Fprintf(out, " %-22s %s (missing secret: %s)\n", it.domain, colour(colYellow, "blocked"), strings.Join(it.missing, ", ")) + continue + } + // Config-file terminal targets share the dotfile states but, unlike + // dotfiles, capture has NO config-file terminal pass, so a drift/conflict + // is NOT a capture candidate: the guidance points at updating the repo + // source or `ferry apply --force`, never `ferry capture`. + switch it.state { + case dotfile.StateClean: + fmt.Fprintf(out, " %-22s %s (already in sync)\n", it.domain, colour(colGreen, "clean")) + case dotfile.StateMissing: + create++ + fmt.Fprintf(out, " %-22s %s\n", it.domain, colour(colYellow, "would create")) + case dotfile.StateRepoAhead: + update++ + fmt.Fprintf(out, " %-22s %s\n", it.domain, colour(colYellow, "would update")) + case dotfile.StateLocallyDrifted: + fmt.Fprintf(out, " %-22s %s (local edits; update the repo source to match, or `ferry apply --force`)\n", it.domain, colour(colYellow, "would skip")) + case dotfile.StateConflict: + conflict++ + fmt.Fprintf(out, " %-22s %s (modified locally AND in the repo; update the repo source, or `ferry apply --force`)\n", it.domain, colour(colRed, "conflict")) + default: + fmt.Fprintf(out, " %-22s %s\n", it.domain, it.state) + } default: - fmt.Fprintf(out, " %-22s %s\n", it.domain, it.state) + if it.skip { + fmt.Fprintf(out, " %-22s %s (missing secret: %s)\n", it.domain, colour(colYellow, "blocked"), strings.Join(it.missing, ", ")) + continue + } + switch it.state { + case dotfile.StateClean: + fmt.Fprintf(out, " %-22s %s (already in sync)\n", it.domain, colour(colGreen, "clean")) + case dotfile.StateMissing: + create++ + fmt.Fprintf(out, " %-22s %s\n", it.domain, colour(colYellow, "would create")) + case dotfile.StateRepoAhead: + update++ + fmt.Fprintf(out, " %-22s %s\n", it.domain, colour(colYellow, "would update")) + case dotfile.StateLocallyDrifted: + fmt.Fprintf(out, " %-22s %s (uncaptured local edits; run `ferry capture`)\n", it.domain, colour(colYellow, "would skip")) + case dotfile.StateConflict: + conflict++ + fmt.Fprintf(out, " %-22s %s (modified locally AND in the repo; `ferry capture` or `ferry apply --force`)\n", it.domain, colour(colRed, "conflict")) + default: + fmt.Fprintf(out, " %-22s %s\n", it.domain, it.state) + } } } } @@ -1233,7 +1216,7 @@ func planSummary(create, update, conflict int) string { // mechanism). Only a StateClean dotfile/overlay is "nothing to do". func planItemPending(it planItem) bool { switch it.kind { - case kindDotfile, kindOverlay, kindAgents, kindTerminal: + case kindFile: if it.skip { return true } @@ -1310,6 +1293,27 @@ func descopeDotfileWarnings(ctx *cmdContext, store *dotfile.Store) []string { if strings.HasPrefix(name, termcfg.KeyPrefix) { continue } + // Key-bindings records are namespaced under "keybindings/" and report their + // OWN de-scope (descopeKeybindingsWarnings) — they are never declared + // dotfiles, so warning here would falsely report a still-managed + // key-bindings target as de-scoped on every apply. + if strings.HasPrefix(name, keybindings.KeyPrefix) { + continue + } + // Emacs-domain records are namespaced under "emacs/" and report their OWN + // de-scope (descopeEmacsConfigWarnings) — they are never declared dotfiles, + // so warning here would falsely report every still-managed Emacs target as + // de-scoped on every apply. + if strings.HasPrefix(name, emacs.KeyPrefix) { + continue + } + // iTerm2 Dynamic Profiles records are namespaced under "iterm2-profiles/" and + // report their OWN de-scope (descopeIterm2ProfilesWarnings) — they are never + // declared dotfiles, so warning here would falsely report every still-managed + // profile target as de-scoped on every apply. + if strings.HasPrefix(name, iterm2profiles.KeyPrefix) { + continue + } if inScope[strings.TrimPrefix(name, ".")] { continue } @@ -1338,7 +1342,7 @@ func descopeDotfileWarnings(ctx *cmdContext, store *dotfile.Store) []string { func isSidecarKey(name string) bool { bare := strings.TrimPrefix(name, ".") base := strings.TrimSuffix(bare, ".local") - return base != bare && isZsh(base) + return base != bare && usesIncludeSidecar(base) } // descopeTerminalWarnings warns about terminal preference DOMAINS ferry @@ -1349,7 +1353,9 @@ func isSidecarKey(name string) bool { // fully revert via `ferry restore `. func descopeTerminalWarnings(ctx *cmdContext, eng *backup.Engine) []string { var out []string - for _, domain := range []string{"iterm2", "terminal"} { + reg := buildRegistry(ctx) + for _, rd := range reg.ResourceDomains { + domain := rd.Name() if ctx.Scope.IsManaged(domain) { continue } @@ -1420,63 +1426,65 @@ func resolveDotfileSource(repo, name string) (string, bool) { } // resolveOverlaySource finds the repo-side .local overlay for a dotfile, under -// local//.local. zsh maps to the "zsh" domain dir. +// local//.local. The overlay directory is resolved through +// overlayDomainDir (zsh's bares map to local/zsh/, tmux.conf to local/tmux/, +// every other dotfile to its own bare name). func resolveOverlaySource(repo, bare string) (string, bool) { - domain := bare - if isZsh(bare) { - domain = "zsh" - } - cand := filepath.Join(repo, "local", domain, bare+".local") + cand := filepath.Join(repo, "local", overlayDomainDir(bare), bare+".local") if regularRepoFile(repo, cand) { return cand, true } return "", false } -func isZsh(bare string) bool { return bare == "zshrc" || bare == "zshenv" || bare == "zprofile" } - -// appendSourceDirective appends a real `source ~/` line so the overlay is -// sourced LAST (after the shared file's own content) and therefore wins. It is -// idempotent: if the directive is already present (uncommented), raw is returned -// unchanged so re-apply produces byte-identical content. -func appendSourceDirective(raw []byte, file string) []byte { - directive := "[ -f ~/" + file + " ] && source ~/" + file - if sourceDirectivePresent(raw, file) { +// appendSourceDirective appends spec's include line for `file` so the overlay is +// sourced LAST (after the shared file's own content) and therefore wins. spec is +// the per-format directive syntax (shell `source`, tmux `source-file`) selected +// by directiveSpecFor; every other byte of the injected block (the leading blank +// line, the marker comment, the trailing newline) is format-agnostic, so the zsh +// output is byte-identical to what ferry has always injected. It is idempotent: +// if the directive is already present (uncommented), raw is returned unchanged so +// re-apply produces byte-identical content. +func appendSourceDirective(raw []byte, file string, spec directiveSpec) []byte { + directive := spec.render(file) + if sourceDirectivePresent(raw, file, spec) { return raw } body := raw if len(body) > 0 && body[len(body)-1] != '\n' { body = append(append([]byte{}, body...), '\n') } - return append(body, []byte("\n# ferry: per-machine overlay, sourced last so it wins\n"+directive+"\n")...) + return append(body, []byte("\n"+spec.marker+"\n"+directive+"\n")...) } // stripSourceDirective removes ferry's managed include boilerplate for file (the -// `# ferry: per-machine overlay …` comment and the `[ -f ~/ ] && source -// ~/` line appendSourceDirective injects) from content, so capture's -// EFFECTIVE-content diff can route the user's real edits to shared WITHOUT -// committing ferry's own generated include line. It is the inverse of -// appendSourceDirective and is conservative: it drops only the exact managed -// marker comment and an uncommented source/`.`-include line naming file; any -// user-authored source line for a different target is left untouched. Used on -// the shared-capture write path for zsh so shared never gains ferry boilerplate. -func stripSourceDirective(content []byte, file string) []byte { - const marker = "# ferry: per-machine overlay, sourced last so it wins" +// marker comment and the format-specific include line appendSourceDirective +// injects) from content, so capture's EFFECTIVE-content diff can route the user's +// real edits to shared WITHOUT committing ferry's own generated include line. It +// is the FILE-KEYED inverse of appendSourceDirective (spec.namesFile recognises +// the include line naming this exact overlay file) and is conservative: it drops +// only the exact managed marker comment and an uncommented include line naming +// file; any user-authored include line for a different target is left untouched. +// Used on the shared-capture write path so shared never gains ferry boilerplate. +func stripSourceDirective(content []byte, file string, spec directiveSpec) []byte { lines := strings.Split(string(content), "\n") kept := make([]string, 0, len(lines)) for _, raw := range lines { trimmed := strings.TrimSpace(raw) - if trimmed == marker { + if trimmed == spec.marker { continue } - // Drop an uncommented include line (`source …file…` / `. …file…`) naming - // this overlay file — ferry's injected directive. A commented or unrelated - // line is kept. - if trimmed != "" && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, file) { - lc := strings.ToLower(trimmed) - if strings.Contains(lc, "source ") || strings.Contains(trimmed, ". ") { - continue + // Drop an uncommented include line (this format's `source`/`source-file`/ + // git `path`) naming this overlay file — ferry's injected directive. A + // commented or unrelated line is kept. For a MULTI-LINE directive (git's + // `[include]` block) also pop the preceding section-header line (spec.header) + // so no orphan `[include]` survives; single-line directives leave header "" + // and this pop never fires. + if trimmed != "" && !strings.HasPrefix(trimmed, "#") && spec.namesFile(trimmed, file) { + if spec.header != "" && len(kept) > 0 && strings.TrimSpace(kept[len(kept)-1]) == spec.header { + kept = kept[:len(kept)-1] } + continue } kept = append(kept, raw) } @@ -1492,8 +1500,8 @@ func stripSourceDirective(content []byte, file string) []byte { } // sourceDirectivePresent reports whether content already has an uncommented -// `source <…file…>` / `. <…file…>` directive for file. -func sourceDirectivePresent(content []byte, file string) bool { +// include directive (in spec's format) naming file. +func sourceDirectivePresent(content []byte, file string, spec directiveSpec) bool { for _, raw := range strings.Split(string(content), "\n") { line := strings.TrimSpace(raw) if line == "" || strings.HasPrefix(line, "#") { @@ -1503,11 +1511,7 @@ func sourceDirectivePresent(content []byte, file string) bool { if h := strings.IndexByte(code, '#'); h >= 0 { code = strings.TrimSpace(code[:h]) } - if !strings.Contains(code, file) { - continue - } - lc := strings.ToLower(code) - if strings.Contains(lc, "source ") || strings.Contains(code, ". ") { + if spec.namesFile(code, file) { return true } } @@ -1597,251 +1601,85 @@ func persistInstalledSet(installed []string) error { func isDarwin() bool { return platform.IsDarwin() } -// buildTerminalDomain constructs the native macOS preference domain for a -// terminal scope name, applying the documented LOCAL-WINS overlay rule: a -// per-machine local capture (local//.plist) takes WHOLE-DOMAIN -// precedence over the shared repo copy, exactly like a plist-domain dotfile -// ("machine-divergent settings go local wholesale"). Capture can route a terminal -// domain to local//.plist; without this, apply only ever read the -// shared repo path, so a local-routed terminal capture was DEAD (never -// materialised). Both branches now prefer the local overlay when present: -// -// - iTerm2: when local/iterm2/.plist exists, point PrefsCustomFolder at the -// LOCAL overlay folder (local/iterm2/) so iTerm2 loads the per-machine plist; -// otherwise the shared repo iterm2/ folder. iTerm2 reads -// com.googlecode.iterm2.plist from whichever folder it is pointed at, and -// capture writes the local plist under exactly that name. -// - Apple Terminal: import the LOCAL export blob (local/terminal/.plist) when -// present, else the shared committed blob, else nil (manage backup/restore only, -// Apply no-ops the import). +// buildTerminalDomain constructs the native macOS preference domain for a terminal +// scope name via the import-blob model both iTerm2 and Apple Terminal now use +// (v0.7.0 D4 retired iTerm2's custom-prefs-folder mechanism). It reads the committed +// export blob (terminalExportBlob, which applies the LOCAL-WINS overlay rule — +// local//.plist takes whole-domain precedence over the shared +// /.plist), renders its {{ferry.secret ...}} placeholders, and wraps the +// rendered bytes in a PreferenceDomain whose Apply `defaults import`s them: // -// Both share the production ExecRunner (which shells `defaults` via PATH). Callers -// gate on platform.IsDarwin(); the terminal package itself stays darwin-guarded. +// - Apple Terminal: NewAppleTerminal imports the rendered blob. +// - iTerm2: NewITerm2 imports the rendered ALLOWLIST-FILTERED global plist, +// REFUSING when iTerm2 is running (a running iTerm2 rewrites the domain on quit) +// and flushing cfprefsd after a successful import. // -// Secret render-or-SKIP parity with dotfiles: the bytes the domain would deploy are -// passed through the secret renderer BEFORE the domain is constructed: -// - Apple Terminal: the export blob's {{ferry.secret ...}} placeholders are -// rendered; the RENDERED bytes are what `defaults import` imports. A MISSING -// secret returns its refs in `missing` and a nil domain — the caller skips the -// import entirely, leaving live Apple Terminal config intact. -// - iTerm2: iTerm2 loads a FOLDER (PrefsCustomFolder). Rather than point it at the -// RAW repo folder (whose com.googlecode.iterm2.plist may contain unrendered -// {{ferry.secret ...}} placeholders), ferry RENDERS the chosen repo plist and -// STAGES the rendered copy into a ferry-owned staging folder, then points -// PrefsCustomFolder at THAT folder — so iTerm2 always loads the rendered plist, -// never the raw placeholder one. The leaf plist is safeRepoPath-validated: a -// REFUSED leaf (symlink/escape/under ~/.ssh) returns err so the caller SKIPS the -// domain (the refusal is honoured for the very file iTerm2 would load, not -// swallowed). A MISSING referenced secret returns its refs in `missing` and a nil -// domain — caller skips, live iTerm2 config intact. The returned iterm2Stage -// carries the rendered bytes + staging plist path the mutating path materialises -// via the backup engine before Apply. +// Both share the production ExecRunner (which shells `defaults` via PATH); iTerm2 +// also gets the production ExecProcessController (pgrep/killall). Callers gate on +// platform.IsDarwin(); the terminal package itself stays darwin-guarded. // -// Returns (domain, stage, missing, err): a non-empty `missing` means SKIP this domain -// with a reported notice; err is the symlink/escape REFUSAL (also a skip). stage is -// meaningful only for iterm2 (the rendered plist to materialise). -func buildTerminalDomain(repo, scopeName string, store *secret.Store) (*terminal.PreferenceDomain, iterm2Stage, error) { - switch scopeName { - case "iterm2": - folder, err := iterm2PrefsFolder(repo) - if err != nil { - return nil, iterm2Stage{}, err - } - stage, missing, err := iterm2RenderStage(repo, folder, store) - if err != nil { - // REFUSED leaf plist (symlink/escape/under ~/.ssh): skip the iterm2 domain. - // Returning err here means iTerm2 is NEVER pointed at the staging folder for - // a poisoned leaf — the refusal is honoured, not swallowed. - return nil, iterm2Stage{}, err - } - if len(missing) > 0 { - return nil, iterm2Stage{Missing: missing}, nil - } - return terminal.NewITerm2(stage.folder, terminal.ExecRunner{}), stage, nil - default: // "terminal" / Apple Terminal - blob, err := appleTerminalExportBlob(repo) - if err != nil { - // PRESENT-but-REFUSED local overlay (symlink/escape/under ~/.ssh): skip the - // Apple Terminal domain with the refusal rather than importing shared. - return nil, iterm2Stage{}, err - } - rendered, missing, skip, err := renderSecrets(store, blob) - if err != nil { - return nil, iterm2Stage{}, err - } - if skip { - return nil, iterm2Stage{Missing: missing}, nil - } - return terminal.NewAppleTerminal(rendered, terminal.ExecRunner{}), iterm2Stage{}, nil - } -} - -// iterm2Stage carries the RENDERED STAGING result for the iTerm2 domain: the -// ferry-owned staging folder PrefsCustomFolder points at, the absolute staging -// plist path the mutating apply writes, and the rendered bytes to write there. -// Missing carries the unresolved secret refs when rendering must SKIP the domain. -type iterm2Stage struct { - folder string // staging folder -> PrefsCustomFolder - plistPath string // /com.googlecode.iterm2.plist (absolute) - content []byte // rendered plist bytes to materialise - Missing []string // unresolved secret refs (skip the domain when non-empty) -} - -// iterm2RenderStage validates + renders the iTerm2 leaf plist the chosen prefs -// folder would load (com.googlecode.iterm2.plist) and computes the ferry-owned -// RENDERED STAGING destination PrefsCustomFolder will point at instead of the raw -// repo folder. +// Secret render-or-SKIP parity with dotfiles: a MISSING referenced secret returns its +// refs in `missing` and a nil domain, so the caller SKIPS the whole domain rather +// than `defaults import` an unrendered {{ferry.secret ...}} placeholder into it. // -// - The leaf plist is safeRepoPath-validated. A REFUSED leaf (symlink / repo -// escape / resolving under ~/.ssh or a system path) returns the refusal ERROR so -// the caller SKIPS the whole iterm2 domain — iTerm2 is never pointed anywhere for -// a poisoned leaf (fixes "refused plist swallowed": the refusal governs the very -// file iTerm2 loads, not just the containing folder). -// - The plist is read and secret-rendered. A MISSING referenced secret returns its -// refs in `missing` (the caller skips; live iTerm2 config left intact). -// - On success (all referenced secrets present, or no placeholders) the rendered -// bytes + staging destination are returned. The mutating path writes them into -// the staging folder via the backup engine (tracked + reversible) before pointing -// PrefsCustomFolder there. We ALWAYS stage (even a placeholder-free plist that -// renders to itself) so the path is uniform and the leaf is always validated + -// rendered. -// -// The staging folder is /rendered/iterm2 (created 0700 by the mutating -// path; the plist written 0600). A missing leaf plist (fresh repo folder, nothing to -// load) yields empty content and no staging — there is nothing for iTerm2 to render. -func iterm2RenderStage(repo, folder string, store *secret.Store) (iterm2Stage, []string, error) { - plist := filepath.Join(folder, terminal.ITerm2Domain+".plist") - safe, err := safeRepoPath(repo, plist) +// Returns (domain, missing, err): a non-empty `missing` means SKIP this domain with a +// reported notice; err is the symlink/escape REFUSAL of the repo blob (also a skip). +func buildTerminalDomain(repo, scopeName string, store *secret.Store) (*terminal.PreferenceDomain, []string, error) { + prefID, ok := terminalPrefDomainID(scopeName) + if !ok { + return nil, nil, fmt.Errorf("terminal: unknown preference domain %q", scopeName) + } + // Read the committed export blob apply imports (local overlay wins). A + // PRESENT-but-REFUSED overlay (symlink/escape/under ~/.ssh) returns the refusal + // so the caller SKIPS the domain rather than importing shared behind it. + blob, err := terminalExportBlob(repo, scopeName, prefID) if err != nil { - // REFUSED leaf: surface the error so the caller skips the domain. - return iterm2Stage{}, nil, err + return nil, nil, err } - data, err := os.ReadFile(safe) - if err != nil { - if os.IsNotExist(err) { - // GENUINELY ABSENT leaf plist (fresh folder) — nothing for iTerm2 to load, - // nothing to stage. Point at the (empty) staging folder so the path is still - // uniform and never the raw repo folder; no plist is written. - stateDir, serr := paths.StateDir() - if serr != nil { - return iterm2Stage{}, nil, serr - } - return iterm2Stage{folder: filepath.Join(stateDir, "rendered", "iterm2")}, nil, nil - } - // PRESENT-but-UNREADABLE leaf (permission / I/O / any non-NotExist error): - // FAIL-CLOSED. A present plist that cannot be read must NEVER be downgraded to - // "absent" and then mutate live state by pointing PrefsCustomFolder at an empty - // staging folder. Surface the error so the caller SKIPS the iterm2 domain with a - // warning, leaving live iTerm2 state intact. - return iterm2Stage{}, nil, fmt.Errorf("iterm2: read leaf plist %s: %w", plist, err) - } - rendered, missing, skip, err := renderSecrets(store, data) + // Secret render-or-SKIP parity with dotfiles: a MISSING referenced secret returns + // its refs so the caller skips the whole domain (never `defaults import` an + // unrendered {{ferry.secret}} placeholder into the live domain). + rendered, missing, skip, err := renderSecrets(store, blob) if err != nil { - return iterm2Stage{}, nil, err + return nil, nil, err } if skip { - return iterm2Stage{}, missing, nil + return nil, missing, nil + } + switch scopeName { + case "iterm2": + // Import-blob model (v0.7.0 D4): iTerm2 no longer uses a custom-prefs folder. + // Apply imports the rendered global plist via `defaults import`, REFUSING when + // iTerm2 is running and flushing cfprefsd afterwards (see terminal.NewITerm2). + return terminal.NewITerm2(rendered, terminal.ExecRunner{}, terminal.ExecProcessController{}), nil, nil + default: // "terminal" / Apple Terminal + return terminal.NewAppleTerminal(rendered, terminal.ExecRunner{}), nil, nil } - stateDir, err := paths.StateDir() - if err != nil { - return iterm2Stage{}, nil, err - } - stageFolder := filepath.Join(stateDir, "rendered", "iterm2") - return iterm2Stage{ - folder: stageFolder, - plistPath: filepath.Join(stageFolder, terminal.ITerm2Domain+".plist"), - content: rendered, - }, nil, nil -} - -// iterm2PrefsFolder picks the prefs folder iTerm2 is pointed at: the per-machine -// LOCAL overlay folder local/iterm2/ when a local plist exists there (local wins -// whole-domain), else the shared repo iterm2/ folder. iTerm2 loads -// com.googlecode.iterm2.plist from PrefsCustomFolder, and capture's local route -// writes local/iterm2/.plist under that same id, so pointing iTerm2 at the -// local folder deploys the captured per-machine plist. -// -// The CHOSEN folder (local OR shared) is safeRepoPath-validated BEFORE it is -// returned to be handed to `defaults write ... PrefsCustomFolder`. A repo-side -// `iterm2 -> ~/.ssh` (or a folder escaping the repo / resolving under a system -// location) must NEVER be persisted as iTerm2's prefs folder — that would bypass -// the repo-side symlink policy for a managed terminal domain. -// -// Local overlay PRESENCE is decided GUARD-FIRST: safeRepoPath(repo, localPlist) -// runs BEFORE any os.Lstat of the leaf, then presence is Lstat'd on the SAFE path -// safeRepoPath returns. This closes a symlinked-PARENT bypass: a raw -// os.Lstat(/local/iterm2/.plist) does NOT follow the final leaf but DOES -// traverse a symlinked PARENT (e.g. `local/iterm2 -> ~/.ssh`), touching outside the -// repo BEFORE the guard runs. safeRepoPath walks component-by-component from the -// repo root and never traverses a symlinked parent, so a poisoned parent (or a -// symlinked/escaping leaf) is REFUSED before any leaf stat. A PRESENT-but-REFUSED -// local overlay returns the refusal error so the caller SKIPS the iterm2 domain — -// it must NOT silently fall back to the shared settings (that would let a malicious -// local overlay mask its own refusal). Only an ABSENT local leaf (the safe path -// Lstats as not-exist) legitimately falls back to shared, whose own leaf/folder is -// then likewise validated; on refusal of the shared folder the error is returned so -// the domain is skipped rather than writing a poisoned PrefsCustomFolder. -func iterm2PrefsFolder(repo string) (string, error) { - localFolder := filepath.Join(repo, "local", "iterm2") - localPlist := filepath.Join(localFolder, terminal.ITerm2Domain+".plist") - // GUARD FIRST: validate the leaf (component-by-component from the repo root, - // never traversing a symlinked parent) BEFORE we stat it. A refusal here — - // symlinked parent, symlinked/escaping leaf, or a target under ~/.ssh — SKIPS the - // domain; we must NOT fall back to shared behind a present, poisoned overlay. - safeLocalPlist, err := safeRepoPath(repo, localPlist) - if err != nil { - return "", err - } - // Presence is decided on the SAFE path WITHOUT following the final link, so a - // symlinked leaf (already cleared by safeRepoPath as in-repo) still counts as - // PRESENT and governs the domain. - if li, lerr := os.Lstat(safeLocalPlist); lerr == nil { - // Local overlay leaf EXISTS. It governs the domain. Validate the chosen prefs - // folder too; a refusal SKIPS the domain rather than falling back to shared. - if !li.Mode().IsRegular() { - return "", fmt.Errorf("iterm2: local overlay leaf %s is not a regular file", localPlist) - } - if _, err := safeRepoPath(repo, localFolder); err != nil { - return "", err - } - return localFolder, nil - } else if !os.IsNotExist(lerr) { - // A present-but-unstatable safe leaf is not a clean absence; skip the domain - // rather than silently reaching for shared. - return "", fmt.Errorf("iterm2: stat local overlay %s: %w", localPlist, lerr) - } - // ABSENT local leaf: legitimately fall back to shared. VALIDATE before use — a - // symlinked/escaping /iterm2 is refused so `defaults write - // PrefsCustomFolder` never persists it. - sharedFolder := filepath.Join(repo, "iterm2") - if _, err := safeRepoPath(repo, sharedFolder); err != nil { - return "", err - } - return sharedFolder, nil } -// appleTerminalExportBlob reads the `defaults export com.apple.Terminal` blob apply -// should import, applying local-wins: the per-machine LOCAL overlay -// (local/terminal/.plist) takes precedence over the shared committed copy when -// present, so a local-routed Apple Terminal capture is actually re-imported on this -// machine. Returns (nil, nil) when neither exists (Apply then manages backup/restore +// terminalExportBlob reads the `defaults export ` blob apply should import +// for a terminal preference domain, applying local-wins: the per-machine LOCAL +// overlay (local//.plist) takes precedence over the shared committed +// copy (/.plist) when present, so a local-routed capture is actually +// re-imported on this machine. Both iTerm2 (D4 import-blob model) and Apple Terminal +// use it. Returns (nil, nil) when neither exists (Apply then manages backup/restore // only). Probes the conventional committed locations under each tree. // // Local-overlay probing is GUARD-FIRST: safeRepoPath(repo, cand) runs BEFORE any // os.Lstat of the candidate, then presence is Lstat'd on the SAFE path. A raw // os.Lstat of a local candidate does NOT follow the final leaf but DOES traverse a -// symlinked PARENT (e.g. `local/terminal -> `), touching outside the +// symlinked PARENT (e.g. `local/ -> `), touching outside the // repo BEFORE the guard runs; the guard-first walk never traverses a symlinked // parent. A LOCAL candidate that is PRESENT (the safe path Lstats) but REFUSED by // safeRepoPath (symlinked parent/leaf, escape, or under ~/.ssh) returns a non-nil -// ERROR so buildTerminalDomain SKIPS the Apple Terminal domain with a refusal -// notice, rather than masking the poisoned overlay by importing the shared blob. -// Only an ABSENT local candidate falls through to the shared committed export, -// whose own candidate is then validated before the symlink-following read. -func appleTerminalExportBlob(repo string) ([]byte, error) { +// ERROR so buildTerminalDomain SKIPS the domain with a refusal notice, rather than +// masking the poisoned overlay by importing the shared blob. Only an ABSENT local +// candidate falls through to the shared committed export, whose own candidate is +// then validated before the symlink-following read. +func terminalExportBlob(repo, domain, prefID string) ([]byte, error) { localCands := []string{ - filepath.Join(repo, "local", "terminal", terminal.AppleTerminalDomain+".plist"), - filepath.Join(repo, "local", "terminal", terminal.AppleTerminalDomain), + filepath.Join(repo, "local", domain, prefID+".plist"), + filepath.Join(repo, "local", domain, prefID), } for _, cand := range localCands { // GUARD FIRST: a refusal (symlinked parent/leaf, escape, under ~/.ssh) SKIPS @@ -1872,8 +1710,8 @@ func appleTerminalExportBlob(repo string) ([]byte, error) { } // No local overlay present — use the shared committed export. for _, cand := range []string{ - filepath.Join(repo, "terminal", terminal.AppleTerminalDomain+".plist"), - filepath.Join(repo, "terminal", terminal.AppleTerminalDomain), + filepath.Join(repo, domain, prefID+".plist"), + filepath.Join(repo, domain, prefID), } { // Refuse a symlinked/escaping plist before the symlink-following read. safe, err := safeRepoPath(repo, cand) diff --git a/cmd/apply_secret_perm_test.go b/cmd/apply_secret_perm_test.go index 138e6b3..d4cf852 100644 --- a/cmd/apply_secret_perm_test.go +++ b/cmd/apply_secret_perm_test.go @@ -43,8 +43,8 @@ func TestApplyTargetSecretRoutedWrites0600(t *testing.T) { content: []byte("[github]\n\ttoken = super-secret-rendered-value\n"), secretRouted: true, } - if _, err := applyTarget(secretItem, store, b, false); err != nil { - t.Fatalf("applyTarget (secret): %v", err) + if _, err := dotfile.ApplyContentDeferred(secretItem.target, secretItem.content, dotfile.DefaultPerm(), store, b, false, secretItem.secretRouted); err != nil { + t.Fatalf("ApplyContentDeferred (secret): %v", err) } fi, err := os.Stat(secretTarget.Home) if err != nil { @@ -59,8 +59,8 @@ func TestApplyTargetSecretRoutedWrites0600(t *testing.T) { content: []byte("set number\n"), secretRouted: false, } - if _, err := applyTarget(plainItem, store, b, false); err != nil { - t.Fatalf("applyTarget (plain): %v", err) + if _, err := dotfile.ApplyContentDeferred(plainItem.target, plainItem.content, dotfile.DefaultPerm(), store, b, false, plainItem.secretRouted); err != nil { + t.Fatalf("ApplyContentDeferred (plain): %v", err) } fi, err = os.Stat(plainTarget.Home) if err != nil { diff --git a/cmd/apply_secret_stage_test.go b/cmd/apply_secret_stage_test.go index b098661..3d6ce99 100644 --- a/cmd/apply_secret_stage_test.go +++ b/cmd/apply_secret_stage_test.go @@ -1,12 +1,13 @@ package cmd -// WS4(a): applyTarget must deploy an include-style (non-whole-file) dotfile -// through the IN-MEMORY apply core, never staging its effective bytes to a +// WS4(a): the shared apply core (dotfile.ApplyContentDeferred) — the one the +// converged kindFile arm calls — must deploy an include-style (non-whole-file) +// dotfile through the IN-MEMORY path, never staging its effective bytes to a // $TMPDIR temp file. For a secret-routed dotfile those bytes are ALREADY-RENDERED // plaintext, so a crash between staging and cleanup would leave the secret at // rest in /tmp. The test points $TMPDIR at an empty dir and inspects it at the // moment of the deploy write (inside the Backuper), when the pre-fix stageContent -// temp would still exist (its cleanup is deferred until applyTarget returns). +// temp would still exist (its cleanup is deferred until the apply call returns). import ( "os" @@ -24,8 +25,8 @@ func TestApplyTargetSecretRoutedStagesNoTempFile(t *testing.T) { home := t.TempDir() target := dotfile.Target{Name: "gitconfig", Home: filepath.Join(home, ".gitconfig")} - // Overlay is the zero value (NOT OverlayWholeFileReplace), so applyTarget takes - // the in-memory ApplyContentDeferred branch — the one WS4(a) fixed. + // Overlay is the zero value (NOT OverlayWholeFileReplace); ApplyContentDeferred + // takes the in-memory branch — the one WS4(a) fixed. plaintext := []byte("[user]\n\ttoken = super-secret-rendered-value\n") it := &planItem{target: target, content: plaintext, secretRouted: true} @@ -50,16 +51,16 @@ func TestApplyTargetSecretRoutedStagesNoTempFile(t *testing.T) { t.Fatalf("open store: %v", err) } - res, err := applyTarget(it, store, b, false) + res, err := dotfile.ApplyContentDeferred(it.target, it.content, dotfile.DefaultPerm(), store, b, false, it.secretRouted) if err != nil { - t.Fatalf("applyTarget: %v", err) + t.Fatalf("ApplyContentDeferred: %v", err) } if res.Action != dotfile.ActionCreated { t.Fatalf("Action = %q, want created (fresh missing target should be written)", res.Action) } if len(tmpEntriesAtWrite) != 0 { - t.Fatalf("applyTarget staged %d file(s) in $TMPDIR during a secret-routed deploy: %v; "+ + t.Fatalf("the apply core staged %d file(s) in $TMPDIR during a secret-routed deploy: %v; "+ "the rendered plaintext must never touch /tmp", len(tmpEntriesAtWrite), tmpEntriesAtWrite) } diff --git a/cmd/apply_wholefile_secret_stage_test.go b/cmd/apply_wholefile_secret_stage_test.go index 6191182..d89b134 100644 --- a/cmd/apply_wholefile_secret_stage_test.go +++ b/cmd/apply_wholefile_secret_stage_test.go @@ -1,14 +1,15 @@ package cmd -// WS4(a): applyTarget must deploy a WHOLE-FILE-REPLACE dotfile (e.g. .gitconfig) -// through the IN-MEMORY apply core too, never staging its effective bytes to a -// $TMPDIR temp file. A generic dotfile defaults to OverlayWholeFileReplace and is -// arguably the most likely to carry a {{ferry.secret}} token (a GitHub token in -// .gitconfig), so for a secret-routed target those bytes are ALREADY-RENDERED +// WS4(a): the shared apply core (dotfile.ApplyContentDeferred) — the one the +// converged kindFile arm calls — must deploy a WHOLE-FILE-REPLACE dotfile +// (e.g. .gitconfig) through the IN-MEMORY path too, never staging its effective +// bytes to a $TMPDIR temp file. A generic dotfile defaults to OverlayWholeFileReplace +// and is arguably the most likely to carry a {{ferry.secret}} token (a GitHub token +// in .gitconfig), so for a secret-routed target those bytes are ALREADY-RENDERED // plaintext; a crash between staging and cleanup would leave the secret at rest in // /tmp. The test points $TMPDIR at an empty dir and inspects it at the moment of // the deploy write (inside the Backuper), when the pre-fix stageContent temp would -// still exist (its cleanup was deferred until applyTarget returned). +// still exist (its cleanup was deferred until the apply call returned). import ( "os" @@ -54,16 +55,16 @@ func TestApplyTargetWholeFileSecretRoutedStagesNoTempFile(t *testing.T) { t.Fatalf("open store: %v", err) } - res, err := applyTarget(it, store, b, false) + res, err := dotfile.ApplyContentDeferred(it.target, it.content, dotfile.DefaultPerm(), store, b, false, it.secretRouted) if err != nil { - t.Fatalf("applyTarget: %v", err) + t.Fatalf("ApplyContentDeferred: %v", err) } if res.Action != dotfile.ActionCreated { t.Fatalf("Action = %q, want created (fresh missing target should be written)", res.Action) } if len(tmpEntriesAtWrite) != 0 { - t.Fatalf("applyTarget staged %d file(s) in $TMPDIR during a whole-file secret-routed deploy: %v; "+ + t.Fatalf("the apply core staged %d file(s) in $TMPDIR during a whole-file secret-routed deploy: %v; "+ "the rendered plaintext must never touch /tmp", len(tmpEntriesAtWrite), tmpEntriesAtWrite) } diff --git a/cmd/capture.go b/cmd/capture.go index 0c70451..51d8d19 100644 --- a/cmd/capture.go +++ b/cmd/capture.go @@ -64,6 +64,13 @@ func runCapture(c *cobra.Command, _ []string) error { return fmt.Errorf("open secret store: %w", err) } + // Converged registry (fn-5): capture drives its per-domain passes off the + // registry — the terminal preference pass enumerates ResourceDomains (no more + // {"iterm2","terminal"} literal), and the dotfile/agents passes run only when + // their FileDomain Captures(). termcfg's Captures() is false, which is why + // there is no config-file terminal capture pass (the deliberate asymmetry). + reg := buildRegistry(ctx) + captured := 0 offered := 0 @@ -81,7 +88,11 @@ func runCapture(c *cobra.Command, _ []string) error { // enumerated, read, or special-cased. Skipped wholesale when dotfiles are // unmanaged (no declarations), but that NO LONGER short-circuits the terminal / // deps passes below. - for _, name := range declaredDotfilesIfManaged(ctx) { + var dotfileCandidates []string + if fileDomainCaptures(reg, "dotfiles") { + dotfileCandidates = declaredDotfilesIfManaged(ctx) + } + for _, name := range dotfileCandidates { // SECURITY BOUNDARY: TargetFor refuses ~/.ssh + path-traversal names. A // refused dotfile is SKIPPED with a clear message and never read/captured — // so a manifest declaring `.ssh/config` never reads or ingests ~/.ssh. @@ -124,7 +135,7 @@ func runCapture(c *cobra.Command, _ []string) error { // capture it back. Gated on a repo overlay existing (apply materialises the // sidecar only when local/zsh/.local is present). Runs INDEPENDENTLY // of the shared file's drift state. - if isZsh(strings.TrimPrefix(name, ".")) { + if usesIncludeSidecar(strings.TrimPrefix(name, ".")) { wroteSide, offeredSide, err := captureZshSidecar(captureCtx{ out: out, errOut: errOut, @@ -154,7 +165,7 @@ func runCapture(c *cobra.Command, _ []string) error { // refused with a diff — never auto-merged. New agent-shaped files under a // tracked mapping's target dir are offered for adoption. A SourceCombined // render cannot be decomposed, so its drift is reported, not captured. - if ctx.Scope.IsManaged("agents") { + if fileDomainCaptures(reg, "agents") && ctx.Scope.IsManaged("agents") { wroteAgents, offeredAgents, aerr := captureAgents(ctx, home, in, out, lastApplied) if aerr != nil { return aerr @@ -172,7 +183,8 @@ func runCapture(c *cobra.Command, _ []string) error { // shared/local/reject, secret-gated as a whole value. macOS-only: a clean no-op // on linux (platform-guarded; internal/terminal builds clean there). if platform.IsDarwin() { - for _, domain := range []string{"iterm2", "terminal"} { + for _, rd := range reg.ResourceDomains { + domain := rd.Name() if !ctx.Scope.IsManaged(domain) { continue } @@ -208,6 +220,14 @@ func runCapture(c *cobra.Command, _ []string) error { captured++ } + // --- npm globals re-dump pass: an INDEPENDENT capture step ORTHOGONAL to the + // brew/apt re-dump above (a machine can carry both). Gated on the npm-globals + // domain being in scope AND npm being present; a missing npm is a clean skip. + if reDumpNpmGlobals(ctx, out) { + offered++ + captured++ + } + if offered == 0 { fmt.Fprintln(out, "capture: nothing has drifted; repo already matches this machine") return nil @@ -288,18 +308,19 @@ func (cc captureCtx) captureErrOut() io.Writer { // only reject / out-of-repo secret store are offered. func captureOne(cc captureCtx) (bool, error) { bare := strings.TrimPrefix(cc.name, ".") - zsh := isZsh(bare) + zsh := usesIncludeSidecar(bare) + extractSpans := captureSpanExtractor(bare) fmt.Fprintf(cc.out, "\n=== %s (drifted) ===\n", "."+bare) // --- MANDATORY secret gate, BEFORE any write. --- // Scan the reviewable content; a high-confidence secret blocks every repo - // route. For a placeholder-aware capture cc.liveBytes is the - // REVERSE-RENDERED live content: stored secrets sit behind their intact - // placeholders and never reach the gate — only a genuinely NEW secret - // trips it, and its consent choice comes AFTER the hunk review (the - // span-grained blocked path, r6-M1). In the missing-ref fallback a block - // is READ-ONLY (r9-M1: no whole-file store escape for a - // placeholder-bearing source). + // route. For a span-routable candidate (a placeholder-aware source, or tmux + // with its column-grained recogniser) cc.liveBytes carries the NEW secret + // only — stored secrets sit behind their intact placeholders and never reach + // the gate — and its consent choice comes AFTER the hunk review (the + // span-grained blocked path, r6-M1). A non-span-routable candidate falls back + // to the whole-file block. In the missing-ref fallback a block is READ-ONLY + // (r9-M1: no whole-file store escape for a placeholder-bearing source). gate := secret.GateText(string(cc.liveBytes)) var hunkMasks []maskPair if gate.BlockedFromRepo { @@ -307,7 +328,7 @@ func captureOne(cc captureCtx) (bool, error) { reportReadOnlyBlock(cc.out, "."+bare) return false, nil } - if !cc.placeholderAware { + if !spanRoutable(bare, cc.placeholderAware) { return captureBlocked(cc, bare) } // Falling through to the hunk review with a gated NEW secret in the @@ -349,15 +370,16 @@ func captureOne(cc captureCtx) (bool, error) { // Re-scan the COMPOSED content too: an accepted hunk might carry a secret even // if the whole-file scan was driven differently. Never write a secret. if secret.IsBlockedFromRepo(captured) { - if !cc.placeholderAware { + if !spanRoutable(bare, cc.placeholderAware) { fmt.Fprintf(cc.out, " %s: accepted change contains secret/credential material — blocked from the repo; handled out-of-band only\n", "."+bare) return false, nil } - // Placeholder-bearing source + a NEW secret in the accepted change: - // the span-grained consent path (r6-M1) — store ONLY the new span(s), - // patch only those spans, preserve the curated remainder. Never the - // whole-file escape (it would overwrite the curated source). - patched, refs, ok, cerr := consentSpanStoreRoute(cc.in, cc.out, cc.secretStore, "."+bare, bare, captured) + // Span-routable source + a NEW secret in the accepted change: the + // span-grained consent path (r6-M1) — store ONLY the new span(s), patch + // only those spans (a tmux quoted option value is patched column-grained, + // preserving the `set -g @token '…'` syntax), preserve the curated + // remainder. Never the whole-file escape (it would overwrite the source). + patched, refs, ok, cerr := consentSpanStoreRoute(cc.in, cc.out, cc.secretStore, "."+bare, bare, captured, extractSpans) if cerr != nil { return false, cerr } @@ -384,8 +406,13 @@ func captureOne(cc captureCtx) (bool, error) { // include line (apply re-appends it on deploy). Non-zsh dotfiles are unchanged. sharedOut := []byte(captured) if zsh { - sharedOut = stripSourceDirective(sharedOut, "."+bare+".local") + sharedOut = stripSourceDirective(sharedOut, "."+bare+".local", directiveSpecFor(bare)) } + // git identity firewall on the shared-capture WRITE: even if the user + // accepted an identity hunk and routed it [s]hared, strip every identity key + // + [includeIf …] block so a machine's commit identity can never be written + // into the shared repo (the STOP condition). A no-op for every other dotfile. + sharedOut = sharedGitTransform(bare, sharedOut) // Write the captured content to every existing shared copy of this dotfile // (canonical dotfiles/, dotted dotfiles/., top-level .) so // the committed shared source is consistent regardless of repo layout, plus @@ -416,7 +443,16 @@ func captureOne(cc captureCtx) (bool, error) { // deployed content IS the raw repo file). if zsh || cc.placeholderAware { staged := []byte(captured) - if cc.placeholderAware { + // Record the hash of the RENDERED-EFFECTIVE bytes — the same bytes apply + // deploys — whenever the captured composition carries a placeholder: + // either from a placeholder-aware source (r4-M2) OR from a span-store + // consent that just patched a NEW secret to a placeholder (spanPatched, + // e.g. a tmux `set -g @token '…'` value). Without rendering here, + // last-applied would hold the un-rendered placeholder hash while the + // deploy writes the rendered value, so status would show spurious drift. + // renderForLastApplied no-ops on content without placeholders, so the + // common zsh path (no secrets) is byte-for-byte unchanged. + if cc.placeholderAware || spanPatched { staged = renderForLastApplied(cc.secretStore, staged) } if err := recordEffectiveLastApplied(cc.target, cc.lastApplied, staged); err != nil { @@ -652,7 +688,7 @@ func captureSharedDotfile(cc captureCtx) (wrote bool, offered bool, err error) { // apply/status compute so only GENUINE user edits (beyond the managed include // line) are offered. Non-zsh dotfiles use the raw source unchanged. bare := strings.TrimPrefix(cc.name, ".") - if isZsh(bare) && !firstCapture { + if usesIncludeSidecar(bare) && !firstCapture { _, hasOverlay := resolveOverlaySource(cc.repoPath, bare) repoBytes = effectiveZshShared(repoBytes, bare, hasOverlay) } @@ -745,6 +781,7 @@ func captureSharedDotfile(cc captureCtx) (wrote bool, offered bool, err error) { // presented (so the caller counts it even on a reject). func captureZshSidecar(cc captureCtx, home string) (wrote bool, offered bool, err error) { bare := strings.TrimPrefix(cc.name, ".") + extractSpans := captureSpanExtractor(bare) overlaySrc := localOverlayPath(cc.repoPath, bare) // local/zsh/.local // Guard the overlay path (read below AND written by the sidecar capture routes) @@ -822,12 +859,12 @@ func captureZshSidecar(cc captureCtx, home string) (wrote bool, offered bool, er reportReadOnlyBlock(cc.out, "."+bare+".local") return false, true, nil } - if !placeholderAware { + if !spanRoutable(bare, placeholderAware) { w, berr := captureBlockedSidecar(cc, bare, overlaySrc, liveBytes) return w, true, berr } - // Gated NEW secret on the placeholder-aware sidecar leg: mask every - // flagged span value in the hunk output (never print the raw value). + // Gated NEW secret on a span-routable sidecar leg: mask every flagged + // span value in the hunk output (never print the raw value). hunkMasks = captureSecretMasks(string(reviewLive), bare+".local") } @@ -860,13 +897,13 @@ func captureZshSidecar(cc captureCtx, home string) (wrote bool, offered bool, er var sidecarStoredRefs []string // refs Put by the span consent (for honest refusal notices) // Re-scan the composed result: never write secret material to the overlay. if secret.IsBlockedFromRepo(composed) { - if !placeholderAware { + if !spanRoutable(bare, placeholderAware) { fmt.Fprintf(cc.out, " %s: accepted change contains secret/credential material — blocked from the repo; handled out-of-band only\n", "."+bare+".local") return false, true, nil } - // Span-grained consent for a NEW secret on the placeholder-bearing - // sidecar leg (r6-M1) — the whole-file escape is never taken here. - patched, refs, ok, cerr := consentSpanStoreRoute(cc.in, cc.out, cc.secretStore, "."+bare+".local", bare+".local", composed) + // Span-grained consent for a NEW secret on a span-routable sidecar leg + // (r6-M1) — the whole-file escape is never taken here. + patched, refs, ok, cerr := consentSpanStoreRoute(cc.in, cc.out, cc.secretStore, "."+bare+".local", bare+".local", composed, extractSpans) if cerr != nil { return false, true, cerr } @@ -987,7 +1024,7 @@ func captureTerminalDomain(cc captureCtx, domain string) (wrote bool, offered bo var d *terminal.PreferenceDomain switch domain { case "iterm2": - d = terminal.NewITerm2(filepath.Join(cc.repoPath, "iterm2"), terminal.ExecRunner{}) + d = terminal.NewITerm2(nil, terminal.ExecRunner{}, terminal.ExecProcessController{}) default: // "terminal" / Apple Terminal d = terminal.NewAppleTerminal(nil, terminal.ExecRunner{}) } @@ -1006,21 +1043,19 @@ func captureTerminalDomain(cc captureCtx, domain string) (wrote bool, offered bo return false, false, nil } - // iTerm2 ONLY: STRIP the machine-local control keys (PrefsCustomFolder / - // LoadPrefsFromCustomFolder) from the live export before it is compared, gated, or - // written. Those keys are how ferry POINTS iTerm2 at the repo (apply sets them via - // `defaults write`), NOT user preferences — `defaults export com.googlecode.iterm2` - // can carry them, but they must NEVER be captured into the repo or local overlay. - // Stripping here makes every downstream route (diff, gate, shared/local/secret - // write) operate on the pointer-free content, so the captured bytes are like-for- - // like with what apply deploys. The exact custom-folder-vs-export round-trip - // fidelity is Layer-2-deferred per AC-terminal-config (see StripITerm2ControlKeys). + // iTerm2 ONLY: reduce the live export to the ALLOWLISTED global keys before it is + // compared, gated, or written. `defaults export com.googlecode.iterm2` carries + // volatile machine state — window geometry, NoSync* one-shot flags, the retired + // custom-prefs-folder pointer — that must NEVER reach the repo; FilterAllowlist + // keeps ONLY the curated global-behaviour keys (see terminal.FilterAllowlist), so + // every downstream route (diff, gate, shared/local/secret write) operates on the + // stable, reviewable subset that is like-for-like with what apply imports. if domain == "iterm2" { - liveBlob = terminal.StripITerm2ControlKeys(liveBlob) + liveBlob = terminal.FilterAllowlist(liveBlob) } // Repo path apply READS for this domain's plist (align with apply's - // buildTerminalDomain / appleTerminalExportBlob): iterm2/.plist for iTerm2, + // buildTerminalDomain / terminalExportBlob): iterm2/.plist for iTerm2, // terminal/.plist for Apple Terminal. repoDest := terminalRepoDest(cc.repoPath, domain, prefID) // Guard the terminal plist repo path (read here, written by the shared route / @@ -1034,9 +1069,9 @@ func captureTerminalDomain(cc captureCtx, domain string) (wrote bool, offered bo // would create it). repoBytes, _ := os.ReadFile(repoDest) if domain == "iterm2" { - // Compare LIKE-FOR-LIKE: strip the control keys from the repo side too, so a repo - // plist that happens to carry stale pointer keys never registers as drift here. - repoBytes = terminal.StripITerm2ControlKeys(repoBytes) + // Compare LIKE-FOR-LIKE: filter the repo side to the same allowlist so a repo + // plist that happens to carry stale volatile keys never registers as drift. + repoBytes = terminal.FilterAllowlist(repoBytes) } if string(repoBytes) == string(liveBlob) { return false, false, nil @@ -1119,9 +1154,9 @@ func captureBlockedTerminal(cc captureCtx, domain, prefID, repoDest string, live } // terminalRepoDest is the committed repo plist path apply READS for a terminal -// domain — aligned with apply's buildTerminalDomain / appleTerminalExportBlob: -// iTerm2 reads its custom-prefs folder /iterm2/, where the domain plist is -// .plist; Apple Terminal imports the committed /terminal/.plist. A +// domain — aligned with apply's buildTerminalDomain / terminalExportBlob: both +// iTerm2 and Apple Terminal import the committed //.plist via +// `defaults import` (iTerm2's is the allowlist-filtered global plist). A // shared-routed capture writes here so a later apply re-deploys exactly these bytes. func terminalRepoDest(repo, domain, prefID string) string { if domain == "iterm2" { @@ -1395,14 +1430,11 @@ func sharedTargets(repo, bare string) []string { } // localOverlayPath is the gitignored per-machine destination for a local-routed -// capture: /local//.local. zsh dotfiles map to the "zsh" -// domain dir (mirrors apply's resolveOverlaySource). +// capture: /local//.local. The overlay directory is resolved +// through overlayDomainDir (mirrors apply's resolveOverlaySource) so capture and +// apply always agree on where a sidecar lives. func localOverlayPath(repo, bare string) string { - domain := bare - if isZsh(bare) { - domain = "zsh" - } - return filepath.Join(repo, "local", domain, bare+".local") + return filepath.Join(repo, "local", overlayDomainDir(bare), bare+".local") } // writeRepoFile writes content into the repo worktree, creating parent dirs. This @@ -1474,3 +1506,36 @@ func reDumpDeps(ctx *cmdContext, out io.Writer) bool { fmt.Fprintf(out, "deps: re-dumped manifest %s\n", relTo(ctx.RepoPath, path)) return true } + +// reDumpNpmGlobals re-dumps THIS machine's global npm package NAMES to +// deps/npm-globals.txt, gated on the npm-globals domain being in scope AND npm +// being present. It is an INDEPENDENT capture step that COEXISTS with the brew +// re-dump (a machine can carry both a Brewfile and an npm globals list). A missing +// npm, or a symlinked/escaping target, is a clean skip — never a bootstrap, never +// a write-through. Returns true ONLY when it actually (re)wrote the list, so the +// caller counts it as an offered+captured change. +func reDumpNpmGlobals(ctx *cmdContext, out io.Writer) bool { + if !ctx.Scope.IsManaged("npm-globals") { + return false + } + if !platform.HasNpm() { + fmt.Fprintln(out, "npm-globals: skipped (npm not installed)") + return false + } + depsDir := filepath.Join(ctx.RepoPath, "deps") + // Guard the target BEFORE the dump writes it (belt-and-suspenders: ReDumpNpmGlobals + // re-checks in the library). safeRepoPath refuses a symlinked deps/npm-globals.txt + // or a symlinked deps/ parent escaping the repo / resolving under ~/.ssh. + target := deps.NpmGlobalsFile(depsDir) + if _, err := safeRepoPath(ctx.RepoPath, target); err != nil { + fmt.Fprintf(out, "npm-globals: skipped manifest re-dump (%v)\n", err) + return false + } + path, err := deps.ReDumpNpmGlobals(depsDir, deps.ExecRunner{}) + if err != nil { + fmt.Fprintf(out, "npm-globals: skipped manifest re-dump (%v)\n", err) + return false + } + fmt.Fprintf(out, "npm-globals: re-dumped %s\n", relTo(ctx.RepoPath, path)) + return true +} diff --git a/cmd/capture_render.go b/cmd/capture_render.go index ff16046..e15e19c 100644 --- a/cmd/capture_render.go +++ b/cmd/capture_render.go @@ -18,9 +18,44 @@ import ( "sort" "strings" + "github.com/REPPL/ferry/internal/gitconfig" "github.com/REPPL/ferry/internal/secret" + "github.com/REPPL/ferry/internal/tmux" ) +// spanExtractor returns the flagged secret spans of a capture candidate's text. +// It is the seam that lets a format-specific recogniser (tmux's quoted +// `set -g @token '…'` value) replace the generic line-grained secret.FlaggedSpans +// for a domain that needs sub-value, column-grained isolation. +type spanExtractor func(text string) []secret.SecretSpan + +// captureSpanExtractor selects the secret-span recogniser for a bare dotfile +// name. tmux uses the column-grained tmux recogniser so ONLY the quoted option +// value is stored/patched (the `set -g @token '…'` prefix and quotes survive +// byte-for-byte); every other dotfile uses the generic line-grained extractor. +func captureSpanExtractor(bare string) spanExtractor { + switch bare { + case "tmux.conf": + return tmux.SecretSpans + case gitconfigBare: + // git tokens live inside a URL userinfo (url.*.insteadOf) or after + // Bearer/Basic in http.extraHeader — the git recogniser isolates ONLY the + // token so the surrounding URL/header syntax survives byte-for-byte. + return gitconfig.SecretSpans + } + return secret.FlaggedSpans +} + +// spanRoutable reports whether a capture candidate can route a NEW secret +// span-by-span (store just the span, patch only that span to a placeholder, +// preserve the curated remainder) rather than being blocked whole-file. A +// placeholder-bearing source is span-routable (the r6-M1 path); so is tmux, whose +// column-grained recogniser can isolate the secret inside a quoted option value +// even in an otherwise plaintext source. +func spanRoutable(bare string, placeholderAware bool) bool { + return placeholderAware || bare == "tmux.conf" || bare == gitconfigBare +} + // spliceReverseRender rebuilds the live bytes in SOURCE coordinates: unchanged // 1:1 lines take the repo-source line (placeholders intact — a placeholder can // only ever reappear at its own source position); changed/inserted lines take @@ -141,6 +176,12 @@ func prepareReverseRender(store *secret.Store, label string, source, live []byte // line. refPrefix names the would-be positional ref shown as the mask. func captureSecretMasks(text, refPrefix string) []maskPair { var masks []maskPair + // Mask with the BROAD line-grained detector, never a domain's narrow + // column extractor: preview masking must cover EVERY high-confidence line the + // gate caught (a secret that a domain recogniser does not isolate for storage + // must still never print), and over-masking a preview is always safe. Storage + // precision (a tmux column span) is a separate concern handled at the store + // site (consentSpanStoreRoute's extractor). for _, sp := range secret.FlaggedSpans(text) { ph := secret.Placeholder(fmt.Sprintf("%s.secret_%d", refPrefix, sp.StartLine)) for _, l := range strings.Split(sp.Value, "\n") { @@ -178,7 +219,7 @@ func reportReadOnlyBlock(out io.Writer, label string) { // span's Value is stored under a positional ref and ONLY that span is patched // to its placeholder — the curated remainder (existing placeholders included) // is preserved, and stored values never contain placeholders. -func consentSpanStoreRoute(in *bufio.Reader, out io.Writer, store *secret.Store, label, refPrefix, captured string) (patched string, refs []string, ok bool, err error) { +func consentSpanStoreRoute(in *bufio.Reader, out io.Writer, store *secret.Store, label, refPrefix, captured string, spansOf spanExtractor) (patched string, refs []string, ok bool, err error) { fmt.Fprintf(out, " %s: SECRET / credential material detected in the accepted change (e.g. a private key or token).\n", label) fmt.Fprintf(out, " It is BLOCKED from the repo entirely (both shared and local). It is never committed.\n") fmt.Fprintf(out, " Only the out-of-band path is offered: [r]eject, or route ONLY the new secret span(s) to the out-of-repo secret store [x].\n") @@ -187,7 +228,7 @@ func consentSpanStoreRoute(in *bufio.Reader, out io.Writer, store *secret.Store, fmt.Fprintf(out, " %s: rejected; secret kept out of the repo\n", label) return "", nil, false, nil } - spans := secret.FlaggedSpans(captured) + spans := spansOf(captured) if len(spans) == 0 { fmt.Fprintf(out, " %s: rejected; the blocked span could not be isolated\n", label) return "", nil, false, nil @@ -297,15 +338,28 @@ func freeSecretRef(store *secret.Store, prefix string, line int, value string) ( } } -// patchSpanLines replaces EXACTLY the span's line range (1-based, inclusive) -// with a single placeholder line, preserving everything else — including any -// earlier byte-equal occurrence of the same content — and the text's -// trailing-newline shape. +// patchSpanLines replaces a flagged span with a placeholder, preserving +// everything else — including any earlier byte-equal occurrence of the same +// content — and the text's trailing-newline shape. +// +// A COLUMN-grained span (HasColumns: a tmux quoted option value) replaces ONLY +// the byte range [StartCol, EndCol) of its single line via secret.SwapColumns, +// so the `set -g @token '…'` prefix and the surrounding quotes are byte-identical +// after patching. A whole-line span replaces its entire 1-based inclusive line +// range with the single placeholder line, as before. func patchSpanLines(text string, sp secret.SecretSpan, placeholder string) string { lines := strings.Split(text, "\n") if sp.StartLine < 1 || sp.EndLine < sp.StartLine || sp.EndLine > len(lines) { return text } + if sp.HasColumns() && sp.StartLine == sp.EndLine { + swapped, err := secret.SwapColumns(lines[sp.StartLine-1], sp.StartCol, sp.EndCol, placeholder) + if err != nil { + return text // out-of-range offsets: leave the text untouched (never a partial write) + } + lines[sp.StartLine-1] = swapped + return strings.Join(lines, "\n") + } out := make([]string, 0, len(lines)) out = append(out, lines[:sp.StartLine-1]...) out = append(out, placeholder) diff --git a/cmd/capture_render_test.go b/cmd/capture_render_test.go index f6cfe63..b2114e2 100644 --- a/cmd/capture_render_test.go +++ b/cmd/capture_render_test.go @@ -103,7 +103,7 @@ func TestConsentSpanStoreRoute_UncleanSpanReadOnly(t *testing.T) { "alias keep='me'\n" var out bytes.Buffer - _, _, ok, err := consentSpanStoreRoute(bufio.NewReader(strings.NewReader("x\n")), &out, store, ".zshrc", "zshrc", captured) + _, _, ok, err := consentSpanStoreRoute(bufio.NewReader(strings.NewReader("x\n")), &out, store, ".zshrc", "zshrc", captured, secret.FlaggedSpans) if err != nil { t.Fatalf("consentSpanStoreRoute: %v", err) } @@ -130,7 +130,7 @@ func TestConsentSpanStoreRoute_CleanSpanStores(t *testing.T) { captured := "# curated\n" + `{{ferry.secret "zsh.github_token"}}` + "\n" + line + "\n" var out bytes.Buffer - patched, _, ok, err := consentSpanStoreRoute(bufio.NewReader(strings.NewReader("x\n")), &out, store, ".zshrc", "zshrc", captured) + patched, _, ok, err := consentSpanStoreRoute(bufio.NewReader(strings.NewReader("x\n")), &out, store, ".zshrc", "zshrc", captured, secret.FlaggedSpans) if err != nil || !ok { t.Fatalf("consent failed: ok=%v err=%v\n%s", ok, err, out.String()) } @@ -270,9 +270,10 @@ func TestCaptureOne_WholeFileLocalWritesPatchedComposition(t *testing.T) { storeDir := t.TempDir() store := secret.OpenAt(storeDir) repoPath := t.TempDir() - // A NON-zsh dotfile (whole-file local overlay, no include point). - src := "[user]\n\tname = tester\n# stored key\n" + `{{ferry.secret "gitconfig.secret_9"}}` + "\n" - if err := store.Put("gitconfig.secret_9", "synthetic-old-value"); err != nil { + // A NON-zsh, non-include whole-file dotfile (.npmrc: whole-file local overlay, + // no include point, generic line-grained secret extractor). + src := "registry=https://registry.npmjs.org/\n# stored key\n" + `{{ferry.secret "npmrc.secret_9"}}` + "\n" + if err := store.Put("npmrc.secret_9", "synthetic-old-value"); err != nil { t.Fatal(err) } newLine := "export NEW_TOKEN=" + synthWizardSecret @@ -283,7 +284,7 @@ func TestCaptureOne_WholeFileLocalWritesPatchedComposition(t *testing.T) { out: &out, in: bufio.NewReader(strings.NewReader("y\nx\nl\n")), // accept, store consent, LOCAL route repoPath: repoPath, - name: ".gitconfig", + name: ".npmrc", repoBytes: []byte(src), liveBytes: []byte(live), secretStore: store, @@ -295,7 +296,7 @@ func TestCaptureOne_WholeFileLocalWritesPatchedComposition(t *testing.T) { if !wrote { t.Fatalf("store-then-local whole-file capture refused\n%s", out.String()) } - dest := filepath.Join(repoPath, "local", "gitconfig", "gitconfig") + dest := filepath.Join(repoPath, "local", "npmrc", "npmrc") data, err := os.ReadFile(dest) if err != nil { t.Fatalf("whole-file local overlay not written at %s: %v\n%s", dest, err, out.String()) @@ -303,11 +304,11 @@ func TestCaptureOne_WholeFileLocalWritesPatchedComposition(t *testing.T) { if strings.Contains(string(data), synthWizardSecret) { t.Errorf("CRITICAL: the raw secret value reached the repo worktree at %s:\n%s", dest, data) } - if !strings.Contains(string(data), "{{ferry.secret") || !strings.Contains(string(data), "name = tester") { + if !strings.Contains(string(data), "{{ferry.secret") || !strings.Contains(string(data), "registry=https://registry.npmjs.org/") { t.Errorf("local overlay is not the patched composition:\n%s", data) } // The printed message names exactly what was written. - if !strings.Contains(out.String(), "captured -> local (local/gitconfig/gitconfig") { + if !strings.Contains(out.String(), "captured -> local (local/npmrc/npmrc") { t.Errorf("capture message does not match the written file:\n%s", out.String()) } // The consented value is in the store, and never printed. @@ -458,7 +459,7 @@ func TestConsentSpanStoreRoute_PartialPutFailureNamesStoredRefs(t *testing.T) { t.Cleanup(func() { storePut = orig }) var out bytes.Buffer - _, _, ok, err := consentSpanStoreRoute(bufio.NewReader(strings.NewReader("x\n")), &out, store, ".zshrc", "zshrc", captured) + _, _, ok, err := consentSpanStoreRoute(bufio.NewReader(strings.NewReader("x\n")), &out, store, ".zshrc", "zshrc", captured, secret.FlaggedSpans) if ok { t.Fatal("partial Put failure reported success") } diff --git a/cmd/directive.go b/cmd/directive.go new file mode 100644 index 0000000..c591af4 --- /dev/null +++ b/cmd/directive.go @@ -0,0 +1,143 @@ +package cmd + +import "strings" + +// overlayMarker is the exact comment line ferry injects above a per-machine +// overlay include directive, for EVERY include-style file format. It is the +// shape-keyed strip's anchor (internal/dotfile isFerryOverlayInclude keys off +// the line that FOLLOWS it) and MUST stay byte-identical to the marker the +// dotfile package recognises. +const overlayMarker = "# ferry: per-machine overlay, sourced last so it wins" + +// directiveSpec is the per-file-format description of ferry's injected +// per-machine overlay include directive. The include-sidecar mechanism is +// otherwise format-agnostic — it always injects ferry's marker comment plus one +// include line that sources the `.local` sidecar LAST so it wins — so the ONLY +// thing that varies between zsh and tmux is the SYNTAX of that include line. +// directiveSpec makes that syntax DATA, selected per bare dotfile name +// (directiveSpecFor), so a new include-style format is a new spec value rather +// than a new branch through appendSourceDirective / stripSourceDirective / +// sourceDirectivePresent. +// +// This is deliberately the FILE-KEYED half of the two-strip contract: render + +// namesFile drive appendSourceDirective and the capture-write stripSourceDirective +// (both of which know the exact overlay FILE name). The SHAPE-keyed half lives +// separately in internal/dotfile (isFerryOverlayInclude), keyed off the marker + +// the include line's structure, and is NOT merged with this — the two strips +// remain distinct functions with distinct keying. +type directiveSpec struct { + // marker is the comment line injected above the include directive. + marker string + // render builds the include directive ferry injects for overlay basename + // `file` (e.g. ".tmux.conf.local"), sourced last so the sidecar wins. It may + // span MULTIPLE lines: the git-INI directive is a two-line `[include]` block + // (a section header line plus a `path = …` line), where zsh/tmux are one line. + render func(file string) string + // namesFile reports whether an uncommented CODE line is the include directive + // (in this format) that names overlay `file` — the recogniser the file-keyed + // strip and the idempotency check use to drop/detect ONLY ferry's own line. For + // a multi-line directive it matches the SINGLE line that names the file (git's + // `path = ~/`); the preceding header line is dropped alongside it via + // header (below). + namesFile func(code, file string) bool + // header, when non-empty, is a section-header line that PRECEDES the + // file-naming line in a multi-line directive (git's `[include]`). The + // file-keyed strip drops it together with the file-naming line so no orphan + // header survives; single-line directives (zsh/tmux) leave it "" and are + // byte-identical to before. It is compared trimmed. + header string +} + +// shellDirective is the zsh (and generic shell) include directive: +// +// [ -f ~/.zshrc.local ] && source ~/.zshrc.local +// +// Its render/marker are byte-identical to what ferry has always injected, so the +// zsh round-trip stays byte-for-byte stable (TestFn5_ZshRoundTripByteStable_Baseline +// and the overlay-bypass guard eval are the proof). +var shellDirective = directiveSpec{ + marker: overlayMarker, + render: func(file string) string { return "[ -f ~/" + file + " ] && source ~/" + file }, + namesFile: func(code, file string) bool { + if !strings.Contains(code, file) { + return false + } + lc := strings.ToLower(code) + return strings.Contains(lc, "source ") || strings.Contains(code, ". ") + }, +} + +// tmuxDirective is the tmux include directive: +// +// source-file -q ~/.tmux.conf.local +// +// `source-file -q` sources the per-machine file if it exists and stays quiet if +// it does not — the tmux analogue of the shell `[ -f … ] && source …` guard. +var tmuxDirective = directiveSpec{ + marker: overlayMarker, + render: func(file string) string { return "source-file -q ~/" + file }, + namesFile: func(code, file string) bool { + return strings.Contains(code, file) && strings.Contains(code, "source-file") + }, +} + +// gitDirective is the git-INI include directive — a TWO-line block: +// +// [include] +// path = ~/.gitconfig.local +// +// git applies includes inline and last-wins, so an [include] appended LAST makes +// the machine-local ~/.gitconfig.local override — the native equivalent of +// ferry's per-machine overlay. Unlike the shell/tmux one-liners, the directive +// spans a section header (`[include]`) plus a tab-indented `path` line; render +// emits both, namesFile matches the `path` line, and header ("[include]") is the +// preceding line the file-keyed strip drops alongside it. +var gitDirective = directiveSpec{ + marker: overlayMarker, + render: func(file string) string { return "[include]\n\tpath = ~/" + file }, + namesFile: func(code, file string) bool { + // Match ferry's own `path = ~/` line PRECISELY: key "path", value + // exactly "~/" + file. A user's [includeIf] path to a different file, or a + // path to some other target, is left untouched. + eq := strings.IndexByte(code, '=') + if eq < 0 { + return false + } + key := strings.ToLower(strings.TrimSpace(code[:eq])) + val := strings.TrimSpace(code[eq+1:]) + return key == "path" && val == "~/"+file + }, + header: "[include]", +} + +// directiveSpecFor selects the include directive syntax for a bare dotfile name. +// It is only consulted for include-sidecar domains (usesIncludeSidecar): tmux +// gets the tmux `source-file` directive, git the git-INI `[include]` block, every +// other include-style dotfile (the zsh family) the shell `source` directive. +func directiveSpecFor(bare string) directiveSpec { + switch bare { + case "tmux.conf": + return tmuxDirective + case "gitconfig": + return gitDirective + } + return shellDirective +} + +// overlayDomainDir maps a bare dotfile name to its per-machine overlay directory +// under /local/. Include-style families are grouped by tool (zsh's three +// bares share local/zsh/; tmux uses local/tmux/); every other dotfile overlays +// under its own bare name. It is the single source of truth both apply +// (resolveOverlaySource) and capture (localOverlayPath) resolve the overlay path +// through, so the two directions can never disagree on where a sidecar lives. +func overlayDomainDir(bare string) string { + switch bare { + case "zshrc", "zshenv", "zprofile": + return "zsh" + case "tmux.conf": + return "tmux" + case "gitconfig": + return "git" + } + return bare +} diff --git a/cmd/directive_test.go b/cmd/directive_test.go new file mode 100644 index 0000000..d355b9f --- /dev/null +++ b/cmd/directive_test.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "strings" + "testing" +) + +// TestDirectiveSpecSelection pins the per-bare-name directive syntax selection: +// tmux gets the tmux `source-file` directive; the zsh family (and any other +// include-style name) gets the shell `source` directive. +func TestDirectiveSpecSelection(t *testing.T) { + if got := directiveSpecFor("tmux.conf").render(".tmux.conf.local"); got != "source-file -q ~/.tmux.conf.local" { + t.Errorf("tmux render = %q", got) + } + for _, bare := range []string{"zshrc", "zshenv", "zprofile"} { + want := "[ -f ~/." + bare + ".local ] && source ~/." + bare + ".local" + if got := directiveSpecFor(bare).render("." + bare + ".local"); got != want { + t.Errorf("%s render = %q, want %q", bare, got, want) + } + } + if got := directiveSpecFor("gitconfig").render(".gitconfig.local"); got != "[include]\n\tpath = ~/.gitconfig.local" { + t.Errorf("git render = %q", got) + } +} + +// TestGitDirectiveInject pins git's injected TWO-line block: the shared marker +// comment plus the `[include]` / `path = ~/.gitconfig.local` block, appended last +// so git last-wins-merges the machine-local file. strip must remove the whole +// block (header included) with no orphan `[include]`. +func TestGitDirectiveInject(t *testing.T) { + raw := []byte("[core]\n\teditor = vim\n") + got := appendSourceDirective(raw, ".gitconfig.local", gitDirective) + want := "[core]\n\teditor = vim\n\n# ferry: per-machine overlay, sourced last so it wins\n[include]\n\tpath = ~/.gitconfig.local\n" + if string(got) != want { + t.Errorf("git injected block:\n got %q\nwant %q", got, want) + } + // Idempotent: a second append is a no-op. + if again := appendSourceDirective(got, ".gitconfig.local", gitDirective); string(again) != string(got) { + t.Errorf("append not idempotent for git:\n%q", again) + } + // strip removes marker + [include] header + path line, leaving no orphan. + stripped := stripSourceDirective(got, ".gitconfig.local", gitDirective) + if string(stripped) != string(raw) { + t.Errorf("git strip did not restore the shared body:\n got %q\nwant %q", stripped, raw) + } + if strings.Contains(string(stripped), "[include]") { + t.Errorf("orphan [include] header survived the strip:\n%q", stripped) + } +} + +// TestZshDirectiveByteIdentical is the load-bearing guard that the directiveSpec +// generalisation did NOT change a single byte of the zsh include block ferry has +// always injected — the marker comment and the guarded `[ -f … ] && source …` +// line, in the exact shape the overlay-bypass eval and the byte-stable migration +// eval depend on. +func TestZshDirectiveByteIdentical(t *testing.T) { + raw := []byte("# managed zshrc\nexport EDITOR=vim\n") + got := appendSourceDirective(raw, ".zshrc.local", directiveSpecFor("zshrc")) + want := "# managed zshrc\nexport EDITOR=vim\n\n# ferry: per-machine overlay, sourced last so it wins\n[ -f ~/.zshrc.local ] && source ~/.zshrc.local\n" + if string(got) != want { + t.Errorf("zsh injected block changed:\n got %q\nwant %q", got, want) + } +} + +// TestTmuxDirectiveInject pins tmux's injected block: the shared marker comment +// plus the `source-file -q ~/.tmux.conf.local` include line, sourced last. +func TestTmuxDirectiveInject(t *testing.T) { + raw := []byte("set -g mouse on\n") + got := appendSourceDirective(raw, ".tmux.conf.local", tmuxDirective) + want := "set -g mouse on\n\n# ferry: per-machine overlay, sourced last so it wins\nsource-file -q ~/.tmux.conf.local\n" + if string(got) != want { + t.Errorf("tmux injected block:\n got %q\nwant %q", got, want) + } + // Idempotent: a second append is a no-op (the directive is already present). + if again := appendSourceDirective(got, ".tmux.conf.local", tmuxDirective); string(again) != string(got) { + t.Errorf("append not idempotent for tmux:\n%q", again) + } +} + +// TestDirectiveRoundTrip is the Reassemble(Parse(x)) == x property for BOTH +// include formats: strip(append(x)) reproduces the shared body byte-for-byte for +// any newline-terminated source that does not itself carry ferry's directive. +func TestDirectiveRoundTrip(t *testing.T) { + cases := []struct { + spec directiveSpec + file string + body string + }{ + {tmuxDirective, ".tmux.conf.local", "set -g mouse on\nset -g @plugin 'x'\n"}, + {tmuxDirective, ".tmux.conf.local", "\n"}, + {tmuxDirective, ".tmux.conf.local", "# only a comment\n"}, + {shellDirective, ".zshrc.local", "export EDITOR=vim\n"}, + {shellDirective, ".zshrc.local", "# managed\nalias gs='git status'\n"}, + {gitDirective, ".gitconfig.local", "[core]\n\teditor = vim\n"}, + {gitDirective, ".gitconfig.local", "[alias]\n\tst = status\n\tlg = log\n"}, + {gitDirective, ".gitconfig.local", "# only a comment\n"}, + } + for _, tc := range cases { + app := appendSourceDirective([]byte(tc.body), tc.file, tc.spec) + got := stripSourceDirective(app, tc.file, tc.spec) + if string(got) != tc.body { + t.Errorf("round-trip: strip(append(%q)) = %q, want %q", tc.body, got, tc.body) + } + } +} + +// FuzzDirectiveRoundTrip proves strip(append(x)) == x cannot corrupt or lose a +// newline-terminated shared body that carries neither ferry's marker nor an +// include directive — the same property the parser STOP condition guards. +func FuzzDirectiveRoundTrip(f *testing.F) { + f.Add("set -g mouse on\n") + f.Add("# c\nset -g @x 'v'\n") + f.Add("export EDITOR=vim\n") + f.Fuzz(func(t *testing.T, body string) { + // Constrain to the shape apply passes: a newline-terminated body with no + // ferry marker and no existing include directive (else append is a no-op). + if strings.Contains(body, "ferry:") || strings.Contains(body, "source-file") || + strings.Contains(body, "source ") || strings.Contains(body, ". ") { + t.Skip() + } + if body == "" || !strings.HasSuffix(body, "\n") { + body += "\n" + } + // appendSourceDirective prefixes its block with one blank line and + // stripSourceDirective collapses the trailing blank RUN back to a single + // newline — so the identity holds only for a body that does not itself end + // in a blank line (a trailing "\n\n" would be collapsed on the way back). + if strings.HasSuffix(body, "\n\n") { + t.Skip() + } + for _, spec := range []struct { + d directiveSpec + file string + }{{tmuxDirective, ".tmux.conf.local"}, {shellDirective, ".zshrc.local"}} { + app := appendSourceDirective([]byte(body), spec.file, spec.d) + got := stripSourceDirective(app, spec.file, spec.d) + if string(got) != body { + t.Fatalf("round-trip: got %q want %q", got, body) + } + } + }) +} diff --git a/cmd/doctor.go b/cmd/doctor.go index 47c5ee9..876c16a 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -1,16 +1,20 @@ package cmd import ( + "errors" "fmt" "io" "os" "os/exec" "path/filepath" + "strings" "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/REPPL/ferry/internal/dotfile" "github.com/REPPL/ferry/internal/platform" + "github.com/REPPL/ferry/internal/sshguard" ) // runDoctor reports machine/tool health, READ-ONLY: it inspects host tools, the @@ -48,6 +52,15 @@ func runDoctor(c *cobra.Command, _ []string) error { // modify). ferry is hands-off with ~/.ssh contents. reportSSHPerms(out) + // Managed-target invariants (read-only observation): ferry's non-negotiables + // are that deployed targets are regular-file copies (never symlinks), that no + // managed target resolves under ~/.ssh, and that every managed target resolves + // inside $HOME. These are enforced at WRITE time elsewhere; doctor only + // OBSERVES the live state and reports a genuine breach as [fail]. + if !reportInvariants(out, colour) { + healthy = false + } + fmt.Fprintln(out) // blank line before the verdict footer if !healthy { // A required prerequisite is missing: signal an unhealthy machine with a @@ -152,3 +165,122 @@ func reportSSHPerms(out io.Writer) { } } } + +// reportInvariants OBSERVES ferry's write-time invariants against the currently +// managed targets and reports each as [pass]/[fail]. It re-uses the read-only +// plan (buildPlan → the same path diff/status take: no engine, no state dir, no +// writes) to enumerate the deployed file targets, then, per target, LSTATS the +// leaf (never following it, never reading contents) to observe three things: +// +// - no deployed target is a symlink (ferry deploys regular-file copies); +// - no managed target resolves under ~/.ssh (the hands-off guard's promise); +// - every managed target resolves inside $HOME (containment). +// +// A genuine breach — a symlink where a copy should be, or a target that escapes +// $HOME / lands under ~/.ssh — is a real invariant violation and returns false +// (failing the exit code). The ABSENCE of managed state (no config repo, or a +// plan that could not be read) is advisory: it prints a [warn] and returns true, +// so a fresh machine is never reported unhealthy for having nothing to check. +func reportInvariants(out io.Writer, colour func(*color.Color, string) string) bool { + fmt.Fprintln(out, "\nmanaged-target invariants (read-only check):") + + ctx, err := loadContext() + if err != nil { + fmt.Fprintf(out, " %s no config repo resolved — no managed targets to check (run `ferry init`)\n", colour(colYellow, "[warn]")) + return true + } + home, err := os.UserHomeDir() + if err != nil { + fmt.Fprintf(out, " %s could not resolve $HOME — skipping invariant checks\n", colour(colYellow, "[warn]")) + return true + } + + items, _, err := buildPlan(ctx) + if err != nil { + // A managed target that is present but NOT a regular file (a symlink or + // directory) makes the plan REFUSE to treat it as managed content — this is + // itself the copy-not-symlink invariant breach. Surface it as a [fail]. + var uke *dotfile.UnexpectedKindError + if errors.As(err, &uke) { + kind := "NOT a regular file" + if uke.Mode&os.ModeSymlink != 0 { + kind = "a SYMLINK" + } + fmt.Fprintf(out, " %s managed target %s is %s (%s) — ferry deploys regular-file copies, never symlinks\n", colour(colRed, "[fail]"), displayHomePath(home, uke.Path), kind, uke.Mode) + return false + } + fmt.Fprintf(out, " %s could not read the managed plan (%v) — skipping invariant checks\n", colour(colYellow, "[warn]"), err) + return true + } + + // Collect the file targets (native preference domains carry no $HOME path). + var targets []string + for _, it := range items { + if it.target.Home != "" { + targets = append(targets, it.target.Home) + } + } + if len(targets) == 0 { + fmt.Fprintf(out, " %s no managed targets deployed yet — nothing to check\n", colour(colYellow, "[warn]")) + return true + } + + healthy := true + + // Invariant 1: no deployed target is a symlink. LSTAT the leaf only (never + // following it, never reading contents); a not-yet-deployed target is skipped. + symlinkOK := true + for _, path := range targets { + fi, statErr := os.Lstat(path) + if statErr != nil { + continue // not deployed yet: nothing to observe + } + if fi.Mode()&os.ModeSymlink != 0 { + symlinkOK = false + healthy = false + fmt.Fprintf(out, " %s managed target %s is a SYMLINK — ferry deploys regular-file copies, never symlinks\n", colour(colRed, "[fail]"), displayHomePath(home, path)) + } + } + if symlinkOK { + fmt.Fprintf(out, " %s no managed target is a symlink (%d target(s) checked)\n", colour(colGreen, "[pass]"), len(targets)) + } + + // Invariant 2: no managed target resolves under ~/.ssh (pure path arithmetic, + // never stats ~/.ssh). + sshOK := true + for _, path := range targets { + if sshguard.UnderHomeSSH(home, path) { + sshOK = false + healthy = false + fmt.Fprintf(out, " %s managed target %s resolves under ~/.ssh — ferry never manages ~/.ssh\n", colour(colRed, "[fail]"), displayHomePath(home, path)) + } + } + if sshOK { + fmt.Fprintf(out, " %s no managed target resolves under ~/.ssh\n", colour(colGreen, "[pass]")) + } + + // Invariant 3: every managed target resolves inside $HOME (containment). + containOK := true + for _, path := range targets { + rel, relErr := filepath.Rel(home, path) + if relErr != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + containOK = false + healthy = false + fmt.Fprintf(out, " %s managed target %s resolves OUTSIDE $HOME — every managed target must live under $HOME\n", colour(colRed, "[fail]"), displayHomePath(home, path)) + } + } + if containOK { + fmt.Fprintf(out, " %s all managed targets resolve inside $HOME\n", colour(colGreen, "[pass]")) + } + + return healthy +} + +// displayHomePath renders an absolute path under $HOME as a ~-relative string for +// display, leaving anything outside $HOME untouched. Cosmetic only. +func displayHomePath(home, path string) string { + if rel, err := filepath.Rel(home, path); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "~/" + filepath.ToSlash(rel) + } + return path +} diff --git a/cmd/emacs_plan.go b/cmd/emacs_plan.go new file mode 100644 index 0000000..b4b03d0 --- /dev/null +++ b/cmd/emacs_plan.go @@ -0,0 +1,128 @@ +package cmd + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/REPPL/ferry/internal/dotfile" + "github.com/REPPL/ferry/internal/emacs" + "github.com/REPPL/ferry/internal/secret" +) + +// planEmacs builds the per-target plan for the Emacs configuration domain: the +// repo's emacs/ tree fanned out file-by-file (volatile, machine-generated paths +// pruned) into hash-classified (content, target) planItems under ~/.emacs.d/, +// carried like a dotfile with the per-machine .local overlay winning per file, +// but repo-authoritative on capture (no capture pass). +// +// A target that is currently a symlink (or any non-regular file) is SKIPPED with +// a clear warning rather than classified: ferry materialises regular-file copies +// only. Repo-side reads route through safeRepoPath, so a symlinked source in the +// config repo is refused exactly like a dotfile's. +// +// Each target's bytes carry {{ferry.secret ...}} placeholders through the SAME +// render-or-SKIP as dotfiles and terminals (renderSecrets): a present secret is +// substituted and the target flagged secretRouted (deployed 0600, its plaintext +// kept out of the last-applied snapshot); a MISSING secret SKIPS the whole target +// rather than deploy the literal placeholder into the live Emacs config. +func planEmacs(ctx *cmdContext, home string, secretStore *secret.Store, lastApplied *dotfile.Store) ([]planItem, []string, error) { + eItems, warnings, err := emacs.Plan(emacs.PlanInput{ + RepoRoot: ctx.RepoPath, + Home: home, + Guard: func(cand string) (string, error) { return safeRepoPath(ctx.RepoPath, cand) }, + }) + if err != nil { + return nil, nil, err + } + + var items []planItem + planned := map[string]bool{} + for _, ei := range eItems { + planned[ei.Key] = true + // Render secrets EXACTLY like a dotfile/terminal: substitute present + // placeholders, or SKIP the whole target (never deploy a literal + // {{ferry.secret}}) when a referenced secret is missing. secretRouted is + // judged on the PRE-render bytes. + rendered, missing, skip, rerr := renderSecrets(secretStore, ei.Content) + if rerr != nil { + return nil, nil, rerr + } + secretRouted := isSecretRouted(ei.Content) + if skip { + items = append(items, planItem{ + kind: kindFile, + domain: ei.Label, + target: ei.Target, + action: "skipped", + skip: true, + missing: missing, + secretRouted: secretRouted, + }) + continue + } + state, risky, reason, cerr := classifyItem(ei.Target, rendered, secretRouted, lastApplied) + if cerr != nil { + var kind *dotfile.UnexpectedKindError + if errors.As(cerr, &kind) { + warnings = append(warnings, fmt.Sprintf( + "%s skipped: %s is a symlink (or non-regular file) not managed by ferry — remove it, then re-run `ferry apply`", + ei.Label, ei.Target.Home)) + continue + } + return nil, nil, cerr + } + items = append(items, planItem{ + kind: kindFile, + domain: ei.Label, + target: ei.Target, + content: rendered, + execBit: ei.Exec, + state: state, + risky: risky, + riskReason: reason, + secretRouted: secretRouted, + }) + } + + warnings = append(warnings, descopeEmacsConfigWarnings(lastApplied, planned, true)...) + return items, warnings, nil +} + +// descopeEmacsConfigWarnings warns about Emacs targets ferry previously applied +// (recorded under the emacs/ prefix in the last-applied store) that the current +// plan no longer covers. Files are left untouched (de-scope = warn, never +// auto-remove). With the whole domain unmanaged the records collapse into ONE +// warning; with the domain managed, each dropped target (a file removed from the +// tree) warns individually. A full `ferry restore` reverts them to their +// pre-ferry baseline (Emacs files deploy through the backup engine, like +// dotfiles). +func descopeEmacsConfigWarnings(store *dotfile.Store, planned map[string]bool, managed bool) []string { + var stale []string + for _, name := range store.RecordedNames() { + if !strings.HasPrefix(name, emacs.KeyPrefix) { + continue + } + if planned[name] { + continue + } + stale = append(stale, name) + } + if len(stale) == 0 { + return nil + } + if !managed { + return []string{fmt.Sprintf( + "warning: the emacs domain is no longer managed; %d previously applied file(s) left as-is (now unmanaged). To revert them to their pre-ferry state: ferry restore", + len(stale))} + } + out := make([]string, 0, len(stale)) + for _, name := range stale { + out = append(out, fmt.Sprintf( + "warning: %s is no longer part of the emacs plan; existing file left as-is (now unmanaged). To revert: ferry restore", + name)) + } + sort.Strings(out) + return out +} diff --git a/cmd/export.go b/cmd/export.go index 181fb69..8415887 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -25,7 +25,8 @@ and withheld if any are found, otherwise bundled. The result is a self-contained .zip you move to another account or machine and ingest with "ferry import". Secrets and the per-machine local layer are never included unless you pass --include-local. export prints the bundle's -SHA256 so you can verify the move with "ferry import --expect-sha256".`, +reproducible SHA256 — exporting the same tracked sources always yields the same +digest — so you can verify the move with "ferry import --expect-sha256".`, Args: cobra.NoArgs, RunE: runExport, } @@ -149,8 +150,9 @@ func runExport(c *cobra.Command, _ []string) error { fmt.Fprintln(out, w) } fmt.Fprintf(out, "wrote bundle: %s\n", absOut) - fmt.Fprintf(out, "bundle sha256: %s\n", sha) + fmt.Fprintf(out, "bundle sha256 (reproducible): %s\n", sha) fmt.Fprintf(out, "summary: %d file(s) bundled, %d withheld\n", len(sources), len(withheld)) + fmt.Fprintln(out, "this sha256 is reproducible: exporting the same tracked sources always yields the same digest (no timestamps or randomness are bundled).") fmt.Fprintln(out, "convey the sha256 out-of-band; import with `ferry import --expect-sha256 ` to verify.") return nil } diff --git a/cmd/gitconfig.go b/cmd/gitconfig.go new file mode 100644 index 0000000..1521904 --- /dev/null +++ b/cmd/gitconfig.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + + "github.com/REPPL/ferry/internal/gitconfig" +) + +// gitconfigBare is the bare dotfile name of ~/.gitconfig. git rides the dotfiles +// list as an include-sidecar dotfile (like tmux), but adds the identity firewall +// and the git-INI [include] overlay directive keyed off this name. +const gitconfigBare = "gitconfig" + +// sharedGitTransform is the identity firewall applied to the SHARED git-config +// bytes on BOTH the deploy composition and the shared-capture write: for +// ~/.gitconfig it strips every identity key (user.email/name/signingkey, +// gpg.program, credential.helper) and every [includeIf …] block so a machine's +// commit identity can never reach the shared ~/.gitconfig or the shared repo +// (plan §3.1; the STOP condition "git leaks an identity key to the shared repo"). +// For every other dotfile it is a byte-for-byte no-op, and for an +// already-identity-free gitconfig it returns the input unchanged (so the git +// round-trip stays byte-stable). It is applied BEFORE ferry's [include] directive +// is appended, so the injected overlay line is preserved. +func sharedGitTransform(bare string, raw []byte) []byte { + if bare != gitconfigBare { + return raw + } + return gitconfig.SharedContent(raw) +} + +// gitCredentialHelperWarnings returns a warning when a git-config sets +// `credential.helper = store` — the backend that writes credentials as PLAINTEXT +// into ~/.git-credentials. ferry never carries ~/.git-credentials (it is treated +// like ~/.ssh — untouchable) and the helper must be `osxkeychain`; the warning +// tells the user to switch. It scans every source that composes the deployed +// git-config (the shared source and, when present, the per-machine overlay). +func gitCredentialHelperWarnings(bare string, sources ...[]byte) []string { + if bare != gitconfigBare { + return nil + } + for _, src := range sources { + if gitconfig.CredentialHelperStore(src) { + return []string{fmt.Sprintf( + "warning: .gitconfig sets credential.helper = store, which writes credentials as PLAINTEXT to ~/.git-credentials. ferry never carries ~/.git-credentials; switch to `credential.helper = osxkeychain`.")} + } + } + return nil +} diff --git a/cmd/guided_apply.go b/cmd/guided_apply.go index 5704652..7d65270 100644 --- a/cmd/guided_apply.go +++ b/cmd/guided_apply.go @@ -60,7 +60,7 @@ type guidedOutcome struct { // the walkthrough and risk gate cover. Terminal preference domains (kindPreference) // are NOT: they auto-apply through their own reversible backup-first path. func isGuidedKind(k planKind) bool { - return k == kindDotfile || k == kindOverlay || k == kindAgents || k == kindTerminal + return k == kindFile } // itemKey is the stable per-machine identity used for skip-always exclusions: a @@ -269,10 +269,10 @@ type riskyGroup struct { func groupRisky(risky []planItem) []riskyGroup { var dot, ag, term []planItem for _, it := range risky { - switch it.kind { - case kindAgents: + switch it.fileDomain { + case "agents": ag = append(ag, it) - case kindTerminal: + case "terminals": term = append(term, it) default: dot = append(dot, it) diff --git a/cmd/iterm2profiles_plan.go b/cmd/iterm2profiles_plan.go new file mode 100644 index 0000000..73a52f3 --- /dev/null +++ b/cmd/iterm2profiles_plan.go @@ -0,0 +1,121 @@ +package cmd + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/REPPL/ferry/internal/dotfile" + "github.com/REPPL/ferry/internal/iterm2profiles" + "github.com/REPPL/ferry/internal/secret" +) + +// planIterm2Profiles builds the per-target plan for the iTerm2 Dynamic Profiles +// domain: the repo's iterm2/DynamicProfiles/ tree fanned out file-by-file into +// hash-classified (content, target) planItems under +// ~/Library/Application Support/iTerm2/DynamicProfiles/, carried like a dotfile +// with the per-machine local/iterm2-profiles/ overlay winning per file, but +// repo-authoritative on capture (no capture pass). A malformed profile JSON is +// refused (never deployed) with a warning, so one bad file can never disable all of +// iTerm2's dynamic profiles. +// +// A target that is currently a symlink (or any non-regular file) is SKIPPED with a +// clear warning rather than classified: ferry materialises regular-file copies +// only. Repo-side reads route through safeRepoPath, so a symlinked source in the +// config repo is refused exactly like a dotfile's. Each target's bytes carry +// {{ferry.secret ...}} placeholders through the SAME render-or-SKIP as dotfiles. +func planIterm2Profiles(ctx *cmdContext, home string, secretStore *secret.Store, lastApplied *dotfile.Store) ([]planItem, []string, error) { + pItems, warnings, err := iterm2profiles.Plan(iterm2profiles.PlanInput{ + RepoRoot: ctx.RepoPath, + Home: home, + Guard: func(cand string) (string, error) { return safeRepoPath(ctx.RepoPath, cand) }, + Linter: iterm2profiles.PlutilLinter{}, + }) + if err != nil { + return nil, nil, err + } + + var items []planItem + planned := map[string]bool{} + for _, pi := range pItems { + planned[pi.Key] = true + rendered, missing, skip, rerr := renderSecrets(secretStore, pi.Content) + if rerr != nil { + return nil, nil, rerr + } + secretRouted := isSecretRouted(pi.Content) + if skip { + items = append(items, planItem{ + kind: kindFile, + domain: pi.Label, + target: pi.Target, + action: "skipped", + skip: true, + missing: missing, + secretRouted: secretRouted, + }) + continue + } + state, risky, reason, cerr := classifyItem(pi.Target, rendered, secretRouted, lastApplied) + if cerr != nil { + var kind *dotfile.UnexpectedKindError + if errors.As(cerr, &kind) { + warnings = append(warnings, fmt.Sprintf( + "%s skipped: %s is a symlink (or non-regular file) not managed by ferry — remove it, then re-run `ferry apply`", + pi.Label, pi.Target.Home)) + continue + } + return nil, nil, cerr + } + items = append(items, planItem{ + kind: kindFile, + domain: pi.Label, + target: pi.Target, + content: rendered, + state: state, + risky: risky, + riskReason: reason, + secretRouted: secretRouted, + }) + } + + warnings = append(warnings, descopeIterm2ProfilesWarnings(lastApplied, planned, true)...) + return items, warnings, nil +} + +// descopeIterm2ProfilesWarnings warns about Dynamic Profile targets ferry +// previously applied (recorded under the iterm2-profiles/ prefix in the last-applied +// store) that the current plan no longer covers. Files are left untouched (de-scope +// = warn, never auto-remove). With the whole domain unmanaged the records collapse +// into ONE warning; with the domain managed, each dropped target (a file removed +// from the tree) warns individually. A full `ferry restore` reverts them to their +// pre-ferry baseline. +func descopeIterm2ProfilesWarnings(store *dotfile.Store, planned map[string]bool, managed bool) []string { + var stale []string + for _, name := range store.RecordedNames() { + if !strings.HasPrefix(name, iterm2profiles.KeyPrefix) { + continue + } + if planned[name] { + continue + } + stale = append(stale, name) + } + if len(stale) == 0 { + return nil + } + if !managed { + return []string{fmt.Sprintf( + "warning: the iterm2-profiles domain is no longer managed; %d previously applied file(s) left as-is (now unmanaged). To revert them to their pre-ferry state: ferry restore", + len(stale))} + } + out := make([]string, 0, len(stale)) + for _, name := range stale { + out = append(out, fmt.Sprintf( + "warning: %s is no longer part of the iterm2-profiles plan; existing file left as-is (now unmanaged). To revert: ferry restore", + name)) + } + sort.Strings(out) + return out +} diff --git a/cmd/keybindings_plan.go b/cmd/keybindings_plan.go new file mode 100644 index 0000000..490a568 --- /dev/null +++ b/cmd/keybindings_plan.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/REPPL/ferry/internal/dotfile" + "github.com/REPPL/ferry/internal/keybindings" + "github.com/REPPL/ferry/internal/secret" +) + +// planKeybindings builds the per-target plan for the macOS key-bindings domain: +// the single repo source (keybindings/DefaultKeyBinding.dict) validated for +// format hygiene and expanded to one hash-classified (content, target) planItem +// the shared apply/status/diff machinery acts on, carried like a dotfile but +// repo-authoritative (no capture pass) and with no per-machine .local overlay. +// +// A target that is currently a symlink (or any non-regular file) is SKIPPED with +// a clear warning rather than classified: ferry materialises regular-file copies +// only. Repo-side reads route through safeRepoPath, so a symlinked source in the +// config repo is refused exactly like a dotfile's. The domain carries no secrets +// (secretStore is unused), so there is no render-or-skip pass. +func planKeybindings(ctx *cmdContext, home string, _ *secret.Store, lastApplied *dotfile.Store) ([]planItem, []string, error) { + kItems, warnings, err := keybindings.Plan(keybindings.PlanInput{ + RepoRoot: ctx.RepoPath, + Home: home, + Guard: func(cand string) (string, error) { return safeRepoPath(ctx.RepoPath, cand) }, + Linter: keybindings.PlutilLinter{}, + }) + if err != nil { + return nil, nil, err + } + + var items []planItem + planned := map[string]bool{} + for _, ki := range kItems { + planned[ki.Key] = true + // Key-bindings content is never secret-rendered (secretRouted=false); its + // risk comes from the three-way state alone (a first-touch adoption over an + // existing file, or a conflict). + state, risky, reason, cerr := classifyItem(ki.Target, ki.Content, false, lastApplied) + if cerr != nil { + var kind *dotfile.UnexpectedKindError + if errors.As(cerr, &kind) { + warnings = append(warnings, fmt.Sprintf( + "%s skipped: %s is a symlink (or non-regular file) not managed by ferry — remove it, then re-run `ferry apply`", + ki.Label, ki.Target.Home)) + continue + } + return nil, nil, cerr + } + items = append(items, planItem{ + kind: kindFile, + domain: ki.Label, + target: ki.Target, + content: ki.Content, + state: state, + risky: risky, + riskReason: reason, + }) + } + + warnings = append(warnings, descopeKeybindingsWarnings(lastApplied, planned, true)...) + return items, warnings, nil +} + +// descopeKeybindingsWarnings warns about a key-bindings target ferry previously +// applied (recorded under the keybindings/ prefix in the last-applied store) that +// the current plan no longer covers. The file is left untouched (de-scope = warn, +// never auto-remove). With the domain unmanaged the record collapses into ONE +// warning; with the domain managed but the source removed, the dropped target +// warns. A full `ferry restore` reverts it to its pre-ferry baseline (the file +// deploys through the backup engine, like a dotfile). +func descopeKeybindingsWarnings(store *dotfile.Store, planned map[string]bool, managed bool) []string { + var stale []string + for _, name := range store.RecordedNames() { + if !strings.HasPrefix(name, keybindings.KeyPrefix) { + continue + } + if planned[name] { + continue + } + stale = append(stale, name) + } + if len(stale) == 0 { + return nil + } + if !managed { + return []string{fmt.Sprintf( + "warning: the keybindings domain is no longer managed; %d previously applied file(s) left as-is (now unmanaged). To revert them to their pre-ferry state: ferry restore", + len(stale))} + } + out := make([]string, 0, len(stale)) + for _, name := range stale { + out = append(out, fmt.Sprintf( + "warning: %s is no longer part of the keybindings plan; existing file left as-is (now unmanaged). To revert: ferry restore", + name)) + } + // The keybindings/ namespace only ever holds one key, so this is a no-op today; + // sorted anyway for byte-for-byte parity with the termcfg de-scope twin. + sort.Strings(out) + return out +} diff --git a/cmd/registry.go b/cmd/registry.go new file mode 100644 index 0000000..08564d7 --- /dev/null +++ b/cmd/registry.go @@ -0,0 +1,291 @@ +package cmd + +import ( + "github.com/REPPL/ferry/internal/domains" + "github.com/REPPL/ferry/internal/dotfile" + "github.com/REPPL/ferry/internal/secret" + "github.com/REPPL/ferry/internal/terminal" +) + +// buildRegistry assembles ferry's converged domain registry (fn-5): the ordered +// FileDomains (dotfiles, agents, terminals, keybindings, emacs, iterm2-profiles) +// reconciled through dotfile.ApplyContentDeferred, and the ordered ResourceDomains +// (iterm2, apple terminal) reconciled through the backup engine's Resource hook. +// The ORDER is load-bearing: it is exactly the sequence the pre-fn-5 dispatch ran +// the domains in, so plan/status/diff/capture OUTPUT ORDERING is unchanged. New +// FileDomains append AFTER the existing set, so existing ordering is preserved. +// It replaces the hardcoded IsManaged("dotfiles"/"agents"/"terminals") sequence +// and the {"iterm2","terminal"} literal that drove routing before convergence. +// +// The ResourceDomain constructions here are used only for enumeration (Name()) and +// restore registration (the engine replays the captured blob via Restore), so the +// import blob is nil and the runner/process seams are the production ones; apply +// builds a domain with the RENDERED export blob per-run via buildTerminalDomain. +func buildRegistry(ctx *cmdContext) domains.Registry { + return domains.Registry{ + FileDomains: []domains.FileDomain{ + dotfilesFileDomain{}, + agentsFileDomain{}, + termcfgFileDomain{}, + keybindingsFileDomain{}, + emacsFileDomain{}, + iterm2ProfilesFileDomain{}, + }, + ResourceDomains: []domains.ResourceDomain{ + terminal.NewITerm2(nil, terminal.ExecRunner{}, terminal.ExecProcessController{}), + terminal.NewAppleTerminal(nil, terminal.ExecRunner{}), + }, + } +} + +// filePlanner is the cmd-side extension of domains.FileDomain: it adds the +// full-fidelity planItems / descopeUnmanaged surface the read/apply plan needs. +// The frozen domains.FileItem deliberately cannot carry the three-way classify +// state, the missing-secret skip, or the guided-apply risk verdict, so the plan +// driver upcasts each registered FileDomain to this interface and produces the +// rich planItems here — the same per-domain planners (planDotfiles / planAgents +// / planTerminals) the direct unit tests still drive, so behaviour is preserved +// byte-for-byte. +type filePlanner interface { + domains.FileDomain + // planItems expands the managed domain into the read/apply plan items, with + // secrets rendered, three-way state classified, and the risk verdict computed + // — exactly what the pre-fn-5 per-domain planners returned. + planItems(ctx *cmdContext, home string, secretStore *secret.Store, lastApplied *dotfile.Store) ([]planItem, []string, error) + // descopeUnmanaged returns the de-scope warnings emitted when this domain is + // OUT of scope (previously-applied targets left as-is). Dotfiles return nil + // here: their de-scope is emitted at the END of the plan (after the preference + // domains) by descopeDotfileWarnings, preserving warning order. + descopeUnmanaged(ctx *cmdContext, lastApplied *dotfile.Store) []string +} + +// fileItemsFromPlanItems is the shared implementation of the frozen +// domains.FileDomain.Plan surface: it runs a domain's rich planItems and +// projects the result down to the frozen FileItem shape (content -> target, +// secrets already rendered), dropping the missing-secret skips per the interface +// contract. Reusing the SAME per-domain planner the driver uses means the frozen +// Plan surface never duplicates a domain's composition logic — the sole reason +// the freeze can prove the interface expresses each domain without a re-cut. It +// opens a throwaway read-only last-applied store; the classification it drives is +// discarded here (only Content/Target/Exec/SecretRouted survive into a FileItem). +func fileItemsFromPlanItems(fp filePlanner, in domains.PlanInput) ([]domains.FileItem, []string, error) { + lastApplied, err := dotfile.OpenStoreReadOnly() + if err != nil { + return nil, nil, err + } + ctx := &cmdContext{RepoPath: in.RepoRoot, Scope: in.Scope} + pits, warnings, err := fp.planItems(ctx, in.Home, in.Secrets, lastApplied) + if err != nil { + return nil, nil, err + } + var items []domains.FileItem + for _, it := range pits { + if it.skip { + continue // frozen contract: a missing secret drops the item, never a literal placeholder + } + items = append(items, domains.FileItem{ + Key: it.target.Name, + Label: it.domain, + Target: it.target, + Content: it.content, + Exec: it.execBit, + SecretRouted: it.secretRouted, + }) + } + return items, warnings, nil +} + +// usesIncludeSidecar reports whether the dotfiles domain composes a bare dotfile +// name's per-machine overlay as an include-style SIDECAR (zsh: shared ~/.zshrc +// `source`s ~/.zshrc.local last) rather than a whole-file replace. It is the +// DATA-DRIVEN replacement for the old isZsh() oracle: the decision now comes +// from the dotfiles FileDomain's Overlay(key), the single source of truth the +// two-strip contract keys its trigger off. +func usesIncludeSidecar(bare string) bool { + return dotfilesFileDomain{}.Overlay(bare) == dotfile.OverlayIncludeSidecar +} + +// fileDomainCaptures reports whether the named FileDomain offers its targets back +// to capture (dotfiles/agents true, terminals false). Capture drives its +// per-domain passes off this so termcfg's deliberate no-capture asymmetry is a +// registry fact, not a hand-coded omission. +func fileDomainCaptures(reg domains.Registry, name string) bool { + for _, fd := range reg.FileDomains { + if fd.Name() == name { + return fd.Captures() + } + } + return false +} + +// --- dotfiles FileDomain ----------------------------------------------------- + +// dotfilesFileDomain is the converged dotfiles domain: generic whole-file +// dotfiles PLUS the zsh include-sidecar split. It OWNS the sidecar-vs-whole-file +// overlay decision (Overlay), the single source of truth that replaced isZsh(). +type dotfilesFileDomain struct{} + +func (dotfilesFileDomain) Name() string { return "dotfiles" } + +// Overlay reports the per-machine overlay strategy for a bare dotfile name. The +// include-style names — the zsh family (zshrc/zshenv/zprofile) and tmux +// (tmux.conf) — compose their overlay as a sourced sidecar (their format has a +// real include point: shell `source`, tmux `source-file`); every other dotfile +// is a whole-file replace. This is the authoritative replacement for the isZsh() +// oracle, and the single trigger the two-strip contract keys off (via +// usesIncludeSidecar). +func (dotfilesFileDomain) Overlay(key string) dotfile.OverlayMode { + switch key { + case "zshrc", "zshenv", "zprofile", "tmux.conf", "gitconfig": + return dotfile.OverlayIncludeSidecar + } + return dotfile.OverlayWholeFileReplace +} + +func (dotfilesFileDomain) Captures() bool { return true } + +func (d dotfilesFileDomain) Plan(in domains.PlanInput) ([]domains.FileItem, []string, error) { + return fileItemsFromPlanItems(d, in) +} + +func (dotfilesFileDomain) planItems(ctx *cmdContext, home string, secretStore *secret.Store, lastApplied *dotfile.Store) ([]planItem, []string, error) { + return planDotfiles(ctx, home, secretStore, lastApplied) +} + +func (dotfilesFileDomain) descopeUnmanaged(*cmdContext, *dotfile.Store) []string { + // Dotfile de-scope is emitted at the END of the plan (after the preference + // domains) by descopeDotfileWarnings, so nothing is emitted inline here. + return nil +} + +// --- agents FileDomain ------------------------------------------------------- + +// agentsFileDomain is the converged agents domain (harness instructions + asset +// trees), reconciled like dotfiles and offered back to capture. +type agentsFileDomain struct{} + +func (agentsFileDomain) Name() string { return "agents" } +func (agentsFileDomain) Overlay(string) dotfile.OverlayMode { return dotfile.OverlayWholeFileReplace } +func (agentsFileDomain) Captures() bool { return true } + +func (d agentsFileDomain) Plan(in domains.PlanInput) ([]domains.FileItem, []string, error) { + return fileItemsFromPlanItems(d, in) +} + +func (agentsFileDomain) planItems(ctx *cmdContext, home string, _ *secret.Store, lastApplied *dotfile.Store) ([]planItem, []string, error) { + return planAgents(ctx, home, lastApplied) +} + +func (agentsFileDomain) descopeUnmanaged(_ *cmdContext, lastApplied *dotfile.Store) []string { + return descopeAgentsWarnings(lastApplied, nil, false) +} + +// --- config-file terminals FileDomain ---------------------------------------- + +// termcfgFileDomain is the converged config-file terminal domain (alacritty, +// kitty, wezterm, …), carried like a dotfile but repo-authoritative on capture: +// Captures() is FALSE, preserving the deliberate no-capture asymmetry. +type termcfgFileDomain struct{} + +func (termcfgFileDomain) Name() string { return "terminals" } +func (termcfgFileDomain) Overlay(string) dotfile.OverlayMode { return dotfile.OverlayWholeFileReplace } +func (termcfgFileDomain) Captures() bool { return false } + +func (d termcfgFileDomain) Plan(in domains.PlanInput) ([]domains.FileItem, []string, error) { + return fileItemsFromPlanItems(d, in) +} + +func (termcfgFileDomain) planItems(ctx *cmdContext, home string, secretStore *secret.Store, lastApplied *dotfile.Store) ([]planItem, []string, error) { + return planTerminals(ctx, home, secretStore, lastApplied) +} + +func (termcfgFileDomain) descopeUnmanaged(_ *cmdContext, lastApplied *dotfile.Store) []string { + return descopeTerminalConfigWarnings(lastApplied, nil, false) +} + +// --- macOS key-bindings FileDomain ------------------------------------------- + +// keybindingsFileDomain is the converged macOS Cocoa key-bindings domain: the +// single ~/Library/KeyBindings/DefaultKeyBinding.dict carried like a dotfile but +// repo-authoritative on capture (Captures() is FALSE, like termcfg — you edit +// the dict in the repo and apply deploys it; a live edit shows as drift and apply +// skips it). It has no per-machine .local overlay (keyboard behaviour is +// machine-agnostic, and the old-style dict format has no include/merge layer). +type keybindingsFileDomain struct{} + +func (keybindingsFileDomain) Name() string { return "keybindings" } +func (keybindingsFileDomain) Overlay(string) dotfile.OverlayMode { + return dotfile.OverlayWholeFileReplace +} +func (keybindingsFileDomain) Captures() bool { return false } + +func (d keybindingsFileDomain) Plan(in domains.PlanInput) ([]domains.FileItem, []string, error) { + return fileItemsFromPlanItems(d, in) +} + +func (keybindingsFileDomain) planItems(ctx *cmdContext, home string, secretStore *secret.Store, lastApplied *dotfile.Store) ([]planItem, []string, error) { + return planKeybindings(ctx, home, secretStore, lastApplied) +} + +func (keybindingsFileDomain) descopeUnmanaged(_ *cmdContext, lastApplied *dotfile.Store) []string { + return descopeKeybindingsWarnings(lastApplied, nil, false) +} + +// --- Emacs FileDomain -------------------------------------------------------- + +// emacsFileDomain is the converged Emacs configuration domain: the config repo's +// emacs/ tree fanned out file-by-file under ~/.emacs.d/, carried like a dotfile +// with the per-machine local/emacs/ overlay winning per file, but +// repo-authoritative on capture (Captures() is FALSE, like termcfg — edit the +// repo source and apply deploys it; a live edit shows as drift and apply skips +// it). Volatile, machine-generated paths (elpa/, *.elc, session state, …) are +// pruned by the domain and never deployed. +type emacsFileDomain struct{} + +func (emacsFileDomain) Name() string { return "emacs" } +func (emacsFileDomain) Overlay(string) dotfile.OverlayMode { return dotfile.OverlayWholeFileReplace } +func (emacsFileDomain) Captures() bool { return false } + +func (d emacsFileDomain) Plan(in domains.PlanInput) ([]domains.FileItem, []string, error) { + return fileItemsFromPlanItems(d, in) +} + +func (emacsFileDomain) planItems(ctx *cmdContext, home string, secretStore *secret.Store, lastApplied *dotfile.Store) ([]planItem, []string, error) { + return planEmacs(ctx, home, secretStore, lastApplied) +} + +func (emacsFileDomain) descopeUnmanaged(_ *cmdContext, lastApplied *dotfile.Store) []string { + return descopeEmacsConfigWarnings(lastApplied, nil, false) +} + +// --- iTerm2 Dynamic Profiles FileDomain -------------------------------------- + +// iterm2ProfilesFileDomain is the converged iTerm2 Dynamic Profiles domain: the +// config repo's iterm2/DynamicProfiles/ tree of *.json files fanned out +// file-by-file under ~/Library/Application Support/iTerm2/DynamicProfiles/, carried +// like a dotfile with the per-machine local/iterm2-profiles/ overlay winning per +// file, but repo-authoritative on capture (Captures() is FALSE, like termcfg — edit +// the repo JSON and apply deploys it; a live edit shows as drift and apply skips +// it). Each file is validated (JSON validity everywhere, plutil -convert on macOS — +// not -lint, which rejects valid JSON) before it lands; a profile's frozen Guid is +// byte-preserved (ferry never rewrites +// the JSON). It is distinct from the iTerm2 global-plist ResourceDomain ("iterm2"). +type iterm2ProfilesFileDomain struct{} + +func (iterm2ProfilesFileDomain) Name() string { return "iterm2-profiles" } +func (iterm2ProfilesFileDomain) Overlay(string) dotfile.OverlayMode { + return dotfile.OverlayWholeFileReplace +} +func (iterm2ProfilesFileDomain) Captures() bool { return false } + +func (d iterm2ProfilesFileDomain) Plan(in domains.PlanInput) ([]domains.FileItem, []string, error) { + return fileItemsFromPlanItems(d, in) +} + +func (iterm2ProfilesFileDomain) planItems(ctx *cmdContext, home string, secretStore *secret.Store, lastApplied *dotfile.Store) ([]planItem, []string, error) { + return planIterm2Profiles(ctx, home, secretStore, lastApplied) +} + +func (iterm2ProfilesFileDomain) descopeUnmanaged(_ *cmdContext, lastApplied *dotfile.Store) []string { + return descopeIterm2ProfilesWarnings(lastApplied, nil, false) +} diff --git a/cmd/registry_test.go b/cmd/registry_test.go new file mode 100644 index 0000000..aef0df8 --- /dev/null +++ b/cmd/registry_test.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "testing" + + "github.com/REPPL/ferry/internal/domains" + "github.com/REPPL/ferry/internal/dotfile" +) + +// TestFileDomainOverlay pins each converged FileDomain's overlay strategy — the +// DATA-DRIVEN replacement for the deleted isZsh() oracle. Only the include-style +// dotfile names (the zsh family, tmux, and git — whose formats have a real +// include point) compose their per-machine overlay as a sourced sidecar; every +// other name (and every other domain) is a whole-file replace. +func TestFileDomainOverlay(t *testing.T) { + dot := dotfilesFileDomain{} + for _, name := range []string{"zshrc", "zshenv", "zprofile", "tmux.conf", "gitconfig"} { + if got := dot.Overlay(name); got != dotfile.OverlayIncludeSidecar { + t.Errorf("dotfiles Overlay(%q) = %q, want OverlayIncludeSidecar", name, got) + } + } + for _, name := range []string{"npmrc", "vimrc"} { + if got := dot.Overlay(name); got != dotfile.OverlayWholeFileReplace { + t.Errorf("dotfiles Overlay(%q) = %q, want OverlayWholeFileReplace", name, got) + } + } + // agents and config-file terminals never use the sidecar split. + if got := (agentsFileDomain{}).Overlay("anything"); got != dotfile.OverlayWholeFileReplace { + t.Errorf("agents Overlay = %q, want OverlayWholeFileReplace", got) + } + if got := (termcfgFileDomain{}).Overlay("anything"); got != dotfile.OverlayWholeFileReplace { + t.Errorf("terminals Overlay = %q, want OverlayWholeFileReplace", got) + } + if got := (keybindingsFileDomain{}).Overlay("anything"); got != dotfile.OverlayWholeFileReplace { + t.Errorf("keybindings Overlay = %q, want OverlayWholeFileReplace", got) + } + if got := (emacsFileDomain{}).Overlay("anything"); got != dotfile.OverlayWholeFileReplace { + t.Errorf("emacs Overlay = %q, want OverlayWholeFileReplace", got) + } + if got := (iterm2ProfilesFileDomain{}).Overlay("anything"); got != dotfile.OverlayWholeFileReplace { + t.Errorf("iterm2-profiles Overlay = %q, want OverlayWholeFileReplace", got) + } +} + +// TestUsesIncludeSidecar pins the isZsh() replacement helper: it delegates to the +// dotfiles FileDomain's Overlay(), so exactly the zsh names route to the sidecar. +func TestUsesIncludeSidecar(t *testing.T) { + for _, bare := range []string{"zshrc", "zshenv", "zprofile", "tmux.conf", "gitconfig"} { + if !usesIncludeSidecar(bare) { + t.Errorf("usesIncludeSidecar(%q) = false, want true", bare) + } + } + for _, bare := range []string{"bashrc", "zsh", "zshrc.local"} { + if usesIncludeSidecar(bare) { + t.Errorf("usesIncludeSidecar(%q) = true, want false", bare) + } + } +} + +// TestFileDomainCaptures pins the capture asymmetry: dotfiles and agents are +// offered back to capture; config-file terminals are repo-authoritative (no +// capture pass), which is exactly why Captures() is false for them. +func TestFileDomainCaptures(t *testing.T) { + cases := map[string]struct { + fd domains.FileDomain + want bool + }{ + "dotfiles": {dotfilesFileDomain{}, true}, + "agents": {agentsFileDomain{}, true}, + "terminals": {termcfgFileDomain{}, false}, + "keybindings": {keybindingsFileDomain{}, false}, + "emacs": {emacsFileDomain{}, false}, + "iterm2-profiles": {iterm2ProfilesFileDomain{}, false}, + } + for name, tc := range cases { + if tc.fd.Name() != name { + t.Errorf("Name() = %q, want %q", tc.fd.Name(), name) + } + if got := tc.fd.Captures(); got != tc.want { + t.Errorf("%s Captures() = %v, want %v", name, got, tc.want) + } + } +} + +// TestBuildRegistryOrder pins the LOAD-BEARING registry order: FileDomains are +// [dotfiles, agents, terminals, keybindings] and ResourceDomains are +// [iterm2, terminal], matching the pre-fn-5 dispatch sequence so +// plan/status/diff/capture output ordering is unchanged (keybindings appends +// last, after terminals, so existing ordering is unchanged). Each FileDomain must +// also satisfy the cmd filePlanner upcast the plan driver relies on. +func TestBuildRegistryOrder(t *testing.T) { + reg := buildRegistry(&cmdContext{RepoPath: t.TempDir()}) + + wantFile := []string{"dotfiles", "agents", "terminals", "keybindings", "emacs", "iterm2-profiles"} + if len(reg.FileDomains) != len(wantFile) { + t.Fatalf("FileDomains len = %d, want %d", len(reg.FileDomains), len(wantFile)) + } + for i, name := range wantFile { + if reg.FileDomains[i].Name() != name { + t.Errorf("FileDomains[%d] = %q, want %q", i, reg.FileDomains[i].Name(), name) + } + if _, ok := reg.FileDomains[i].(filePlanner); !ok { + t.Errorf("FileDomains[%d] (%s) does not implement filePlanner", i, name) + } + } + + wantResource := []string{"iterm2", "terminal"} + if len(reg.ResourceDomains) != len(wantResource) { + t.Fatalf("ResourceDomains len = %d, want %d", len(reg.ResourceDomains), len(wantResource)) + } + for i, name := range wantResource { + if reg.ResourceDomains[i].Name() != name { + t.Errorf("ResourceDomains[%d] = %q, want %q", i, reg.ResourceDomains[i].Name(), name) + } + } +} diff --git a/cmd/restore.go b/cmd/restore.go index 80434d6..b9687b8 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -342,14 +342,23 @@ func registerTerminalDomains(ctx *cmdContext) error { if err != nil { return err } - eng.Register(terminal.NewITerm2(filepath.Join(ctx.RepoPath, "iterm2"), terminal.ExecRunner{})) - eng.Register(terminal.NewAppleTerminal(nil, terminal.ExecRunner{})) + // Register every ResourceDomain from the converged registry (fn-5), in its + // load-bearing order, instead of the hardcoded iTerm2 + Apple Terminal pair. + // The runner/blob are irrelevant for restore (the engine replays the captured + // blob via Restore), so the registry's default construction suffices. + for _, rd := range buildRegistry(ctx).ResourceDomains { + eng.Register(rd) + } return nil } // restorePackages uninstalls ONLY the packages ferry recorded as self-installed // (the newline list apply --deps persisted). Homebrew / the package manager // itself is NEVER removed. An empty or absent record means there is nothing to do. +// +// npm globals are deliberately OUT of this rail: apply --deps reconciles them +// install-only and records nothing for them (mirroring the Homebrew cleanup-out +// decision), so restore --packages never uninstalls an npm global. func restorePackages(out io.Writer) error { pkgs, err := readInstalledSet() if err != nil { diff --git a/cmd/status.go b/cmd/status.go index 774c964..ad3ce58 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -11,6 +11,7 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/REPPL/ferry/internal/deps" "github.com/REPPL/ferry/internal/dotfile" "github.com/REPPL/ferry/internal/platform" "github.com/REPPL/ferry/internal/terminal" @@ -78,22 +79,22 @@ func runStatus(c *cobra.Command, _ []string) error { } continue } - // Agents targets share the three-way states but carry the domain's - // repo-authoritative guidance (capture never ingests them in v1), so - // their drift lines point at the repo copy, not `ferry capture`. - if it.kind == kindAgents { - reported++ + // Converged FileDomain items (fn-5). Agents targets carry the domain's + // repo-authoritative guidance (capture never ingests them in v1), so their + // drift lines point at the repo copy, not `ferry capture`; dotfiles and + // config-file terminals share the three-way status rendering with capture + // guidance. + if it.kind != kindFile { + continue + } + reported++ + + if it.fileDomain == "agents" { if reportAgentsStatus(out, colour, it.domain, it.state) { drifted++ } continue } - // Config-file terminal targets are carried like dotfiles, so they share - // the three-way status rendering (capture guidance, not repo-authoritative). - if it.kind != kindDotfile && it.kind != kindOverlay && it.kind != kindTerminal { - continue - } - reported++ // A missing secret means apply would SKIP this target — report it as // blocked (held back), NOT as drift, so it is not a false action item. @@ -107,6 +108,14 @@ func runStatus(c *cobra.Command, _ []string) error { } } + // Deps drift is an INDEPENDENT, read-only status pass (deps is a side-channel + // outside the FileDomain/ResourceDomain plan, so it is not in buildPlan). It + // surfaces Homebrew Brewfile drift and npm-globals drift when their domains are + // managed, WITHOUT installing anything or rewriting a manifest. + dr, dd := reportDepsStatus(ctx, out, colour) + reported += dr + drifted += dd + if reported == 0 { fmt.Fprintln(out, "no managed config in scope; nothing to report") return nil @@ -164,7 +173,7 @@ func terminalLiveDiffers(repo, domain string) bool { var d *terminal.PreferenceDomain switch domain { case "iterm2": - d = terminal.NewITerm2(filepath.Join(repo, "iterm2"), terminal.ExecRunner{}) + d = terminal.NewITerm2(nil, terminal.ExecRunner{}, terminal.ExecProcessController{}) default: // "terminal" / Apple Terminal d = terminal.NewAppleTerminal(nil, terminal.ExecRunner{}) } @@ -187,17 +196,13 @@ func terminalLiveDiffers(repo, domain string) bool { } repoBytes, _ := os.ReadFile(statusSrc) if domain == "iterm2" { - // iTerm2 ONLY: compare LIKE-FOR-LIKE with the machine-local control keys - // (PrefsCustomFolder / LoadPrefsFromCustomFolder) STRIPPED from BOTH sides. Those - // keys are how ferry POINTS iTerm2 at the repo (apply sets them via `defaults - // write`), NOT user prefs — `defaults export` can carry them, so status must - // exclude them or it would mis-report drift solely because the live domain holds - // the pointer ferry itself wrote. The repo plist apply deploys never contains - // them; stripping both sides compares the actual settings. The exact - // custom-folder-vs-export round-trip fidelity is Layer-2-deferred per - // AC-terminal-config (see terminal.StripITerm2ControlKeys). - liveBlob = terminal.StripITerm2ControlKeys(liveBlob) - repoBytes = terminal.StripITerm2ControlKeys(repoBytes) + // iTerm2 ONLY: compare LIKE-FOR-LIKE by reducing BOTH sides to the allowlisted + // global keys. `defaults export` carries volatile machine state (window + // geometry, NoSync* flags, the retired custom-prefs pointer) that the repo plist + // never contains; filtering both sides compares the actual carried settings and + // never mis-reports drift from a volatile key (see terminal.FilterAllowlist). + liveBlob = terminal.FilterAllowlist(liveBlob) + repoBytes = terminal.FilterAllowlist(repoBytes) } return string(repoBytes) != string(liveBlob) } @@ -249,6 +254,58 @@ func reportAgentsStatus(out io.Writer, colour func(*color.Color, string) string, } } +// reportDepsStatus prints the read-only deps drift lines and returns how many +// deps domains it reported and how many drifted. It reuses the SAME dump/read +// machinery apply/capture use, but read-only: Homebrew drift goes through +// `brew bundle dump --file=-` (stdout, writes no file) and npm-globals drift +// through `npm ls -g` — neither installs a package nor rewrites a manifest. Each +// managed deps domain is gated on the SAME Scope.IsManaged predicate as capture, +// keeping the managed surface consistent across status/capture (`apply --deps` +// stays the explicit install override). A hard error or an absent tool is a +// clean, non-drift note — status never fails on the read-only path. +func reportDepsStatus(ctx *cmdContext, out io.Writer, colour func(*color.Color, string) string) (reported, drifted int) { + depsDir := filepath.Join(ctx.RepoPath, "deps") + + if ctx.Scope.IsManaged("brew") { + reported++ + drift, ok, err := deps.BrewDrift(depsDir, deps.ExecRunner{}) + switch { + case err != nil: + fmt.Fprintf(out, " %-22s %s (%v)\n", "brew", colour(colYellow, "skipped"), err) + case !ok: + fmt.Fprintf(out, " %-22s %s (no Homebrew on this machine)\n", "brew", colour(colGreen, "n/a")) + case drift.Empty(): + fmt.Fprintf(out, " %-22s %s\n", "brew", colour(colGreen, "clean")) + default: + drifted++ + fmt.Fprintf(out, " %-22s %s (%d to capture, %d to install; `ferry capture` / `ferry apply --deps`)\n", + "brew", colour(colYellow, "drifted"), len(drift.Added), len(drift.Removed)) + } + } + + if ctx.Scope.IsManaged("npm-globals") { + reported++ + switch { + case !platform.HasNpm(): + fmt.Fprintf(out, " %-22s %s (npm not installed)\n", "npm-globals", colour(colGreen, "n/a")) + default: + drift, err := deps.NpmGlobalsDrift(depsDir, deps.ExecRunner{}) + switch { + case err != nil: + fmt.Fprintf(out, " %-22s %s (%v)\n", "npm-globals", colour(colYellow, "skipped"), err) + case drift.Empty(): + fmt.Fprintf(out, " %-22s %s\n", "npm-globals", colour(colGreen, "clean")) + default: + drifted++ + fmt.Fprintf(out, " %-22s %s (%d to capture, %d to install; `ferry capture` / `ferry apply --deps`)\n", + "npm-globals", colour(colYellow, "drifted"), len(drift.Added), len(drift.Removed)) + } + } + } + + return reported, drifted +} + // reportStatus prints one git-status-like line for a target and reports whether // it counts as drift (a non-clean state the user should act on). Names the target // and its state: clean / locally-drifted (capture candidate) / repo-ahead / diff --git a/cmd/terminals_plan.go b/cmd/terminals_plan.go index 318b315..98ee601 100644 --- a/cmd/terminals_plan.go +++ b/cmd/terminals_plan.go @@ -61,7 +61,7 @@ func planTerminals(ctx *cmdContext, home string, secretStore *secret.Store, last secretRouted := isSecretRouted(ti.Content) if skip { items = append(items, planItem{ - kind: kindTerminal, + kind: kindFile, domain: ti.Label, target: ti.Target, action: "skipped", @@ -87,7 +87,7 @@ func planTerminals(ctx *cmdContext, home string, secretStore *secret.Store, last return nil, nil, cerr } items = append(items, planItem{ - kind: kindTerminal, + kind: kindFile, domain: ti.Label, target: ti.Target, content: rendered, diff --git a/cmd/wizard.go b/cmd/wizard.go index 7e3433d..fcc7d89 100644 --- a/cmd/wizard.go +++ b/cmd/wizard.go @@ -619,7 +619,7 @@ func printSeedPlanPreview(out io.Writer, plan *seedPlan) { if plan.original != nil && plan.shared != nil { future := plan.shared if len(plan.local) > 0 { - future = appendSourceDirective(plan.shared, ".zshrc.local") + future = appendSourceDirective(plan.shared, ".zshrc.local", shellDirective) } fmt.Fprintln(out, "--- diff: ~/.zshrc after first apply vs current (dropped lines removed; secrets masked) ---") hunks := diffHunks(plan.maskSecrets(string(plan.original)), plan.maskSecrets(string(future))) diff --git a/cmd/wizard_test.go b/cmd/wizard_test.go index 5efac14..0b4c90f 100644 --- a/cmd/wizard_test.go +++ b/cmd/wizard_test.go @@ -67,9 +67,6 @@ func (l *leakyPlugin) ApplyRepairs(blocks []plugin.Block, accepted []plugin.Find func (l *leakyPlugin) StarterQuestions() []plugin.Question { return nil } func (l *leakyPlugin) Starter(a plugin.Answers) ([]byte, error) { return []byte("# starter\n"), nil } func (l *leakyPlugin) Describe(b plugin.Block) string { return "synthetic block" } -func (l *leakyPlugin) Deploy() plugin.DeploySpec { - return plugin.DeploySpec{DotfileName: ".zshrc", Overlay: plugin.Sidecar} -} func leakyInputs(t *testing.T, p plugin.Plugin) *wizardInputs { t.Helper() diff --git a/docs/plans/2026-07-06-v0.7.0.md b/docs/plans/2026-07-06-v0.7.0.md index afc21d5..7cfdc2f 100644 --- a/docs/plans/2026-07-06-v0.7.0.md +++ b/docs/plans/2026-07-06-v0.7.0.md @@ -1,8 +1,9 @@ # ferry v0.7.0 — config plugins (git, Emacs, iTerm2, Homebrew, npm) on a converged registry -**Status:** signed off — all decisions resolved (§6); implementation-ready and -scheduled to begin with Phase A (fn-5) once credits return. Nothing here is -implemented yet. This revision supersedes the earlier v0.7.0 backlog draft: it +**Status:** shipped in v0.7.0 — all decisions resolved (§6) and delivered: the +fn-5 domain convergence landed first, then the seven config plugins on the +converged registry, plus the observability half. This revision supersedes the +earlier v0.7.0 backlog draft: it makes the seven config plugins the headline, **reverses that draft's emacs DROP**, and folds in a five-stream state-of-the-art pass plus a full code-feasibility map. The deferred later band (restore extras, GNOME/dconf, diff --git a/docs/reference/cli/ferry_export.md b/docs/reference/cli/ferry_export.md index 4ce69c1..445ad3b 100644 --- a/docs/reference/cli/ferry_export.md +++ b/docs/reference/cli/ferry_export.md @@ -13,7 +13,8 @@ and withheld if any are found, otherwise bundled. The result is a self-contained .zip you move to another account or machine and ingest with "ferry import". Secrets and the per-machine local layer are never included unless you pass --include-local. export prints the bundle's -SHA256 so you can verify the move with "ferry import --expect-sha256". +reproducible SHA256 — exporting the same tracked sources always yields the same +digest — so you can verify the move with "ferry import --expect-sha256". ``` ferry export [flags] diff --git a/docs/reference/commands.md b/docs/reference/commands.md index f20c763..33c0b74 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -16,10 +16,10 @@ Every command is run as `ferry ` (e.g. `ferry init`). | `capture` | Pull local changes back into the repo. Interactive: approve each change, route it *shared* (synced everywhere) or *local* (this machine only). For sources that reference stored secrets, capture compares against the rendered content and splices your edits back around the placeholders, so stored values never re-enter the repo and a store-routed secret never blocks its own round-trip. It also captures edits to deployed agent files (routing to their shared source or a `local/agents/` overlay, refusing a true divergence with a diff) and offers to adopt new agent-shaped files it finds; see [The agents domain](../explanation/agents.md). | | `sync` | Publish captured changes and pull remote ones for a managed repo, in one command. Integrates the remote first, never force-pushes, gates the whole push range for secrets, and leaves your machine unchanged on a conflict. Route-1 repos need `--allow-unmanaged`. Run `ferry apply` after to deploy pulled changes. | | `status` | Report config drift (what changed on this machine). | -| `doctor` | Report machine/tool health. | +| `doctor` | Report machine/tool health, and observe ferry's managed-target invariants read-only: no deployed target is a symlink (ferry deploys regular-file copies), none resolves under `~/.ssh`, and every target resolves inside `$HOME`. A genuine breach is reported `[fail]` and exits non-zero; a machine with nothing managed yet is advisory. | | `diff` | Preview what `apply` would change. | | `restore` | Reverse ferry's changes, returning the machine to its pre-ferry state from an automatic backup. | -| `export` | Write a portable, secret-scanned `.zip` bundle of the repo's tracked shared files for an offline move. Prints the bundle SHA256. Never includes secrets, `~/.ssh`, or the local layer (unless `--include-local`). | +| `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 `import --expect-sha256`. Never includes secrets, `~/.ssh`, or the local layer (unless `--include-local`). | | `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. | | `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 `.work/` handoff files. `--private` instead creates a `.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. | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 4f09805..ccc29d1 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -24,10 +24,14 @@ to ferry: it is never applied and never captured. [manage] dotfiles = [".zshrc", ".gitconfig"] brew = true -iterm2 = true +iterm2 = true # the iTerm2 global preferences; see iTerm2 below fonts = false # never sync fonts -agents = true # AI-agent instruction files; see agents.md -terminals = true # config-file terminal emulators (Alacritty, kitty, WezTerm) +agents = true # AI-agent instruction files; see agents.md +terminals = true # config-file terminal emulators (Alacritty, kitty, WezTerm) +keybindings = true # the macOS Cocoa key-bindings dict; see below +emacs = true # the Emacs configuration tree (~/.emacs.d/); see below +iterm2-profiles = true # iTerm2 Dynamic Profiles (JSON); see iTerm2 below +npm-globals = true # globally-installed npm packages; see Dependencies below [agents] devtree = "Development" # optional workspace layer, relative to $HOME @@ -87,14 +91,347 @@ per-machine colour scheme: an override at `local/terminals//` wins over the shared copy on the next `apply`, leaving every other file shared. +## iTerm2 + +iTerm2 is carried as **two** artefacts, because its profiles and its app-wide +preferences want different representations: + +| Artefact | `[manage]` key | Repo source | Home target | Mechanism | +|---|---|---|---|---| +| Dynamic Profiles | `iterm2-profiles` | `iterm2/DynamicProfiles/*.json` | `~/Library/Application Support/iTerm2/DynamicProfiles/` | file copy | +| Global preferences | `iterm2` | `iterm2/com.googlecode.iterm2.plist` | the `com.googlecode.iterm2` domain | `defaults import` | + +### Profiles — Dynamic Profiles JSON + +iTerm2 reads any `*.json` file in its `DynamicProfiles/` folder and live-reloads +it, so profiles are carried as plain, reviewable, mergeable JSON — a config-file +domain like the terminal emulators above. Enable it with `iterm2-profiles = true` +and commit one JSON file per profile set under `iterm2/DynamicProfiles/`. Each +file deploys as a regular-file copy reconciled by hash; ferry never symlinks it. + +The domain is **repo-authoritative**: edit the JSON in the repo and `apply` +deploys it. A live edit to a deployed file shows as drift, and `apply` skips it +rather than overwriting your change — set `"Rewritable": false` in the profile so +iTerm2 itself will not rewrite it either. There is no capture pass. + +A profile's `"Guid"` is its **frozen identity**: ferry copies the JSON +byte-for-byte and never generates or rewrites a GUID (a changed GUID would orphan +the deployed profile). Each file is validated before it lands — as JSON on every +platform, and additionally through `plutil` on macOS — because **one malformed +file disables all of iTerm2's dynamic profiles**; a file that fails validation is +warned about and skipped, never deployed. + +**Per-machine divergence.** The `.local` layer applies per file: a file at +`local/iterm2-profiles/.json` wins over the shared +`iterm2/DynamicProfiles/.json`, and a file present *only* under +`local/iterm2-profiles/` deploys as a machine-only profile — the natural home for +a child Dynamic Profile that sets `"Dynamic Profile Parent GUID"` to a shared +parent's GUID and overrides only, say, the font size. ferry performs no JSON +surgery; the overlay is purely file-level. + +### Global preferences — filtered plist + +App-wide iTerm2 settings that live outside any profile are carried as an +**allowlisted** `defaults` plist. Enable it with `iterm2 = true`. On `capture`, +ferry exports the live `com.googlecode.iterm2` domain and keeps **only** an +allowlisted set of stable, machine-agnostic global keys (quit/close prompts, tab +and window chrome behaviour, dimming, clipboard behaviour, the auto-update +preference, the default-profile pointer). Everything else is dropped, so volatile +machine state — window geometry, one-shot `NoSync…` dialog flags — can never reach +the repo. The filtered plist is committed at `iterm2/com.googlecode.iterm2.plist`. +(The allowlist is a curated starting point; extend it in your own repo review.) + +On `apply`, ferry imports that committed plist into the domain with +`defaults import`, which **replaces** the whole `com.googlecode.iterm2` domain +with the carried set — so any global key you have not allowlisted is reset. The +dropped keys are the volatile ones (window geometry regenerates, `NoSync…` dialog +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.) + +The `.local` layer applies whole-domain: a committed +`local/iterm2/com.googlecode.iterm2.plist` is imported instead of the shared copy +on machines that need a wholesale-divergent global set. + +## tmux + +tmux is carried as a dotfile: declare `".tmux.conf"` in the `dotfiles` list and +commit your shared config at `dotfiles/tmux.conf`. Like the zsh shell files, +`~/.tmux.conf` is an **include-sidecar** dotfile — it has a real include point, +so its per-machine `.local` layer is a separate sourced file rather than a +whole-file swap. When a per-machine overlay exists at `local/tmux/tmux.conf.local`, +`apply` deploys the shared `~/.tmux.conf` with ferry's directive appended **last** +so the sidecar wins: + +```tmux +# ferry: per-machine overlay, sourced last so it wins +source-file -q ~/.tmux.conf.local +``` + +`source-file -q` sources the per-machine file when it exists and stays quiet when +it does not — the tmux analogue of the shell `[ -f … ] && source …` guard. On +`capture`, ferry strips that injected directive before writing the shared source, +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. + +## git + +git is carried as a dotfile: declare `".gitconfig"` in the `dotfiles` list and +commit your shared config at `dotfiles/gitconfig`. Like the zsh and tmux files, +`~/.gitconfig` is an **include-sidecar** dotfile, but the injected directive is +git's own native two-line `[include]` block, appended **last** so git +last-wins-merges the machine-local file: + +```ini +# ferry: per-machine overlay, sourced last so it wins +[include] + path = ~/.gitconfig.local +``` + +git applies includes inline as it reads the file, so an `[include]` placed after +every existing `[include]`/`[includeIf]` block gives `~/.gitconfig.local` the +final say — a native equivalent of ferry's overlay. On `capture`, ferry strips +that injected block (header and `path` line) before writing the shared source, so +the committed `dotfiles/gitconfig` never carries ferry's own boilerplate. + +**Identity is never shared.** A machine's commit identity must never travel to +another machine's `~/.gitconfig`, so ferry forces these keys — and every +`[includeIf "gitdir:…"]` block (per-directory identity) — into the never-shared +`~/.gitconfig.local` layer, dropping them from the shared `~/.gitconfig` and the +shared repo: + +- `user.email`, `user.name` — commit authorship; +- `user.signingkey` — the signing key id or public-key path (identity, not a + secret: it stays as plaintext in the local file and is never routed to the + secret store); +- `gpg.program` — the local signing binary; +- `credential.helper` — the local credential backend; +- `credential..username` — the account login name (account identity, kept + as plaintext in the local file and never routed to the secret store). + +The firewall runs on both `apply` and `capture`: even a `dotfiles/gitconfig` that +mistakenly commits an identity key has it stripped before it can deploy into the +shared `~/.gitconfig`. Put your identity in `local/git/gitconfig.local` (which +`apply` materialises to `~/.gitconfig.local`), where each machine keeps its own. + +**`credential.helper` must be `osxkeychain`.** ferry warns about +`credential.helper = store`, which writes credentials as **plaintext** into +`~/.git-credentials`, and never carries it. ferry never reads or writes +`~/.git-credentials` (it is treated like `~/.ssh` — untouchable). + +**Tokens in URLs and headers.** A literal token embedded in a `url.*.insteadOf` +value (`https://@host/` or `https://oauth2:@host/`) or after +`Bearer`/`Basic` in an `http.extraHeader` value is caught on capture: only the +token is routed to the out-of-repo secret store and replaced with a +`{{ferry.secret …}}` placeholder — the surrounding URL scheme and host, the +`user:` prefix, and the `Authorization: Bearer ` prefix stay byte-for-byte +intact. `apply` renders the placeholder back to the real value (deployed `0600`). +An environment reference such as `https://${GIT_TOKEN}@host/` is **not** a literal +secret (git expands it at read time), so it is carried to the shared repo +verbatim. + +## npm registry auth (`~/.npmrc`) + +npm's user config is carried as a whole-file dotfile: declare `".npmrc"` in the +`dotfiles` list and commit your shared config at `dotfiles/npmrc`. Unlike the +zsh, tmux, and git files, `~/.npmrc` is a plain **whole-file** dotfile — it has +no include point, so it gains no ferry directive and is reconciled by hash like +any other dotfile. Only the **user-level** `~/.npmrc` is carried; ferry never +manages a per-project (repo-level) `.npmrc`, which belongs with its project and +routinely holds project-specific registry pins. + +**The registry auth token.** A registry auth line — `//registry.example.com/:_authToken=…`, +`:_auth=…`, or `:_password=…` — carries a credential that must never reach the +shared repo. Two patterns keep it out: + +- **Recommended — an environment reference.** Write the token as + `//registry.npmjs.org/:_authToken=${NPM_TOKEN}` and export `NPM_TOKEN` on each + machine. npm expands the environment when it reads the file, so the real token + never lives in the config at all; ferry recognises `${…}` as a non-secret and + carries the line to the shared repo verbatim. +- **Alternative — a stored literal.** If a machine writes a literal token into + `~/.npmrc`, `capture` detects it and blocks the change from the repo entirely + (both shared and the gitignored `local/` layer). Route it to the out-of-repo + secret store and ferry leaves a `{{ferry.secret …}}` placeholder in the + committed file in its place; `apply` renders the placeholder back to the real + token so npm reads it, deploying the file `0600`. + +The token is never written as plaintext into the committed repo — the same +discipline git applies to `~/.git-credentials`, which ferry refuses to carry. + +This section covers the `~/.npmrc` config file only; the machine's +globally-installed npm **packages** are a separate managed domain — see +[npm globals](#npm-globals) under Dependencies. + +## macOS key bindings + +The macOS Cocoa text system reads a single key-bindings file at app launch to +remap keys system-wide (for example, binding ⌥f to move a word +forward in every Cocoa text field). ferry carries that one file: + +| Repo source | Home target | +|---|---| +| `keybindings/DefaultKeyBinding.dict` | `~/Library/KeyBindings/DefaultKeyBinding.dict` | + +Enable the domain with `keybindings = true` under `[manage]`, then commit your +dict at `keybindings/DefaultKeyBinding.dict`. With the domain enabled but no +source committed, nothing deploys. The file is deployed as a regular-file copy, +reconciled by hash like every other target — ferry never symlinks it. + +The domain is **repo-authoritative**: edit the dict in the config repo and +`apply` deploys it. A live edit to the deployed file shows as drift, and `apply` +skips it rather than overwriting your change — update the repo copy (or +`ferry apply --force`) to reconcile. There is no capture pass and no `.local` +overlay: keyboard behaviour is machine-agnostic, and the old-style dict format +has no include or merge layer, so a per-machine variant would be a whole-file +swap that earns nothing. A genuinely divergent machine keeps a separate dict. + +**Reloading.** The bindings load when an app launches, so an apply takes effect +on the *next* launch of each app — relaunch the affected app to pick up new +bindings (no logout needed). An app that is still running will keep showing the +old bindings; that is expected, not an apply failure. Note too that many apps +honour only a subset of the dict: Electron apps, Visual Studio Code, and +Terminal.app respect a limited set of actions, which is an app limitation rather +than a ferry fault. + +**Format.** The dict must stay the readable old-style (NeXT/ASCII) text property +list — the reviewable, diff-friendly form. ferry validates the source with +`plutil -lint` on macOS before deploying, and refuses a source that is a binary +plist (a `bplist00` header, which an editor can save silently), that carries a +UTF-8 byte-order mark, or that is not valid UTF-8. ferry never runs +`plutil -convert` on the file (convert would rewrite it to XML or binary and +destroy the readable diff). A `.gitattributes` entry marks the dict as `text` so +git normalises and diffs it as text. Do not carry the SIP-protected system +template (`StandardKeyBinding.dict` inside AppKit), and do not confuse this with +per-app `NSUserKeyEquivalents` (a separate, churning `defaults` domain). + +## Emacs + +ferry carries an Emacs configuration tree across machines like a config-file +terminal: the repo's `emacs/` area is deployed file by file to `~/.emacs.d/`. + +| Repo source | Home target | +|---|---| +| `emacs/` | `~/.emacs.d/` | + +Enable the domain with `emacs = true` under `[manage]`, then commit your +configuration under `emacs/` in the repo (`init.el`, `early-init.el`, a literate +`inits/repp.org`, `docs/`, `README`, `LICENSE`, …). With the domain enabled but +no `emacs/` tree committed, nothing deploys. Every file is deployed as a +regular-file copy reconciled by hash — ferry never symlinks `~/.emacs.d/`, so the +older `ln -s repo ~/.emacs.d` habit is replaced by the apply cycle. Note that +`~/.emacs.d` shadows the XDG location `~/.config/emacs`: with `~/.emacs.d` +present, Emacs reads it and never consults `~/.config/emacs`. + +**Carry and exclude.** The carry set is everything committed under `emacs/`. Even +so, ferry defensively excludes the volatile, machine-generated paths so they are +never deployed even if a source tree contains them: package stores and compiled +bytecode (`elpa/`, `eln-cache/`, `*.elc`), the tangled Emacs-Lisp output +`inits/repp.el` (regenerated from the literate `inits/repp.org` at load), and +session state (`auto-save-list/`, `transient/`, `url/`, `network-security.data`, +`recentf`, `savehist`, `saveplace`). + +**Repo-authoritative.** Edit the configuration in the config repo, then +`ferry apply` deploys it. A live edit to a deployed file shows as drift, and +`apply` skips it rather than overwriting your change — update the repo source (or +`ferry apply --force`) to reconcile. For a literate config this adds one `apply` +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/` wins over the shared `emacs/` on the next +`apply`, leaving every other file shared. 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/`. + +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 +secret-routed file is deployed `0600`. + +## Dependencies + +ferry carries the packages a machine needs as declarative manifests under +`deps/` in the config repo, one representation per manager. Installing packages +mutates system state, so it happens **only** under the explicit `ferry apply +--deps` step — never during a default unattended `apply`. Every manager here is +**install/reconcile-only**: ferry adds what the manifest declares and never +removes a package the manifest omits. + +### Homebrew + +Enable `brew = true` under `[manage]`. The git-tracked representation is a +`Brewfile.` (`deps/Brewfile.darwin`, `deps/Brewfile.linux`) plus an optional +per-machine `deps/Brewfile..local` overlay for casks or Mac App Store apps +that belong to one machine only. `ferry capture` re-dumps the Brewfile from +`brew bundle dump`; `ferry apply --deps` installs it with `brew bundle` (shared +first, then the `.local` overlay). + +`ferry status` reports **Brewfile drift**: it compares the live `brew bundle +dump` against the repo Brewfile and reports how many entries would be captured +(installed locally but not recorded) or installed (recorded but not present). +This is read-only — status never installs a package and never rewrites the +Brewfile. Drift is compared by package identity (the directive and name), so a +benign option or version difference is not reported as drift. + +A cloned config repo's Brewfile is untrusted input that `brew bundle` evaluates +as Ruby, so ferry gates every directive through a fail-closed allow-list +(`brew`, `cask`, `mas`, `tap`, `vscode`, `whalebrew` only) before any `brew +bundle` runs. There is no `brew bundle cleanup` step: ferry never uninstalls an +undeclared package. + +### npm globals + +Enable `npm-globals = true` under `[manage]`. ferry carries the **names** of the +globally-installed npm packages — never their versions — as a plain sorted list +at `deps/npm-globals.txt`. `ferry capture` re-dumps the list from `npm ls -g +--json --depth=0` (npm itself is excluded); `ferry apply --deps` reconciles it +with `npm i -g`; `ferry status` reports drift the same way as Homebrew (how many +names would be captured or installed). + +npm is detected independently of the OS package manager, so npm globals run +**alongside** Homebrew or apt rather than instead of them — a machine can carry +both a Brewfile and an npm globals list. When npm is not installed, the domain +skips cleanly. Like Homebrew, this is install/reconcile-only: `apply --deps` adds +missing packages but never uninstalls, and `restore --packages` leaves npm +globals intact. + +Because the list feeds `npm i -g`, each entry is validated as a plain registry +package name (optionally scoped, e.g. `@angular/cli`). A git URL, a tarball URL, +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 `.local` layer Some settings should differ per machine on purpose: a colour scheme on your laptop, a machine-specific tool. Those live in the `.local` layer: -- **In the repo**: gitignored, under `local//` (e.g. `local/zsh/zshrc.local`). +- **In the repo**: gitignored, under `local//` (e.g. `local/zsh/zshrc.local`, + `local/tmux/tmux.conf.local`, `local/git/gitconfig.local`). - **On the machine**: materialised to the real path (e.g. `~/.zshrc.local`, sourced - last by the shared `~/.zshrc`). + last by the shared `~/.zshrc`; `~/.tmux.conf.local`, sourced last by the shared + `~/.tmux.conf` via `source-file -q`; `~/.gitconfig.local`, pulled in last by the + shared `~/.gitconfig` via a native git `[include]`, and the home of each + machine's git identity). `apply` layers `.local` **on top of** shared, so your deliberate per-machine differences always win and are never overwritten. diff --git a/evals/deps_track_test.go b/evals/deps_track_test.go new file mode 100644 index 0000000..4b0b4a5 --- /dev/null +++ b/evals/deps_track_test.go @@ -0,0 +1,171 @@ +package evals + +// Black-box evals for the v0.7.0 deps track: Homebrew Brewfile drift in the +// managed status surface, and npm globals as a coexisting deps manager. Both +// fake the package manager with a PATH shim (ferry resolves brew/npm through +// PATH), so no real brew/npm is required and the assertions are host-independent. + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// brewfileName is the deps Brewfile filename for the ferry binary's platform +// (the manifest is selected by the binary's runtime.GOOS, which equals the test +// process's GOOS since FERRY_BIN is this host's build). +func brewfileName() string { return "Brewfile." + runtime.GOOS } + +// stubPathEnv puts dir first on PATH so a stub brew/npm shadows any real one, +// while the real PATH still resolves git/coreutils behind it. +func stubPathEnv(dir string) string { + return "PATH=" + dir + string(os.PathListSeparator) + os.Getenv("PATH") +} + +// TestStatusReportsBrewfileDrift: with `brew = true` managed, `ferry status` +// reports Brewfile drift by comparing the live `brew bundle dump` against the +// repo Brewfile — READ-ONLY (it never installs and never rewrites the Brewfile). +func TestStatusReportsBrewfileDrift(t *testing.T) { + t.Parallel() + requireBin(t) + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, "[manage]\nbrew = true\n") + brewfile := s.WriteRepoFile(t, filepath.Join("deps", brewfileName()), "brew \"a\"\nbrew \"b\"\n") + snap := s.SnapshotFile(t, brewfile) // status must not rewrite the Brewfile + + stub := t.TempDir() + // Live dump: a + c. `b` is repo-only (to install), `c` is live-only (to capture). + writeStub(t, filepath.Join(stub, "brew"), + "#!/bin/sh\ncase \"$*\" in\n*'bundle dump'*) printf 'brew \"a\"\\nbrew \"c\"\\n' ;;\nesac\nexit 0\n") + + out, errOut, code := s.FerryEnv([]string{stubPathEnv(stub)}, "status") + combined := out + errOut + if code != 0 { + t.Fatalf("status exited %d\n%s", code, combined) + } + if _, ok := containsAllFold(combined, "brew", "drifted"); !ok { + t.Errorf("status did not report Brewfile drift:\n%s", combined) + } + snap.AssertUnchanged(t) // read-only: the repo Brewfile is untouched +} + +// TestStatusBrewfileClean: when the live dump matches the repo Brewfile, status +// reports brew as clean (no drift). +func TestStatusBrewfileClean(t *testing.T) { + t.Parallel() + requireBin(t) + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, "[manage]\nbrew = true\n") + s.WriteRepoFile(t, filepath.Join("deps", brewfileName()), "brew \"a\"\nbrew \"b\"\n") + + stub := t.TempDir() + writeStub(t, filepath.Join(stub, "brew"), + "#!/bin/sh\ncase \"$*\" in\n*'bundle dump'*) printf 'brew \"a\"\\nbrew \"b\"\\n' ;;\nesac\nexit 0\n") + + out, errOut, code := s.FerryEnv([]string{stubPathEnv(stub)}, "status") + combined := out + errOut + if code != 0 { + t.Fatalf("status exited %d\n%s", code, combined) + } + if !containsAnyFold(combined, "brew") { + t.Fatalf("status did not mention brew at all:\n%s", combined) + } + if strings.Contains(strings.ToLower(combined), "drifted") { + t.Errorf("status reported drift for a matching Brewfile:\n%s", combined) + } +} + +// TestNpmGlobalsCaptureWritesSortedNames: with `npm-globals = true` managed, +// `ferry capture` re-dumps the live global set to deps/npm-globals.txt as a +// deterministic, sorted, NAMES-ONLY list (versions dropped, npm itself excluded). +func TestNpmGlobalsCaptureWritesSortedNames(t *testing.T) { + t.Parallel() + requireBin(t) + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, "[manage]\nnpm-globals = true\n") + + stub := t.TempDir() + writeStub(t, filepath.Join(stub, "npm"), + "#!/bin/sh\ncase \"$*\" in\n*'ls -g'*) printf '{\"dependencies\":{\"zed\":{\"version\":\"1\"},\"apple\":{\"version\":\"2\"},\"npm\":{\"version\":\"3\"}}}\\n' ;;\nesac\nexit 0\n") + + out, errOut, code := s.FerryEnv([]string{stubPathEnv(stub)}, "capture") + combined := out + errOut + if code != 0 { + t.Fatalf("capture exited %d\n%s", code, combined) + } + got, err := os.ReadFile(s.RepoPath("deps", "npm-globals.txt")) + if err != nil { + t.Fatalf("capture did not write deps/npm-globals.txt: %v\n%s", err, combined) + } + if want := "apple\nzed\n"; string(got) != want { + t.Errorf("npm-globals.txt = %q, want %q (sorted, names-only, npm excluded)", got, want) + } +} + +// TestNpmGlobalsApplyInstallsAlongsideBrew: `apply --deps` reconciles npm globals +// via `npm i -g` from the committed list AND, because npm globals COEXIST with the +// OS package manager, brew is driven in the same run (both managers invoked). +func TestNpmGlobalsApplyInstallsAlongsideBrew(t *testing.T) { + t.Parallel() + requireBin(t) + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, "[manage]\nbrew = true\nnpm-globals = true\n") + s.WriteRepoFile(t, filepath.Join("deps", brewfileName()), "brew \"a\"\n") + s.WriteRepoFile(t, filepath.Join("deps", "npm-globals.txt"), "typescript\npyright\n") + + stub := t.TempDir() + npmLog := filepath.Join(stub, "npm.log") + brewLog := filepath.Join(stub, "brew.log") + // brew: log every call, return empty (list → empty installed set), exit 0. + writeStub(t, filepath.Join(stub, "brew"), + "#!/bin/sh\necho \"$*\" >> "+shellQuote(brewLog)+"\nexit 0\n") + // npm: log every call; `i -g` is the reconcile we assert on. + writeStub(t, filepath.Join(stub, "npm"), + "#!/bin/sh\necho \"$*\" >> "+shellQuote(npmLog)+"\nexit 0\n") + + out, errOut, code := s.FerryEnv([]string{stubPathEnv(stub)}, "apply", "--deps") + combined := out + errOut + if code != 0 { + t.Fatalf("apply --deps exited %d\n%s", code, combined) + } + + npmCalls, _ := os.ReadFile(npmLog) + if !strings.Contains(string(npmCalls), "i -g") || + !strings.Contains(string(npmCalls), "typescript") || + !strings.Contains(string(npmCalls), "pyright") { + t.Errorf("npm was not driven with `i -g `:\nnpm log:\n%s\nferry:\n%s", npmCalls, combined) + } + // Coexistence: the OS package manager ran in the SAME apply (brew not suppressed + // by npm detection, nor npm by brew detection). + if countInvocations(brewLog) == 0 { + t.Errorf("brew was never invoked — npm globals must COEXIST with brew, not replace it\n%s", combined) + } +} + +// TestNpmGlobalsAbsentSkipsCleanly: with npm-globals managed but no npm on PATH, +// `apply --deps` skips cleanly (reports npm absent, non-fatal) and installs nothing. +func TestNpmGlobalsAbsentSkipsCleanly(t *testing.T) { + t.Parallel() + requireBin(t) + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, "[manage]\nnpm-globals = true\n") + s.WriteRepoFile(t, filepath.Join("deps", "npm-globals.txt"), "typescript\n") + + // Curated PATH with NEITHER npm nor any package manager discoverable. + stub := t.TempDir() + out, errOut, code := s.FerryEnv([]string{"PATH=" + stub}, "apply", "--deps") + combined := out + errOut + if code != 0 { + t.Fatalf("apply --deps exited %d with npm absent (must skip cleanly)\n%s", code, combined) + } + if !containsAnyFold(combined, "npm") { + t.Errorf("apply --deps did not note the npm-globals skip when npm is absent:\n%s", combined) + } +} diff --git a/evals/doctor_test.go b/evals/doctor_test.go index 8bec4d9..a5f96e5 100644 --- a/evals/doctor_test.go +++ b/evals/doctor_test.go @@ -80,6 +80,109 @@ func splitLinesLower(s string) []string { return strings.Split(strings.ToLower(s), "\n") } +// TestDoctorReportsInvariants covers the read-only managed-target invariant +// checks: on a clean managed setup `ferry doctor` OBSERVES that the deployed +// target is a regular-file copy (not a symlink), does not resolve under ~/.ssh, +// and lives inside $HOME — reporting each as a pass and exiting 0. It is the +// black-box proof that the invariant lines are present and pass on a healthy +// machine. +func TestDoctorReportsInvariants_AC_doctor_invariants(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + target := seedManagedDotfile(t, s, "export EDITOR=vim\n") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("setup apply exited %d; stderr:\n%s", code, errOut) + } + if fi, err := os.Lstat(target); err != nil || fi.Mode()&os.ModeSymlink != 0 { + t.Fatalf("setup: expected %s to be a regular file after apply (err=%v)", target, err) + } + + out, errOut, code := s.Ferry("doctor") + combined := out + errOut + if code != 0 { + t.Fatalf("AC-doctor-invariants: doctor exited %d on a clean managed setup (want 0)\n%s", code, combined) + } + if !containsAnyFold(combined, "invariant") { + t.Errorf("AC-doctor-invariants: doctor did not report the managed-target invariant section\n%s", combined) + } + // The symlink invariant line must be present AND report a pass (no breach). + symLine := lineContaining(combined, "symlink") + if symLine == "" { + t.Errorf("AC-doctor-invariants: doctor did not report the no-symlink invariant\n%s", combined) + } else if !strings.Contains(symLine, "pass") { + t.Errorf("AC-doctor-invariants: clean setup did not report the no-symlink invariant as pass\n%s", combined) + } + // The ~/.ssh and containment invariants must be observed too. + if !containsAllFoldOK(combined, ".ssh") { + t.Errorf("AC-doctor-invariants: doctor did not report the ~/.ssh invariant\n%s", combined) + } + if !containsAnyFold(combined, "inside $home", "inside $HOME") { + t.Errorf("AC-doctor-invariants: doctor did not report the $HOME-containment invariant\n%s", combined) + } +} + +// TestDoctorFlagsSymlinkTarget plants a symlink where a ferry-deployed regular +// file should be and asserts doctor OBSERVES the breach: it names the offending +// target as a symlink and exits non-zero. The symlink points at another in-$HOME +// file (so it does not itself escape containment) — the breach is purely "a +// symlink where a regular-file copy belongs", exactly what ferry's copy-not-link +// invariant forbids. doctor detects it by lstat alone (never following the link, +// never reading contents). +func TestDoctorFlagsSymlinkTarget_AC_doctor_invariants(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + target := seedManagedDotfile(t, s, "export EDITOR=vim\n") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("setup apply exited %d; stderr:\n%s", code, errOut) + } + + // Replace the deployed regular file with a symlink to another in-$HOME file. + decoy := s.HomePath("decoy.txt") + if err := os.WriteFile(decoy, []byte("export EDITOR=vim\n"), 0o644); err != nil { + t.Fatalf("write decoy: %v", err) + } + if err := os.Remove(target); err != nil { + t.Fatalf("remove target: %v", err) + } + if err := os.Symlink(decoy, target); err != nil { + t.Fatalf("plant symlink: %v", err) + } + + out, errOut, code := s.Ferry("doctor") + combined := out + errOut + if code == 0 { + t.Errorf("AC-doctor-invariants: doctor exited 0 despite a symlinked managed target (want non-zero)\n%s", combined) + } + symLine := lineContaining(combined, "symlink") + if symLine == "" || !strings.Contains(symLine, "fail") { + t.Errorf("AC-doctor-invariants: doctor did not report the symlinked target as a [fail]\n%s", combined) + } + if !containsAnyFold(combined, ".zshrc") { + t.Errorf("AC-doctor-invariants: doctor did not name the offending target\n%s", combined) + } +} + +// lineContaining returns the first lowercased line of out that contains substr +// (case-insensitive), or "" if none does. Lets an assertion pair a signal +// (pass/fail) with the specific invariant line it belongs to. +func lineContaining(out, substr string) string { + sub := strings.ToLower(substr) + for _, line := range splitLinesLower(out) { + if strings.Contains(line, sub) { + return line + } + } + return "" +} + +// containsAllFoldOK is a small case-insensitive substring check (the eval package +// already has containsAnyFold; this is the single-needle "contains" variant). +func containsAllFoldOK(haystack, needle string) bool { + return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle)) +} + // writeStub writes an executable shell stub at path. func writeStub(t *testing.T, path, script string) { t.Helper() diff --git a/evals/emacs_test.go b/evals/emacs_test.go new file mode 100644 index 0000000..b172aa0 --- /dev/null +++ b/evals/emacs_test.go @@ -0,0 +1,165 @@ +package evals + +// B3 — the Emacs configuration tree carried like a config-file terminal domain. +// These evals drive the real binary and assert observable outcomes: an emacs/ +// tree deploys from the config repo to ~/.emacs.d/ as regular-file COPIES (never +// symlinks) when the domain is in scope; volatile, machine-generated paths (a +// decoy elpa/ package, a *.elc, the tangled inits/repp.el) are pruned and never +// deployed; the domain is gated by [manage] emacs; the per-machine +// local/emacs/ overlay wins per file; and status reports clean after apply +// and drift after a live edit (repo-authoritative, no capture pass). + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// emacsManifest enables the Emacs domain plus the dotfiles domain (so scope has +// a second domain to prove independence). +const emacsManifest = `[manage] +dotfiles = [".zshrc"] +emacs = true +brew = false +iterm2 = false +fonts = false +` + +// TestEmacsAppliesTreeAsCopies proves an in-scope emacs/ tree deploys from the +// repo to ~/.emacs.d/ as regular-file copies (not symlinks), preserving the +// nested relpath, while excluded volatile paths are never deployed. +func TestEmacsAppliesTreeAsCopies(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, emacsManifest) + + // Carry set: init.el + a nested literate source. + s.WriteRepoFile(t, filepath.Join("emacs", "init.el"), ";; shared init\n") + s.WriteRepoFile(t, filepath.Join("emacs", "inits", "repp.org"), "* literate config\n") + // Volatile decoys that must be pruned: a package file, compiled bytecode, and + // the tangled Emacs-Lisp output. + s.WriteRepoFile(t, filepath.Join("emacs", "elpa", "some-pkg", "foo.el"), ";; decoy pkg\n") + s.WriteRepoFile(t, filepath.Join("emacs", "init.elc"), "decoy bytecode\n") + s.WriteRepoFile(t, filepath.Join("emacs", "inits", "repp.el"), ";; tangled decoy\n") + gitCommitAll(t, s.Repo, "emacs tree") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + + // The carry set deployed, preserving the nested relpath. + initTarget := s.HomePath(".emacs.d", "init.el") + assertFileContains(t, initTarget, "shared init") + assertFileContains(t, s.HomePath(".emacs.d", "inits", "repp.org"), "literate config") + + // Deployed as a REGULAR FILE copy — ferry never symlinks under $HOME. + fi, err := os.Lstat(initTarget) + if err != nil { + t.Fatalf("lstat target: %v", err) + } + if fi.Mode()&os.ModeSymlink != 0 { + t.Errorf("%s is a symlink; ferry must deploy a regular-file copy", initTarget) + } + if !fi.Mode().IsRegular() { + t.Errorf("%s is not a regular file (mode %v)", initTarget, fi.Mode()) + } + + // The volatile decoys were pruned and never deployed. + for _, rel := range [][]string{ + {".emacs.d", "elpa", "some-pkg", "foo.el"}, + {".emacs.d", "init.elc"}, + {".emacs.d", "inits", "repp.el"}, + } { + p := s.HomePath(rel...) + if _, err := os.Lstat(p); err == nil { + t.Errorf("excluded volatile path was deployed: %s", p) + } + } +} + +// TestEmacsScopeGated proves the domain is OFF by default: with no `emacs` key +// in scope, a repo emacs/ tree is never deployed. +func TestEmacsScopeGated(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, baseManifest) // no `emacs` key + s.WriteRepoFile(t, filepath.Join("emacs", "init.el"), ";; should not deploy\n") + gitCommitAll(t, s.Repo, "emacs not in scope") + + target := s.HomePath(".emacs.d", "init.el") + tw := s.SnapshotFile(t, target) // absent + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + tw.AssertUnchanged(t) // still absent — domain out of scope +} + +// TestEmacsLocalOverlayWins proves the per-machine overlay +// (local/emacs/) overrides the shared repo copy for just that file, +// while a non-overridden file still deploys the shared content. +func TestEmacsLocalOverlayWins(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, emacsManifest) + + s.WriteRepoFile(t, filepath.Join("emacs", "init.el"), "SHARED_INIT\n") + s.WriteRepoFile(t, filepath.Join("emacs", "inits", "custom.el"), "SHARED_CUSTOM\n") + // Per-machine override of ONLY custom.el. + s.WriteRepoFile(t, filepath.Join("local", "emacs", "inits", "custom.el"), "MACHINE_CUSTOM\n") + gitCommitAll(t, s.Repo, "emacs with local overlay") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + + // The overridden file carries the machine-local content; the base the shared. + assertFileContains(t, s.HomePath(".emacs.d", "inits", "custom.el"), "MACHINE_CUSTOM") + assertFileExcludes(t, s.HomePath(".emacs.d", "inits", "custom.el"), "SHARED_CUSTOM") + assertFileContains(t, s.HomePath(".emacs.d", "init.el"), "SHARED_INIT") +} + +// 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). +func TestEmacsStatusCleanThenDrift(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, emacsManifest) + s.WriteRepoFile(t, filepath.Join("emacs", "init.el"), ";; shared init\n") + gitCommitAll(t, s.Repo, "emacs") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + + // Clean immediately after apply: in sync, no drift, and no spurious "no longer + // managed" de-scope warning for the still-managed target. + stdout, errOut, code := s.Ferry("status") + if code != 0 { + t.Fatalf("status exited %d; stderr:\n%s", code, errOut) + } + if !strings.Contains(stdout, "no drift detected") { + t.Errorf("status did not report clean right after apply; stdout:\n%s", stdout) + } + if strings.Contains(stdout, "no longer managed") || strings.Contains(stdout, "no longer part of the emacs plan") { + t.Errorf("clean status falsely warned the managed emacs target is de-scoped; stdout:\n%s", stdout) + } + + // A live edit must surface as drift (repo-authoritative: apply would skip it). + target := s.HomePath(".emacs.d", "init.el") + if err := os.WriteFile(target, []byte(";; LOCAL EDIT\n"), 0o644); err != nil { + t.Fatalf("live edit: %v", err) + } + stdout, errOut, code = s.Ferry("status") + if code != 0 { + t.Fatalf("status after edit exited %d; stderr:\n%s", code, errOut) + } + if strings.Contains(stdout, "no drift detected") || !strings.Contains(stdout, "drift") { + t.Errorf("status did not report drift after a live edit; stdout:\n%s", stdout) + } +} diff --git a/evals/fn5_migration_test.go b/evals/fn5_migration_test.go new file mode 100644 index 0000000..bf5a7b8 --- /dev/null +++ b/evals/fn5_migration_test.go @@ -0,0 +1,104 @@ +package evals + +// fn-5 domain convergence — the byte-stable migration safety net. +// +// This eval is the guard the fn-5 `isZsh()` -> registry cutover must not +// regress. It proves that the CURRENT (pre-cutover) zsh two-strip + include- +// sidecar round-trip is byte-STABLE (content, mode, size AND mtime identical) +// across a deploy -> capture -> status cycle with no intervening user change: +// +// - apply materialises the include-style split — a shared ~/.zshrc ending in a +// `source ~/.zshrc.local` directive, and the ~/.zshrc.local sidecar itself. +// - a no-op capture (nothing drifted) followed by status must leave BOTH home +// files byte-identical (the mtime dimension makes it byte-STABLE, not merely +// byte-equal — a same-bytes rewrite would fail) and report a clean/no-drift +// status. +// +// Recorded now against current behaviour so the fn-5 port can re-run it and +// prove the sidecar / two-strip contract survived the convergence unchanged. + +import ( + "os" + "path/filepath" + "testing" +) + +// TestFn5_ZshRoundTripByteStable_Baseline records the current zsh include-sidecar +// round-trip as byte-stable, before the isZsh -> registry cutover. +func TestFn5_ZshRoundTripByteStable_Baseline(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + + // Scope: .zshrc managed (baseManifest declares dotfiles = [".zshrc"]). + s.SeedSharedManifest(t, baseManifest) + + // Shared zsh source (seed both candidate layouts so the eval is not brittle + // to the exact repo convention the implementation resolves). + const sharedZsh = "# managed zshrc\nexport EDITOR=vim\nexport PAGER=less\n" + s.WriteRepoFile(t, ".zshrc", sharedZsh) + s.WriteRepoFile(t, filepath.Join("dotfiles", ".zshrc"), sharedZsh) + + // Per-machine overlay: its presence is what makes apply materialise the + // include-style SPLIT — the shared ~/.zshrc gains a trailing + // `source ~/.zshrc.local` and the ~/.zshrc.local sidecar is written. + const overlayZsh = "# per-machine overlay\nexport FROM_OVERLAY=1\n" + s.WriteRepoFile(t, filepath.Join("local", "zsh", "zshrc.local"), overlayZsh) + + gitCommitAll(t, s.Repo, "baseline zsh + overlay") + + // Deploy. Both home targets are absent, so this is a fresh create (no risk + // gate) — plain apply. + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + + sharedTarget := s.HomePath(".zshrc") + sidecarTarget := s.HomePath(".zshrc.local") + + // Sanity: the include-style split actually materialised (both files exist). + // Without both, the byte-stability claim below would be vacuous. + if _, err := os.Stat(sharedTarget); err != nil { + t.Fatalf("apply did not deploy ~/.zshrc: %v", err) + } + if _, err := os.Stat(sidecarTarget); err != nil { + t.Fatalf("apply did not materialise the ~/.zshrc.local sidecar: %v (the include split did not form)", err) + } + + // Snapshot BOTH halves of the split immediately after deploy — content, mode, + // size AND mtime. The mtime dimension is what makes this byte-STABLE. + sharedTW := s.SnapshotFile(t, sharedTarget) + sidecarTW := s.SnapshotFile(t, sidecarTarget) + // Also snapshot the repo sources: a byte-stable round-trip must not rewrite + // the committed shared source or the overlay either. + repoSharedTW := s.SnapshotFile(t, s.RepoPath("dotfiles", ".zshrc")) + repoOverlayTW := s.SnapshotFile(t, s.RepoPath("local", "zsh", "zshrc.local")) + + // No-op capture: nothing drifted, so capture offers nothing and must not + // rewrite either home file. Empty stdin => any prompt sees EOF (the harness + // 30s timeout Fatals on a hang). + if _, errOut, code := s.Ferry("capture"); code != 0 { + t.Fatalf("no-op capture exited %d; stderr:\n%s", code, errOut) + } + + // status must report a POSITIVE clean/no-drift signal and exit 0 (absence of + // a drift token alone is not enough — a silent status would falsely pass). + statusOut, statusErr, statusCode := s.Ferry("status") + statusCombined := statusOut + statusErr + if statusCode != 0 { + t.Errorf("clean `status` exited %d (want 0)\n%s", statusCode, statusCombined) + } + if !containsAnyFold(statusCombined, + "no drift", "clean", "up to date", "up-to-date", "no changes", "nothing to", "in sync") { + t.Errorf("clean `status` gave no positive clean/no-drift signal after a no-op capture\n%s", statusCombined) + } + + // The load-bearing assertion: both halves of the include split are byte-, + // mode-, size- and mtime-identical to the post-apply snapshot — the two-strip + // + sidecar round-trip did not touch them. + sharedTW.AssertUnchanged(t) + sidecarTW.AssertUnchanged(t) + // And the repo sources were not rewritten by the no-op capture. + repoSharedTW.AssertUnchanged(t) + repoOverlayTW.AssertUnchanged(t) +} diff --git a/evals/git_test.go b/evals/git_test.go new file mode 100644 index 0000000..eb410d8 --- /dev/null +++ b/evals/git_test.go @@ -0,0 +1,408 @@ +package evals + +// git config plugin (v0.7.0 Phase B — the FLAGSHIP) exercised through the REAL +// binary. git rides the dotfiles FileDomain as an include-sidecar dotfile: a +// shared ~/.gitconfig whose LAST directive is ferry's injected git-INI +// `[include]` block sourcing ~/.gitconfig.local (git last-wins), plus the +// machine identity partitioned to that never-shared .local layer. These evals are +// the plan's STOP-condition proofs: +// - identity keys (user.email/name/signingkey, gpg.program, credential.helper) +// and [includeIf …] blocks NEVER reach the shared ~/.gitconfig or repo; +// - a URL-embedded token / http.extraHeader credential NEVER reaches the shared +// repo (stored out-of-band as a column-grained span, rendered back on deploy); +// - credential.helper = store is refused/warned; ~/.git-credentials is untouched. + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const gitManifest = `[manage] +dotfiles = [".gitconfig"] +brew = false +iterm2 = false +fonts = false +` + +// gitToken / gitHeaderToken are GitHub-token-shaped High-confidence secrets (no +// placeholder words), so the recogniser stores them and IsNonPlaceholderSecret +// accepts them as real. +const gitToken = "ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8" +const gitHeaderToken = "ghp_Z9y8X7w6V5u4T3s2R1q0P9o8N7m6L5k4J3h2" + +// sharedGitconfig is an identity-FREE shared config (aliases, core.*, +// init.defaultBranch) — exactly what belongs in the shared repo. +const sharedGitconfig = "[core]\n\teditor = vim\n[init]\n\tdefaultBranch = main\n[alias]\n\tst = status\n\tlg = log --oneline\n" + +// aliceLocal / bobLocal are two machines' private identity layers — the +// ~/.gitconfig.local content, gitignored and never shared. +const aliceLocal = "[user]\n\temail = alice@example.com\n\tname = Alice Example\n\tsigningkey = ABCD1234\n[gpg]\n\tprogram = gpg2\n[credential \"https://ghe.example.com\"]\n\tusername = alice-login\n[includeIf \"gitdir:~/work/\"]\n\tpath = ~/work/.gitconfig\n" +const bobLocal = "[user]\n\temail = bob@example.net\n\tname = Bob Other\n" + +// seedGitRepo writes the manifest, the shared source (both layout paths), and a +// .gitignore for local/ so the private identity layer is never committed. +func seedGitRepo(t *testing.T, s *Sandbox, shared string) { + t.Helper() + s.SeedSharedManifest(t, gitManifest) + s.WriteRepoFile(t, filepath.Join("dotfiles", "gitconfig"), shared) + s.WriteRepoFile(t, filepath.Join("dotfiles", ".gitconfig"), shared) + s.WriteRepoFile(t, ".gitignore", "local/\n") +} + +// assertNoIdentity fails if any identity token appears in text. +func assertNoIdentity(t *testing.T, where, text string) { + t.Helper() + for _, id := range []string{ + "alice@example.com", "Alice Example", "ABCD1234", "gpg2", "alice-login", + "[user]", "[gpg]", "[includeIf", "gitdir:", "signingkey", "username", + } { + if strings.Contains(text, id) { + t.Errorf("IDENTITY LEAK in %s: contains %q\n%s", where, id, text) + } + } +} + +// TestGit_IdentityNeverShared is the load-bearing identity firewall proof +// (plan §7): identity lives ONLY in ~/.gitconfig.local; the deployed shared +// ~/.gitconfig, the committed shared repo source, and git history carry NONE of +// it — and a second machine (bob) proves one machine's identity never appears in +// the shared representation. +func TestGit_IdentityNeverShared(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + seedGitRepo(t, s, sharedGitconfig) + gitCommitAll(t, s.Repo, "baseline shared gitconfig (no identity)") + + // Machine A: alice's private identity layer (gitignored, uncommitted). + s.WriteRepoFile(t, filepath.Join("local", "git", "gitconfig.local"), aliceLocal) + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + + sharedTarget := s.HomePath(".gitconfig") + sidecarTarget := s.HomePath(".gitconfig.local") + + deployedShared, err := os.ReadFile(sharedTarget) + if err != nil { + t.Fatalf("apply did not deploy ~/.gitconfig: %v", err) + } + // The shared file carries the native [include] overlay as its last directive. + if !strings.Contains(string(deployedShared), "[include]") || + !strings.Contains(string(deployedShared), "path = ~/.gitconfig.local") { + t.Fatalf("~/.gitconfig missing the git [include] overlay directive:\n%s", deployedShared) + } + // Non-identity shared content is present. + if !strings.Contains(string(deployedShared), "st = status") { + t.Fatalf("~/.gitconfig lost the shared aliases:\n%s", deployedShared) + } + // Identity is NOWHERE in the shared deployed file... + assertNoIdentity(t, "deployed ~/.gitconfig", string(deployedShared)) + // ...nor in the committed shared repo source... + repoShared, err := os.ReadFile(s.RepoPath("dotfiles", "gitconfig")) + if err != nil { + t.Fatalf("read repo shared: %v", err) + } + assertNoIdentity(t, "repo dotfiles/gitconfig", string(repoShared)) + // ...nor anywhere in git history. + assertNoIdentityInGitHistory(t, s) + + // Identity DOES live in the private ~/.gitconfig.local sidecar. + sidecar, err := os.ReadFile(sidecarTarget) + if err != nil { + t.Fatalf("apply did not materialise ~/.gitconfig.local: %v", err) + } + for _, want := range []string{"alice@example.com", "Alice Example", "gitdir:~/work/", "username = alice-login"} { + if !strings.Contains(string(sidecar), want) { + t.Errorf("~/.gitconfig.local missing identity %q:\n%s", want, sidecar) + } + } + + // Machine B: swap in bob's identity, re-apply. The shared deployed file and the + // shared repo source must STILL carry no identity (bob's or alice's). + s.WriteRepoFile(t, filepath.Join("local", "git", "gitconfig.local"), bobLocal) + if _, errOut, code := s.ApplyConfirmed(); code != 0 { + t.Fatalf("machine-B apply exited %d; stderr:\n%s", code, errOut) + } + deployedB, _ := os.ReadFile(sharedTarget) + for _, id := range []string{"bob@example.net", "Bob Other", "alice@example.com"} { + if strings.Contains(string(deployedB), id) { + t.Errorf("IDENTITY LEAK: machine-B ~/.gitconfig contains %q\n%s", id, deployedB) + } + } + sidecarB, _ := os.ReadFile(sidecarTarget) + if !strings.Contains(string(sidecarB), "bob@example.net") { + t.Errorf("machine-B ~/.gitconfig.local missing bob's identity:\n%s", sidecarB) + } +} + +// TestGit_IdentityInSharedSourceStrippedOnDeploy is the DEFENSIVE half: even a +// shared repo source that MISTAKENLY carries identity keys never deploys them +// into ~/.gitconfig — apply's firewall strips them. +func TestGit_IdentityInSharedSourceStrippedOnDeploy(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + // A shared source polluted with identity (a mis-commit). + polluted := sharedGitconfig + aliceLocal + seedGitRepo(t, s, polluted) + gitCommitAll(t, s.Repo, "shared gitconfig with mistakenly-committed identity") + s.WriteRepoFile(t, filepath.Join("local", "git", "gitconfig.local"), bobLocal) + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + deployed, err := os.ReadFile(s.HomePath(".gitconfig")) + if err != nil { + t.Fatalf("apply did not deploy ~/.gitconfig: %v", err) + } + assertNoIdentity(t, "deployed ~/.gitconfig (defensive strip)", string(deployed)) +} + +// TestGit_InlineAndContinuationIdentityStrippedOnDeploy proves the parser models +// git's real grammar end-to-end: identity written in the INLINE `[section] key =` +// form (with and without spaces) and across a BACKSLASH value-continuation is +// firewalled out of the deployed shared ~/.gitconfig, exactly like the canonical +// multi-line form (ruthless-review findings 1 & 2). +func TestGit_InlineAndContinuationIdentityStrippedOnDeploy(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + // A shared source polluted with identity in every awkward spelling git accepts. + polluted := sharedGitconfig + + "[user] email = inline@leak.example\n" + // inline, spaced + "[gpg]program=/leak/gpg\n" + // inline, no spaces + "[user]\n\tname = frag\\\nmented@leak.example\n" + // backslash continuation + "[credential] helper = store\n" // inline credential.helper = store + seedGitRepo(t, s, polluted) + gitCommitAll(t, s.Repo, "shared gitconfig with inline + continuation identity") + s.WriteRepoFile(t, filepath.Join("local", "git", "gitconfig.local"), bobLocal) + + out, errOut, code := s.Ferry("apply") + if code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + deployed, err := os.ReadFile(s.HomePath(".gitconfig")) + if err != nil { + t.Fatalf("apply did not deploy ~/.gitconfig: %v", err) + } + for _, leak := range []string{ + "inline@leak.example", "/leak/gpg", "mented@leak.example", "frag", + "helper = store", "program", + } { + if strings.Contains(string(deployed), leak) { + t.Errorf("IDENTITY LEAK: deployed ~/.gitconfig contains %q (inline/continuation firewall gap)\n%s", leak, deployed) + } + } + // The inline credential.helper = store must still be warned about. + if !containsAnyFold(out+errOut, "plaintext", "osxkeychain", "git-credentials") { + t.Errorf("inline credential.helper = store was not warned about:\n%s", out+errOut) + } + // Non-identity shared content survives. + if !strings.Contains(string(deployed), "st = status") { + t.Errorf("deploy dropped shared aliases:\n%s", deployed) + } +} + +// TestGit_URLTokenNeverReachesRepo is the load-bearing secret gate for git: a +// literal token embedded in a url.*.insteadOf value and an http.extraHeader +// Bearer credential, edited into the live ~/.gitconfig, are routed to the +// out-of-repo store as column-grained spans (the URL/header syntax preserved), a +// placeholder is committed instead, and a redeploy renders both back verbatim. +func TestGit_URLTokenNeverReachesRepo(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + seedGitRepo(t, s, sharedGitconfig) + gitCommitAll(t, s.Repo, "baseline shared gitconfig") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("setup apply exited %d; stderr:\n%s", code, errOut) + } + + // Edit the live file: add a URL-embedded token and a Bearer extraHeader. + insteadOfLine := "[url \"https://github.com/\"]\n\tinsteadOf = https://" + gitToken + "@github.com/\n" + headerLine := "[http]\n\textraHeader = Authorization: Bearer " + gitHeaderToken + "\n" + deployedShared, _ := os.ReadFile(s.HomePath(".gitconfig")) + if err := os.WriteFile(s.HomePath(".gitconfig"), append(deployedShared, []byte(insteadOfLine+headerLine)...), 0o600); err != nil { + t.Fatalf("stage git secret: %v", err) + } + + // Accept the drifted hunk (y — both edits land in one contiguous hunk), route + // the blocked secret span(s) to the store (x), then route the + // placeholder-bearing change to shared (s). + out, errOut, _ := s.FerryWithInput("y\nx\ns\n", "capture") + combined := out + errOut + + // HARD: neither literal token lands in any repo file or git history. + s.AssertNoSecretInRepo(t, gitToken) + s.AssertNoSecretInRepo(t, gitHeaderToken) + + repoShared, err := os.ReadFile(s.RepoPath("dotfiles", "gitconfig")) + if err != nil { + t.Fatalf("read repo shared gitconfig: %v", err) + } + if strings.Contains(string(repoShared), gitToken) || strings.Contains(string(repoShared), gitHeaderToken) { + t.Fatalf("a literal git token reached the shared repo:\n%s", repoShared) + } + // The insteadOf line keeps its URL scheme/host, only the token swapped for a + // placeholder; likewise the Bearer prefix is preserved. + if !strings.Contains(string(repoShared), "https://{{ferry.secret") { + t.Errorf("insteadOf line did not gain a column-grained placeholder (URL syntax preserved):\n%s\ncapture:\n%s", repoShared, combined) + } + if !strings.Contains(string(repoShared), "Bearer {{ferry.secret") { + t.Errorf("extraHeader line did not gain a column-grained placeholder (Bearer prefix preserved):\n%s", repoShared) + } + + // Redeploy renders both placeholders back to the literal tokens. + if err := os.Remove(s.HomePath(".gitconfig")); err != nil { + t.Fatalf("remove live gitconfig: %v", err) + } + if _, errOut, code := s.ApplyConfirmed(); code != 0 { + t.Fatalf("redeploy apply exited %d; stderr:\n%s", code, errOut) + } + redeployed, err := os.ReadFile(s.HomePath(".gitconfig")) + if err != nil { + t.Fatalf("read redeployed ~/.gitconfig: %v", err) + } + if !strings.Contains(string(redeployed), "https://"+gitToken+"@github.com/") { + t.Errorf("deploy did not render the URL token back literally:\n%s", redeployed) + } + if !strings.Contains(string(redeployed), "Bearer "+gitHeaderToken) { + t.Errorf("deploy did not render the Bearer token back literally:\n%s", redeployed) + } +} + +// TestGit_EnvRefLeftVerbatim is the negative: a ${GIT_TOKEN} env-ref in an +// insteadOf URL is NOT a literal secret, so it is never gate-blocked and reaches +// the shared repo verbatim. +func TestGit_EnvRefLeftVerbatim(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + seedGitRepo(t, s, sharedGitconfig) + gitCommitAll(t, s.Repo, "baseline shared gitconfig") + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("setup apply exited %d; stderr:\n%s", code, errOut) + } + + envLine := "[url \"https://github.com/\"]\n\tinsteadOf = https://${GIT_TOKEN}@github.com/\n" + deployed, _ := os.ReadFile(s.HomePath(".gitconfig")) + if err := os.WriteFile(s.HomePath(".gitconfig"), append(deployed, []byte(envLine)...), 0o600); err != nil { + t.Fatalf("stage env-ref: %v", err) + } + s.FerryWithInput("y\ns\n", "capture") + + repoShared, err := os.ReadFile(s.RepoPath("dotfiles", "gitconfig")) + if err != nil { + t.Fatalf("read repo shared gitconfig: %v", err) + } + if !strings.Contains(string(repoShared), "https://${GIT_TOKEN}@github.com/") { + t.Errorf("the ${GIT_TOKEN} env-ref was not carried to the shared repo verbatim:\n%s", repoShared) + } +} + +// TestGit_CredentialHelperStoreWarns proves credential.helper = store is +// refused/warned and never deployed to the shared ~/.gitconfig (it is identity, +// stripped), and that ferry never creates or reads ~/.git-credentials. +func TestGit_CredentialHelperStoreWarns(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + shared := sharedGitconfig + "[credential]\n\thelper = store\n" + seedGitRepo(t, s, shared) + gitCommitAll(t, s.Repo, "shared gitconfig with credential.helper = store") + + out, errOut, code := s.Ferry("apply") + if code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + combined := out + errOut + if !strings.Contains(strings.ToLower(combined), "credential.helper") || !containsAnyFold(combined, "plaintext", "osxkeychain", "git-credentials") { + t.Errorf("apply did not warn about credential.helper = store:\n%s", combined) + } + // helper=store is identity → stripped from the deployed shared file. + deployed, err := os.ReadFile(s.HomePath(".gitconfig")) + if err != nil { + t.Fatalf("read deployed gitconfig: %v", err) + } + if strings.Contains(string(deployed), "helper = store") { + t.Errorf("credential.helper = store leaked into the deployed shared ~/.gitconfig:\n%s", deployed) + } + // ferry never creates ~/.git-credentials. + if _, err := os.Stat(s.HomePath(".git-credentials")); err == nil { + t.Errorf("ferry created ~/.git-credentials — it must never touch it") + } +} + +// TestGit_RoundTripByteStable mirrors the zsh/tmux byte-stable migration eval: +// apply materialises the git include split (a shared ~/.gitconfig ending in the +// `[include]` block, plus the ~/.gitconfig.local sidecar), and a no-op capture + +// status leaves BOTH home files and BOTH repo sources byte-, mode-, size- AND +// mtime-identical, reporting clean. +func TestGit_RoundTripByteStable(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + seedGitRepo(t, s, sharedGitconfig) + s.WriteRepoFile(t, filepath.Join("local", "git", "gitconfig.local"), bobLocal) + gitCommitAll(t, s.Repo, "baseline gitconfig + identity overlay") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + sharedTarget := s.HomePath(".gitconfig") + sidecarTarget := s.HomePath(".gitconfig.local") + got, err := os.ReadFile(sharedTarget) + if err != nil { + t.Fatalf("apply did not deploy ~/.gitconfig: %v", err) + } + if !strings.Contains(string(got), "[include]\n\tpath = ~/.gitconfig.local") { + t.Fatalf("~/.gitconfig is missing ferry's git [include] directive:\n%s", got) + } + + sharedTW := s.SnapshotFile(t, sharedTarget) + sidecarTW := s.SnapshotFile(t, sidecarTarget) + repoSharedTW := s.SnapshotFile(t, s.RepoPath("dotfiles", ".gitconfig")) + repoOverlayTW := s.SnapshotFile(t, s.RepoPath("local", "git", "gitconfig.local")) + + if _, errOut, code := s.Ferry("capture"); code != 0 { + t.Fatalf("no-op capture exited %d; stderr:\n%s", code, errOut) + } + statusOut, statusErr, statusCode := s.Ferry("status") + statusCombined := statusOut + statusErr + if statusCode != 0 { + t.Errorf("clean status exited %d\n%s", statusCode, statusCombined) + } + if !containsAnyFold(statusCombined, + "no drift", "clean", "up to date", "up-to-date", "no changes", "nothing to", "in sync") { + t.Errorf("clean status gave no positive no-drift signal after a no-op capture\n%s", statusCombined) + } + + sharedTW.AssertUnchanged(t) + sidecarTW.AssertUnchanged(t) + repoSharedTW.AssertUnchanged(t) + repoOverlayTW.AssertUnchanged(t) +} + +// assertNoIdentityInGitHistory greps the repo's full git history for alice's +// identity — the AssertNoSecretInRepo-style scan for the identity key, restricted +// to committed history (the gitignored ~/.gitconfig.local legitimately holds it). +func assertNoIdentityInGitHistory(t *testing.T, s *Sandbox) { + t.Helper() + cmd := exec.Command("git", "-C", s.Repo, "log", "-p", "--all", "--full-history") + cmd.Env = gitIsolatedEnv("GIT_PAGER=cat") + out, err := cmd.CombinedOutput() + if err != nil { + return + } + for _, id := range []string{"alice@example.com", "Alice Example", "ABCD1234"} { + if strings.Contains(string(out), id) { + t.Errorf("IDENTITY LEAK: %q found in git history", id) + } + } +} diff --git a/evals/guided_apply_test.go b/evals/guided_apply_test.go index 8ea00bd..1a27496 100644 --- a/evals/guided_apply_test.go +++ b/evals/guided_apply_test.go @@ -32,10 +32,13 @@ const guidedRiskyManifest = "[manage]\ndotfiles = [\".gitconfig\"]\n" func seedRiskyAdoption(t *testing.T, s *Sandbox) (target, repoContent, liveContent string) { t.Helper() s.SeedSharedManifest(t, guidedRiskyManifest) - repoContent = "[user]\n\tname = Repo Managed\n\temail = managed@example.com\n" + // Non-identity gitconfig content (core.* ergonomics): the git identity firewall + // leaves it byte-for-byte, so the deploy is verbatim and the risky-adoption of a + // pre-existing, differing live file is what this exercises. + repoContent = "[core]\n\teditor = vim\n\tpager = less\n" s.WriteRepoFile(t, ".gitconfig", repoContent) s.WriteRepoFile(t, filepath.Join("dotfiles", ".gitconfig"), repoContent) - liveContent = "[user]\n\tname = My Pre-Ferry Name\n\temail = me@personal.example\n" + liveContent = "[core]\n\teditor = nano\n" target = s.WriteHomeFile(t, ".gitconfig", liveContent, 0o644) return target, repoContent, liveContent } diff --git a/evals/iterm2_profiles_test.go b/evals/iterm2_profiles_test.go new file mode 100644 index 0000000..b9fc6ce --- /dev/null +++ b/evals/iterm2_profiles_test.go @@ -0,0 +1,148 @@ +package evals + +// iTerm2 Dynamic Profiles — a repo-authoritative FileDomain (v0.7.0 Phase B). The +// config repo's iterm2/DynamicProfiles/*.json deploy to +// ~/Library/Application Support/iTerm2/DynamicProfiles/ as regular-file COPIES +// (never symlinks); a profile's Guid is byte-preserved; a malformed JSON is refused +// (never deployed, so it can't disable all dynamic profiles); the domain is gated by +// [manage] iterm2-profiles; the per-machine local/iterm2-profiles/ overlay wins per +// file; and status reports clean after apply and drift after a live edit. Runs on +// every platform (JSON validity is checked in pure Go; the deploy is a file copy). + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const iterm2ProfilesManifest = `[manage] +dotfiles = [".zshrc"] +iterm2-profiles = true +brew = false +iterm2 = false +fonts = false +` + +// dynamicProfilesDir is the deploy destination under $HOME. +func dynamicProfilesDir(s *Sandbox, rel ...string) string { + return s.HomePath(append([]string{"Library", "Application Support", "iTerm2", "DynamicProfiles"}, rel...)...) +} + +const workProfileJSON = `{"Profiles":[{"Name":"Work","Guid":"WORK-GUID-FROZEN-1","Rewritable":false}]}` + +// TestITerm2ProfilesDeploysValidRefusesMalformed proves a valid profile deploys as a +// regular-file copy with its GUID byte-preserved, while a malformed JSON is refused +// and never deployed. +func TestITerm2ProfilesDeploysValidRefusesMalformed(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, iterm2ProfilesManifest) + + s.WriteRepoFile(t, filepath.Join("iterm2", "DynamicProfiles", "Work.json"), workProfileJSON) + s.WriteRepoFile(t, filepath.Join("iterm2", "DynamicProfiles", "Bad.json"), "{ this is not json") + gitCommitAll(t, s.Repo, "dynamic profiles") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + + target := dynamicProfilesDir(s, "Work.json") + assertFileContains(t, target, "WORK-GUID-FROZEN-1") + // Byte-identical: ferry never rewrites the JSON (frozen GUID contract). + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read deployed profile: %v", err) + } + if string(data) != workProfileJSON { + t.Errorf("deployed profile bytes changed:\n got: %s\nwant: %s", data, workProfileJSON) + } + // Deployed as a REGULAR FILE copy — ferry never symlinks under $HOME. + fi, err := os.Lstat(target) + if err != nil { + t.Fatalf("lstat target: %v", err) + } + if fi.Mode()&os.ModeSymlink != 0 || !fi.Mode().IsRegular() { + t.Errorf("%s is not a regular-file copy (mode %v)", target, fi.Mode()) + } + // The malformed profile was refused and never deployed. + if _, err := os.Lstat(dynamicProfilesDir(s, "Bad.json")); err == nil { + t.Errorf("a malformed profile was deployed (must be refused)") + } +} + +// TestITerm2ProfilesScopeGated proves the domain is OFF by default: with no +// iterm2-profiles key in scope, a repo tree is never deployed. +func TestITerm2ProfilesScopeGated(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, baseManifest) // no iterm2-profiles key + s.WriteRepoFile(t, filepath.Join("iterm2", "DynamicProfiles", "Work.json"), workProfileJSON) + gitCommitAll(t, s.Repo, "profiles not in scope") + + tw := s.SnapshotFile(t, dynamicProfilesDir(s, "Work.json")) // absent + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + tw.AssertUnchanged(t) // still absent — domain out of scope +} + +// TestITerm2ProfilesLocalOverlayWins proves the per-machine overlay +// (local/iterm2-profiles/) overrides the shared repo copy for that file. +func TestITerm2ProfilesLocalOverlayWins(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, iterm2ProfilesManifest) + + s.WriteRepoFile(t, filepath.Join("iterm2", "DynamicProfiles", "Work.json"), workProfileJSON) + // A machine-local child profile referencing the shared parent's frozen GUID. + local := `{"Profiles":[{"Name":"Work","Guid":"WORK-GUID-FROZEN-1","Rewritable":false,"Normal Font":"Menlo 16"}]}` + s.WriteRepoFile(t, filepath.Join("local", "iterm2-profiles", "Work.json"), local) + gitCommitAll(t, s.Repo, "profiles with local overlay") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + assertFileContains(t, dynamicProfilesDir(s, "Work.json"), "Menlo 16") +} + +// TestITerm2ProfilesStatusCleanThenDrift proves status is clean right after apply +// (no spurious de-scope warning) and drifts after a live edit — the domain is +// repo-authoritative (apply would skip a live edit). +func TestITerm2ProfilesStatusCleanThenDrift(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, iterm2ProfilesManifest) + s.WriteRepoFile(t, filepath.Join("iterm2", "DynamicProfiles", "Work.json"), workProfileJSON) + gitCommitAll(t, s.Repo, "profiles") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + stdout, errOut, code := s.Ferry("status") + if code != 0 { + t.Fatalf("status exited %d; stderr:\n%s", code, errOut) + } + if !strings.Contains(stdout, "no drift detected") { + t.Errorf("status not clean right after apply; stdout:\n%s", stdout) + } + if strings.Contains(stdout, "no longer managed") || strings.Contains(stdout, "no longer part of the iterm2-profiles plan") { + t.Errorf("clean status falsely warned the managed profile is de-scoped; stdout:\n%s", stdout) + } + + // A live edit must surface as drift (repo-authoritative: apply would skip it). + if err := os.WriteFile(dynamicProfilesDir(s, "Work.json"), []byte(`{"Profiles":[{"Name":"Edited"}]}`), 0o644); err != nil { + t.Fatalf("live edit: %v", err) + } + stdout, errOut, code = s.Ferry("status") + if code != 0 { + t.Fatalf("status after edit exited %d; stderr:\n%s", code, errOut) + } + if strings.Contains(stdout, "no drift detected") || !strings.Contains(stdout, "drift") { + t.Errorf("status did not report drift after a live edit; stdout:\n%s", stdout) + } +} diff --git a/evals/iterm2_staging_test.go b/evals/iterm2_staging_test.go index 0c7067a..e4ad008 100644 --- a/evals/iterm2_staging_test.go +++ b/evals/iterm2_staging_test.go @@ -1,11 +1,11 @@ package evals -// Spot-checks for the iTerm2 secret-render-or-skip-on-apply fix: apply renders -// the repo iterm2 plist into a ferry-owned RENDERED STAGING FOLDER under StateDir -// and points PrefsCustomFolder THERE, so a present secret is substituted (iTerm2 -// never loads the raw `{{ferry.secret}}` plist); a MISSING secret OR a refused / -// symlinked leaf plist SKIPS the iterm2 domain (PrefsCustomFolder not pointed at -// it, live config intact). Darwin-only (the terminal domain is macOS-native). +// iTerm2 GLOBAL preference domain — the v0.7.0 D4 import-blob model (the +// custom-prefs-folder mechanism is retired). apply imports the committed, +// allowlist-filtered plist via `defaults import`, REFUSING when iTerm2 is running +// and flushing cfprefsd afterwards; capture reduces the live export to the +// allowlisted global keys (dropping NoSync*/geometry) before committing it. +// Darwin-only (the terminal domain is macOS-native). import ( "os" @@ -16,7 +16,8 @@ import ( ) // seedSecret writes a single secret value into the out-of-repo store so a -// `{{ferry.secret "domain.key"}}` placeholder resolves at apply time. +// `{{ferry.secret "domain.key"}}` placeholder resolves at apply time. (Shared with +// other evals — this domain's own secret handling rides the same pipeline.) func seedSecret(t *testing.T, s *Sandbox, domain, key, value string) { t.Helper() dir := filepath.Join(s.Home, ".config", "ferry", "secrets-local") @@ -29,156 +30,168 @@ func seedSecret(t *testing.T, s *Sandbox, domain, key, value string) { } } -// stagedITerm2Plist is the path apply stages the rendered iterm2 plist at. -func stagedITerm2Plist(s *Sandbox) string { - return filepath.Join(s.Home, ".local", "state", "ferry", "rendered", "iterm2", "com.googlecode.iterm2.plist") +// makeITerm2Stubs writes fake `defaults`, `pgrep` and `killall` recorders on a PATH +// dir. `running` controls whether `pgrep -x iTerm2` reports iTerm2 alive (exit 0) +// or not (exit 1). `defaults export …` prints exportPlist so Backup/capture see a +// live domain; `defaults import …` captures its stdin to importStdin. Returns the +// dir plus the paths of the recorder log and the captured import stdin. +func makeITerm2Stubs(t *testing.T, running bool, exportPlist string) (dir, logPath, importStdin string) { + t.Helper() + dir = t.TempDir() + logPath = filepath.Join(dir, "invocations.log") + importStdin = filepath.Join(dir, "import_stdin") + exportFile := filepath.Join(dir, "export.plist") + if err := os.WriteFile(exportFile, []byte(exportPlist), 0o644); err != nil { + t.Fatal(err) + } + + defaults := "#!/bin/sh\n" + + "echo \"defaults $*\" >> " + shellQuote(logPath) + "\n" + + "if [ \"$1\" = export ]; then cat " + shellQuote(exportFile) + "; fi\n" + + "if [ \"$1\" = import ]; then cat > " + shellQuote(importStdin) + "; fi\n" + + "exit 0\n" + pgrepExit := "1" + if running { + pgrepExit = "0" + } + pgrep := "#!/bin/sh\necho \"pgrep $*\" >> " + shellQuote(logPath) + "\nexit " + pgrepExit + "\n" + killall := "#!/bin/sh\necho \"killall $*\" >> " + shellQuote(logPath) + "\nexit 0\n" + + for name, body := range map[string]string{"defaults": defaults, "pgrep": pgrep, "killall": killall} { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o755); err != nil { + t.Fatalf("stub %s: %v", name, err) + } + } + return dir, logPath, importStdin +} + +func iTerm2PathEnv(stubDir string) string { + return "PATH=" + stubDir + string(os.PathListSeparator) + os.Getenv("PATH") } -func writeITerm2Manifest(t *testing.T, s *Sandbox) { +// writeGlobalITerm2Manifest seeds a repo with the iTerm2 global domain in scope and +// a committed com.googlecode.iterm2.plist (the allowlist-filtered global prefs). +func writeGlobalITerm2Manifest(t *testing.T, s *Sandbox, plist string) { t.Helper() s.WriteRepoFile(t, "ferry.toml", "[manage]\ndotfiles = [\".zshrc\"]\niterm2 = true\n") s.WriteRepoFile(t, ".zshrc", "# managed\n") s.WriteRepoFile(t, filepath.Join("dotfiles", ".zshrc"), "# managed\n") + if plist != "" { + s.WriteRepoFile(t, filepath.Join("iterm2", "com.googlecode.iterm2.plist"), plist) + } if err := os.WriteFile(s.ConfigTOMLPath(), []byte("repo = \""+s.Repo+"\"\n"), 0o644); err != nil { t.Fatal(err) } } -// (a) present secret => rendered plist staged (placeholder substituted) and -// PrefsCustomFolder points at the staging folder, never the raw repo folder. -func TestSpot_ITerm2SecretPresentRendersIntoStaging(t *testing.T) { +const globalITerm2Plist = ` + + + + PromptOnQuit + + + +` + +// (a) apply with iTerm2 NOT running imports the committed plist via `defaults +// import` and flushes cfprefsd (killall cfprefsd). +func TestSpot_ITerm2GlobalApplyImportsWhenNotRunning(t *testing.T) { if runtime.GOOS != "darwin" { - t.Skip("iterm2 staging: macOS-only") + t.Skip("iterm2 global domain: macOS-only") } s := NewSandbox(t) - writeITerm2Manifest(t, s) - seedSecret(t, s, "x", "y", "S3CRET-VALUE") - s.WriteRepoFile(t, filepath.Join("iterm2", "com.googlecode.iterm2.plist"), - "token={{ferry.secret \"x.y\"}}\n") + writeGlobalITerm2Manifest(t, s, globalITerm2Plist) - defStubDir, defLog := makeDefaultsStub(t) - pathOverride := "PATH=" + defStubDir + string(os.PathListSeparator) + os.Getenv("PATH") - out, errOut, code := s.FerryEnv([]string{pathOverride}, "apply") + stubDir, logPath, importStdin := makeITerm2Stubs(t, false, globalITerm2Plist) + out, errOut, code := s.FerryEnv([]string{iTerm2PathEnv(stubDir)}, "apply") if code != 0 { t.Fatalf("apply exited %d\n%s", code, out+errOut) } - - staged := stagedITerm2Plist(s) - data, err := os.ReadFile(staged) - if err != nil { - t.Fatalf("rendered plist not staged at %s: %v", staged, err) - } - if !strings.Contains(string(data), "S3CRET-VALUE") { - t.Errorf("staged plist did not substitute the secret\n%s", data) - } - if strings.Contains(string(data), "ferry.secret") { - t.Errorf("staged plist still carries the raw placeholder (unrendered)\n%s", data) + log, _ := os.ReadFile(logPath) + if !strings.Contains(string(log), "defaults import com.googlecode.iterm2 -") { + t.Errorf("did not `defaults import` the global plist\nlog:\n%s", log) } - // PrefsCustomFolder must point at the staging folder, NOT the raw repo folder. - logData, _ := os.ReadFile(defLog) - log := string(logData) - stageFolder := filepath.Dir(staged) - if !strings.Contains(log, "PrefsCustomFolder") || !strings.Contains(log, stageFolder) { - t.Errorf("PrefsCustomFolder not pointed at the staging folder %s\ndefaults log:\n%s", stageFolder, log) + if !strings.Contains(string(log), "killall cfprefsd") { + t.Errorf("did not flush cfprefsd after import\nlog:\n%s", log) } - if strings.Contains(log, s.RepoPath("iterm2")+" ") || strings.Contains(log, s.RepoPath("iterm2")+"\n") { - t.Errorf("PrefsCustomFolder was pointed at the RAW repo folder (leaks the placeholder plist)\n%s", log) + imported, _ := os.ReadFile(importStdin) + if !strings.Contains(string(imported), "PromptOnQuit") { + t.Errorf("imported blob was not the committed plist\n%s", imported) } - // The rendered secret must never reach the repo working tree / history. - s.AssertNoSecretInRepo(t, "S3CRET-VALUE") } -// (b) missing secret => iterm2 domain SKIPPED: nothing staged, PrefsCustomFolder -// not pointed at it, live config intact. -func TestSpot_ITerm2SecretMissingSkipsDomain(t *testing.T) { +// (b) apply REFUSES when iTerm2 is running: no import, cfprefsd not flushed, live +// config left intact, and the user is told to quit iTerm2. +func TestSpot_ITerm2GlobalApplyRefusesWhenRunning(t *testing.T) { if runtime.GOOS != "darwin" { - t.Skip("iterm2 staging: macOS-only") + t.Skip("iterm2 global domain: macOS-only") } s := NewSandbox(t) - writeITerm2Manifest(t, s) - // No secret seeded => x.y is MISSING. - s.WriteRepoFile(t, filepath.Join("iterm2", "com.googlecode.iterm2.plist"), - "token={{ferry.secret \"x.y\"}}\n") + writeGlobalITerm2Manifest(t, s, globalITerm2Plist) - defStubDir, defLog := makeDefaultsStub(t) - pathOverride := "PATH=" + defStubDir + string(os.PathListSeparator) + os.Getenv("PATH") - out, errOut, code := s.FerryEnv([]string{pathOverride}, "apply") + stubDir, logPath, _ := makeITerm2Stubs(t, true /* running */, globalITerm2Plist) + out, errOut, code := s.FerryEnv([]string{iTerm2PathEnv(stubDir)}, "apply") if code != 0 { t.Fatalf("apply exited %d\n%s", code, out+errOut) } - if !containsAnyFold(out+errOut, "iterm2") || !containsAnyFold(out+errOut, "skip", "missing") { - t.Errorf("expected an iterm2 skip notice for the missing secret\n%s", out+errOut) + if !containsAnyFold(out+errOut, "quit iterm2") { + t.Errorf("expected a 'quit iTerm2' skip notice while running\n%s", out+errOut) } - if _, err := os.Stat(stagedITerm2Plist(s)); err == nil { - t.Errorf("a plist was staged despite the missing secret (domain should be skipped)") + log, _ := os.ReadFile(logPath) + if strings.Contains(string(log), "defaults import com.googlecode.iterm2") { + t.Errorf("imported into a RUNNING iTerm2 (would be silently lost)\nlog:\n%s", log) } - logData, _ := os.ReadFile(defLog) - if strings.Contains(string(logData), "PrefsCustomFolder") { - t.Errorf("PrefsCustomFolder was set despite the missing secret\n%s", logData) + if strings.Contains(string(log), "killall cfprefsd") { + t.Errorf("flushed cfprefsd despite skipping the import\nlog:\n%s", log) } } -// (c) symlinked/escaping leaf plist => iterm2 domain SKIPPED (refusal honored for -// the very file iTerm2 would load, not swallowed). PrefsCustomFolder not set. -func TestSpot_ITerm2LeafPlistSymlinkSkipsDomain(t *testing.T) { +// (c) capture reduces the live export to the ALLOWLISTED global keys: an +// allowlisted key survives into the committed plist; volatile NoSync*/geometry keys +// are dropped. +func TestSpot_ITerm2GlobalCaptureFiltersAllowlist(t *testing.T) { if runtime.GOOS != "darwin" { - t.Skip("iterm2 staging: macOS-only") + t.Skip("iterm2 global domain: macOS-only") } s := NewSandbox(t) - s.SSHTripwire(t) - writeITerm2Manifest(t, s) - // A regular repo iterm2/ folder, but the LEAF plist is a symlink into ~/.ssh. - if err := os.MkdirAll(s.RepoPath("iterm2"), 0o755); err != nil { - t.Fatal(err) - } - leaf := s.RepoPath("iterm2", "com.googlecode.iterm2.plist") - if err := os.Symlink(filepath.Join(s.Home, ".ssh", "config"), leaf); err != nil { - t.Fatal(err) - } - - defStubDir, defLog := makeDefaultsStub(t) - pathOverride := "PATH=" + defStubDir + string(os.PathListSeparator) + os.Getenv("PATH") - out, errOut, _ := s.FerryEnv([]string{pathOverride}, "apply") - if !containsAnyFold(out+errOut, "iterm2") || !containsAnyFold(out+errOut, "refus", "skip") { - t.Errorf("expected a refusal/skip notice for the symlinked iterm2 leaf plist\n%s", out+errOut) - } - if _, err := os.Stat(stagedITerm2Plist(s)); err == nil { - t.Errorf("a plist was staged from a refused leaf (refusal swallowed)") - } - logData, _ := os.ReadFile(defLog) - if strings.Contains(string(logData), "PrefsCustomFolder") { - t.Errorf("PrefsCustomFolder was set for a refused leaf plist\n%s", logData) - } - s.AssertSSHUntouched(t) -} + // No committed plist yet, so the live export is pure drift to capture. + writeGlobalITerm2Manifest(t, s, "") -// (d) a normal plist with no secrets => applies; the rendered staged copy equals -// the original and iTerm2 is pointed at the staging folder. -func TestSpot_ITerm2NoSecretsStagesUnchangedCopy(t *testing.T) { - if runtime.GOOS != "darwin" { - t.Skip("iterm2 staging: macOS-only") - } - s := NewSandbox(t) - writeITerm2Manifest(t, s) - const body = "plain=1\nno-secrets-here\n" - s.WriteRepoFile(t, filepath.Join("iterm2", "com.googlecode.iterm2.plist"), body) - - defStubDir, defLog := makeDefaultsStub(t) - pathOverride := "PATH=" + defStubDir + string(os.PathListSeparator) + os.Getenv("PATH") - out, errOut, code := s.FerryEnv([]string{pathOverride}, "apply") + liveExport := ` + + + + PromptOnQuit + + NoSyncSuppressAnnoyingBellOffer + + NSWindow Frame iTerm Window 0 + 0 0 800 600 + PrefsCustomFolder + /tmp/whatever + + +` + stubDir, _, _ := makeITerm2Stubs(t, false, liveExport) + // Answer: capture the whole domain [y], route [s]hared. + out, errOut, code := s.FerryEnvWithInput("y\ns\n", []string{iTerm2PathEnv(stubDir)}, "capture") if code != 0 { - t.Fatalf("apply exited %d\n%s", code, out+errOut) + t.Fatalf("capture exited %d\n%s", code, out+errOut) } - staged := stagedITerm2Plist(s) - data, err := os.ReadFile(staged) + committed := s.RepoPath("iterm2", "com.googlecode.iterm2.plist") + data, err := os.ReadFile(committed) if err != nil { - t.Fatalf("rendered plist not staged at %s: %v", staged, err) + t.Fatalf("committed global plist not written at %s: %v\n%s", committed, err, out+errOut) } - if string(data) != body { - t.Errorf("staged copy != original\n got: %q\nwant: %q", data, body) + body := string(data) + if !strings.Contains(body, "PromptOnQuit") { + t.Errorf("allowlisted key PromptOnQuit was dropped\n%s", body) } - logData, _ := os.ReadFile(defLog) - if !strings.Contains(string(logData), "PrefsCustomFolder") || !strings.Contains(string(logData), filepath.Dir(staged)) { - t.Errorf("PrefsCustomFolder not pointed at the staging folder\n%s", logData) + for _, volatile := range []string{"NoSyncSuppressAnnoyingBellOffer", "NSWindow Frame", "PrefsCustomFolder"} { + if strings.Contains(body, volatile) { + t.Errorf("volatile key %q survived capture (must be dropped by the allowlist)\n%s", volatile, body) + } } } diff --git a/evals/keybindings_test.go b/evals/keybindings_test.go new file mode 100644 index 0000000..5a5e433 --- /dev/null +++ b/evals/keybindings_test.go @@ -0,0 +1,177 @@ +package evals + +// B1 — the macOS Cocoa key-bindings file carried as a single repo-authoritative +// nested target. These evals drive the real binary and assert observable +// outcomes: a valid old-style dict deploys as a regular-file COPY (never a +// symlink) from keybindings/ to ~/Library/KeyBindings/DefaultKeyBinding.dict when +// the domain is in scope; the domain is gated by [manage] keybindings; a binary +// plist (bplist00) source is refused; and status reports clean after apply and +// drift after a live edit (repo-authoritative, no capture pass). + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// keybindingsManifest enables the key-bindings domain plus the dotfiles domain +// (so scope has a second domain to prove independence). +const keybindingsManifest = `[manage] +dotfiles = [".zshrc"] +keybindings = true +brew = false +iterm2 = false +fonts = false +` + +// validKeyDict is a minimal readable old-style (NeXT/ASCII) key-bindings dict. +const validKeyDict = "{\n \"~f\" = \"moveWordForward:\";\n \"~b\" = \"moveWordBackward:\";\n}\n" + +// keybindingsRel is the home-relative destination the source deploys to. +var keybindingsRel = []string{"Library", "KeyBindings", "DefaultKeyBinding.dict"} + +// TestKeybindingsApplies proves an in-scope key-bindings source deploys from +// keybindings/ to its $HOME destination as a regular-file copy, not a symlink. +func TestKeybindingsApplies(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, keybindingsManifest) + s.WriteRepoFile(t, filepath.Join("keybindings", "DefaultKeyBinding.dict"), validKeyDict) + gitCommitAll(t, s.Repo, "keybindings") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + + target := s.HomePath(keybindingsRel...) + assertFileContains(t, target, "moveWordForward:") + // Deployed as a REGULAR FILE copy — ferry never symlinks under $HOME. + fi, err := os.Lstat(target) + if err != nil { + t.Fatalf("lstat target: %v", err) + } + if fi.Mode()&os.ModeSymlink != 0 { + t.Errorf("%s is a symlink; ferry must deploy a regular-file copy", target) + } + if !fi.Mode().IsRegular() { + t.Errorf("%s is not a regular file (mode %v)", target, fi.Mode()) + } +} + +// TestKeybindingsScopeGated proves the domain is OFF by default: with no +// `keybindings` key in scope, a repo key-bindings source is never deployed. +func TestKeybindingsScopeGated(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, baseManifest) // no `keybindings` key + s.WriteRepoFile(t, filepath.Join("keybindings", "DefaultKeyBinding.dict"), validKeyDict) + gitCommitAll(t, s.Repo, "keybindings not in scope") + + target := s.HomePath(keybindingsRel...) + tw := s.SnapshotFile(t, target) // absent + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + tw.AssertUnchanged(t) // still absent — domain out of scope +} + +// TestKeybindingsRefusesBinaryPlist proves a bplist00 (binary plist) source is +// refused, never deployed: an editor silently saving the dict as binary must not +// clobber the live file with an unreadable form. +func TestKeybindingsRefusesBinaryPlist(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, keybindingsManifest) + // bplist00 magic header + arbitrary trailing bytes. + s.WriteRepoFile(t, filepath.Join("keybindings", "DefaultKeyBinding.dict"), "bplist00\x01\x02\x03binary") + gitCommitAll(t, s.Repo, "binary keybindings") + + target := s.HomePath(keybindingsRel...) + tw := s.SnapshotFile(t, target) // absent + stdout, errOut, code := s.Ferry("apply") + if code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + tw.AssertUnchanged(t) // the binary plist was never deployed + combined := stdout + errOut + if !strings.Contains(combined, "BINARY plist") && !strings.Contains(combined, "bplist00") { + t.Errorf("expected a binary-plist refusal in output; got:\n%s", combined) + } +} + +// TestKeybindingsRefusesMalformedPlist proves the production `plutil -lint` gate +// is LIVE on macOS: a UTF-8 dict that is not a well-formed property list (so the +// pure-Go bplist00/BOM/UTF-8 checks all pass) is still refused before the copy +// lands. Skipped off darwin, where plutil does not exist and the lint is a clean +// no-op by design. +func TestKeybindingsRefusesMalformedPlist(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("plutil -lint is macOS only") + } + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, keybindingsManifest) + // Valid UTF-8, no BOM, no bplist00 header — but an unbalanced brace, so only + // plutil catches it. + s.WriteRepoFile(t, filepath.Join("keybindings", "DefaultKeyBinding.dict"), "{ \"~f\" = \"moveWordForward:\"; ") + gitCommitAll(t, s.Repo, "malformed keybindings") + + target := s.HomePath(keybindingsRel...) + tw := s.SnapshotFile(t, target) // absent + stdout, errOut, code := s.Ferry("apply") + if code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + tw.AssertUnchanged(t) // the malformed dict was never deployed + if !strings.Contains(stdout+errOut, "lint failed") { + t.Errorf("expected a plutil lint refusal in output; got:\n%s", stdout+errOut) + } +} + +// TestKeybindingsStatusCleanThenDrift proves status reports clean right after +// apply and drift after a live edit — the repo-authoritative reconcile (the OS +// never rewrites the file, so drift appears only when the user edits it). +func TestKeybindingsStatusCleanThenDrift(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, keybindingsManifest) + s.WriteRepoFile(t, filepath.Join("keybindings", "DefaultKeyBinding.dict"), validKeyDict) + gitCommitAll(t, s.Repo, "keybindings") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + + // Clean immediately after apply: the domain reports in sync, no drift, and no + // spurious "no longer managed" de-scope warning for the still-managed target. + stdout, errOut, code := s.Ferry("status") + if code != 0 { + t.Fatalf("status exited %d; stderr:\n%s", code, errOut) + } + if !strings.Contains(stdout, "no drift detected") { + t.Errorf("status did not report clean right after apply; stdout:\n%s", stdout) + } + if strings.Contains(stdout, "no longer managed") { + t.Errorf("clean status falsely warned the managed keybindings target is de-scoped; stdout:\n%s", stdout) + } + + // A live edit must surface as drift (repo-authoritative: apply would skip it). + target := s.HomePath(keybindingsRel...) + if err := os.WriteFile(target, []byte("{\n \"~d\" = \"deleteWordForward:\";\n}\n"), 0o644); err != nil { + t.Fatalf("live edit: %v", err) + } + stdout, errOut, code = s.Ferry("status") + if code != 0 { + t.Fatalf("status after edit exited %d; stderr:\n%s", code, errOut) + } + if strings.Contains(stdout, "no drift detected") || !strings.Contains(stdout, "drift") { + t.Errorf("status did not report drift after a live edit; stdout:\n%s", stdout) + } +} diff --git a/evals/npmrc_test.go b/evals/npmrc_test.go new file mode 100644 index 0000000..b5353d0 --- /dev/null +++ b/evals/npmrc_test.go @@ -0,0 +1,199 @@ +package evals + +// npm `~/.npmrc` config plugin (v0.7.0 Phase B) — exercised through the REAL +// binary. `~/.npmrc` rides the EXISTING flat dotfiles FileDomain as a plain +// whole-file-replace dotfile (no include sidecar, no nesting): a shared +// ~/.npmrc reconciled by hash like any other dotfile. The registry auth token is +// the only hazard, routed through ferry's existing secret pipeline: +// - a LITERAL `//registry.npmjs.org/:_authToken=npm_…` line is gate-blocked, +// stored out-of-repo, and only a placeholder reaches the shared repo; a +// redeploy renders the literal token back so npm sees the real value; +// - a `${NPM_TOKEN}` env-ref (npm expands the environment at read time) is NOT +// a literal secret, so it is carried to the shared repo verbatim; +// - the deployed ~/.npmrc is a regular-file copy (never a symlink), and status +// reads clean after apply. + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// npmrcManifest manages .npmrc as a plain whole-file dotfile (NOT an +// include-sidecar domain — it must never gain a sourced sidecar). +const npmrcManifest = `[manage] +dotfiles = [".npmrc"] +brew = false +iterm2 = false +fonts = false +` + +// npmToken is a realistic npm automation token (the `npm_` prefix). It carries no +// placeholder word, so IsNonPlaceholderSecret accepts it as a real secret. +const npmToken = "npm_FAKE0000aaaa1111bbbb2222cccc3333dddd" + +// seedNpmrc writes the manifest and a shared, secret-FREE ~/.npmrc source in both +// repo layout paths (dot and dot-stripped), matching the git/tmux eval seeds. +func seedNpmrc(t *testing.T, s *Sandbox, shared string) { + t.Helper() + s.SeedSharedManifest(t, npmrcManifest) + s.WriteRepoFile(t, filepath.Join("dotfiles", "npmrc"), shared) + s.WriteRepoFile(t, filepath.Join("dotfiles", ".npmrc"), shared) + s.WriteRepoFile(t, ".gitignore", "local/\n") +} + +// TestNpmrc_DeploysAsRegularFileCopy proves the baseline carriage: apply deploys +// ~/.npmrc as a regular-file copy (never a symlink), the shared non-secret +// content lands verbatim, no include sidecar is materialised, and a no-op capture +// + status reports clean and leaves the deployed file byte-, mode-, size- and +// mtime-identical. +func TestNpmrc_DeploysAsRegularFileCopy(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + const sharedNpmrc = "save-exact=true\nregistry=https://registry.npmjs.org/\n" + seedNpmrc(t, s, sharedNpmrc) + gitCommitAll(t, s.Repo, "baseline npmrc (no secret)") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + + target := s.HomePath(".npmrc") + info, err := os.Lstat(target) + if err != nil { + t.Fatalf("apply did not deploy ~/.npmrc: %v", err) + } + // The core invariant: nothing under $HOME is symlinked — it is a regular file. + if info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("~/.npmrc was deployed as a SYMLINK — ferry must deploy regular-file copies") + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read ~/.npmrc: %v", err) + } + if !strings.Contains(string(got), "save-exact=true") { + t.Errorf("~/.npmrc lost the shared content:\n%s", got) + } + // A whole-file dotfile must NOT gain any sourced sidecar directive. + if strings.Contains(string(got), "source") || strings.Contains(string(got), "[include]") { + t.Errorf("~/.npmrc gained an include/sidecar directive — it must be a plain whole-file dotfile:\n%s", got) + } + if _, err := os.Lstat(s.HomePath(".npmrc.local")); err == nil { + t.Errorf("apply materialised a ~/.npmrc.local sidecar — .npmrc is not an include-sidecar domain") + } + + tw := s.SnapshotFile(t, target) + if _, errOut, code := s.Ferry("capture"); code != 0 { + t.Fatalf("no-op capture exited %d; stderr:\n%s", code, errOut) + } + statusOut, statusErr, statusCode := s.Ferry("status") + statusCombined := statusOut + statusErr + if statusCode != 0 { + t.Errorf("clean status exited %d\n%s", statusCode, statusCombined) + } + if !containsAnyFold(statusCombined, + "no drift", "clean", "up to date", "up-to-date", "no changes", "nothing to", "in sync") { + t.Errorf("clean status gave no positive no-drift signal after a no-op capture\n%s", statusCombined) + } + tw.AssertUnchanged(t) +} + +// TestNpmrc_AuthTokenNeverReachesRepo is the load-bearing secret gate for +// `~/.npmrc` (plan STOP condition): a LITERAL registry auth token edited into the +// live ~/.npmrc is gate-blocked, routed to the out-of-repo secret store, and only +// a placeholder reaches the shared repo; a redeploy renders the literal token +// back so npm reads the real value. +func TestNpmrc_AuthTokenNeverReachesRepo(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + const sharedNpmrc = "save-exact=true\n" + seedNpmrc(t, s, sharedNpmrc) + gitCommitAll(t, s.Repo, "baseline npmrc") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("setup apply exited %d; stderr:\n%s", code, errOut) + } + + // Edit the live file: add a literal npm registry auth token line. + tokenLine := "//registry.npmjs.org/:_authToken=" + npmToken + "\n" + if err := os.WriteFile(s.HomePath(".npmrc"), []byte(sharedNpmrc+tokenLine), 0o600); err != nil { + t.Fatalf("stage npmrc secret: %v", err) + } + + // The live ~/.npmrc now holds a high-confidence secret and the repo source + // carries no placeholder yet, so the whole-file block escape is the only repo + // route offered: [r]eject / secret-store [x]. Route it to the out-of-repo + // store (x) — the value is stored out of band and a placeholder is written to + // the committed source in its place. + out, errOut, _ := s.FerryWithInput("x\n", "capture") + combined := out + errOut + + // HARD: the literal token never lands in any repo file or git history — it + // lives only in the out-of-repo secret store (~/.config/ferry/secrets-local). + s.AssertNoSecretInRepo(t, npmToken) + + repoShared, err := os.ReadFile(s.RepoPath("dotfiles", "npmrc")) + if err != nil { + t.Fatalf("read repo shared npmrc: %v", err) + } + if strings.Contains(string(repoShared), npmToken) { + t.Fatalf("the literal npm token reached the shared repo:\n%s", repoShared) + } + if !strings.Contains(string(repoShared), "{{ferry.secret") { + t.Errorf("repo shared npmrc did not gain a ferry placeholder in place of the secret:\n%s\ncapture output:\n%s", repoShared, combined) + } + + // Redeploy renders the placeholder back to the literal token so npm sees the + // real value: remove the live file so apply re-materialises it fresh from the + // placeholder-bearing repo. A secret-routed target is risky, so confirm the gate. + if err := os.Remove(s.HomePath(".npmrc")); err != nil { + t.Fatalf("remove live npmrc: %v", err) + } + if _, errOut, code := s.ApplyConfirmed(); code != 0 { + t.Fatalf("redeploy apply exited %d; stderr:\n%s", code, errOut) + } + deployed, err := os.ReadFile(s.HomePath(".npmrc")) + if err != nil { + t.Fatalf("read redeployed ~/.npmrc: %v", err) + } + if !strings.Contains(string(deployed), tokenLine) { + t.Errorf("deploy did not render the npm token back literally into ~/.npmrc:\n%s", deployed) + } +} + +// TestNpmrc_EnvRefLeftVerbatim is the negative to the secret gate: a +// `//registry.npmjs.org/:_authToken=${NPM_TOKEN}` env-ref is NOT a literal secret +// (npm expands the environment at read time), so it is never gate-blocked and +// reaches the shared repo verbatim — the ${…} exemption from +// secret.IsNonPlaceholderSecret. This is the recommended pattern. +func TestNpmrc_EnvRefLeftVerbatim(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + const sharedNpmrc = "save-exact=true\n" + seedNpmrc(t, s, sharedNpmrc) + gitCommitAll(t, s.Repo, "baseline npmrc") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("setup apply exited %d; stderr:\n%s", code, errOut) + } + + const envLine = "//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n" + if err := os.WriteFile(s.HomePath(".npmrc"), []byte(sharedNpmrc+envLine), 0o600); err != nil { + t.Fatalf("stage npmrc env-ref: %v", err) + } + + // The env-ref is not a secret: accept the hunk (y) and route to shared (s). + s.FerryWithInput("y\ns\n", "capture") + + repoShared, err := os.ReadFile(s.RepoPath("dotfiles", "npmrc")) + if err != nil { + t.Fatalf("read repo shared npmrc: %v", err) + } + if !strings.Contains(string(repoShared), envLine) { + t.Errorf("the ${NPM_TOKEN} env-ref was not carried to the shared repo verbatim:\n%s", repoShared) + } +} diff --git a/evals/tmux_test.go b/evals/tmux_test.go new file mode 100644 index 0000000..869588a --- /dev/null +++ b/evals/tmux_test.go @@ -0,0 +1,220 @@ +package evals + +// tmux config plugin (v0.7.0 Phase B) — the include-sidecar carriage and the +// column-grained secret recogniser, exercised through the REAL binary. +// +// tmux rides the existing dotfiles FileDomain as an include-sidecar dotfile: a +// shared ~/.tmux.conf with ferry's injected `source-file -q ~/.tmux.conf.local` +// directive sourced LAST (so the per-machine sidecar wins), plus a +// ~/.tmux.conf.local materialised from local/tmux/tmux.conf.local. These evals +// mirror the zsh byte-stable migration eval and the secret-blocked eval, proving +// the tmux directive round-trips byte-for-byte and a literal `set -g @token` +// value NEVER reaches the shared repo. + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// tmuxManifest manages .tmux.conf — an include-sidecar dotfile whose shared +// source gains ferry's `source-file -q ~/.tmux.conf.local` directive when an +// overlay exists. +const tmuxManifest = `[manage] +dotfiles = [".tmux.conf"] +brew = false +iterm2 = false +fonts = false +` + +// tmuxSecret is a GitHub-token-shaped High-confidence secret (no placeholder +// words like "example", so IsNonPlaceholderSecret accepts it as a real secret). +const tmuxSecret = "ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8" + +// TestTmux_RoundTripByteStable mirrors the zsh byte-stable migration eval for +// tmux: apply materialises the include split (a shared ~/.tmux.conf ending in +// `source-file -q ~/.tmux.conf.local`, plus the ~/.tmux.conf.local sidecar), and +// a no-op capture + status leaves BOTH home files and BOTH repo sources +// byte-, mode-, size- AND mtime-identical, reporting clean. +func TestTmux_RoundTripByteStable(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, tmuxManifest) + + const sharedTmux = "# managed tmux.conf\nset -g mouse on\nset -g history-limit 10000\n" + s.WriteRepoFile(t, filepath.Join("dotfiles", "tmux.conf"), sharedTmux) + s.WriteRepoFile(t, filepath.Join("dotfiles", ".tmux.conf"), sharedTmux) + + // The overlay's presence is what makes apply materialise the include SPLIT. + const overlayTmux = "# per-machine tmux\nset -g status-bg blue\n" + s.WriteRepoFile(t, filepath.Join("local", "tmux", "tmux.conf.local"), overlayTmux) + + gitCommitAll(t, s.Repo, "baseline tmux + overlay") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("apply exited %d; stderr:\n%s", code, errOut) + } + + sharedTarget := s.HomePath(".tmux.conf") + sidecarTarget := s.HomePath(".tmux.conf.local") + + // The include split must have materialised (both files exist) and the shared + // file must carry ferry's tmux directive — else the byte-stability claim is + // vacuous. + got, err := os.ReadFile(sharedTarget) + if err != nil { + t.Fatalf("apply did not deploy ~/.tmux.conf: %v", err) + } + if !strings.Contains(string(got), "source-file -q ~/.tmux.conf.local") { + t.Fatalf("~/.tmux.conf is missing ferry's injected tmux directive:\n%s", got) + } + sidecar, err := os.ReadFile(sidecarTarget) + if err != nil { + t.Fatalf("apply did not materialise the ~/.tmux.conf.local sidecar: %v", err) + } + if !strings.Contains(string(sidecar), "status-bg blue") { + t.Fatalf("~/.tmux.conf.local sidecar missing the overlay content:\n%s", sidecar) + } + + sharedTW := s.SnapshotFile(t, sharedTarget) + sidecarTW := s.SnapshotFile(t, sidecarTarget) + repoSharedTW := s.SnapshotFile(t, s.RepoPath("dotfiles", ".tmux.conf")) + repoOverlayTW := s.SnapshotFile(t, s.RepoPath("local", "tmux", "tmux.conf.local")) + + // No-op capture (nothing drifted): must not rewrite any home file. + if _, errOut, code := s.Ferry("capture"); code != 0 { + t.Fatalf("no-op capture exited %d; stderr:\n%s", code, errOut) + } + + statusOut, statusErr, statusCode := s.Ferry("status") + statusCombined := statusOut + statusErr + if statusCode != 0 { + t.Errorf("clean `status` exited %d (want 0)\n%s", statusCode, statusCombined) + } + if !containsAnyFold(statusCombined, + "no drift", "clean", "up to date", "up-to-date", "no changes", "nothing to", "in sync") { + t.Errorf("clean `status` gave no positive clean/no-drift signal after a no-op capture\n%s", statusCombined) + } + + // Load-bearing: both halves of the split and both repo sources are unchanged. + sharedTW.AssertUnchanged(t) + sidecarTW.AssertUnchanged(t) + repoSharedTW.AssertUnchanged(t) + repoOverlayTW.AssertUnchanged(t) +} + +// TestTmux_SecretNeverReachesRepo is the load-bearing secret gate for tmux: a +// literal `set -g @token ''` value edited into the live ~/.tmux.conf is +// routed to the out-of-repo secret store as a column-grained span (the +// `set -g @token '…'` syntax preserved), a placeholder is committed instead, and +// a subsequent deploy renders the literal token back verbatim. +func TestTmux_SecretNeverReachesRepo(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, tmuxManifest) + + const sharedTmux = "# managed tmux.conf\nset -g mouse on\n" + s.WriteRepoFile(t, filepath.Join("dotfiles", "tmux.conf"), sharedTmux) + s.WriteRepoFile(t, filepath.Join("dotfiles", ".tmux.conf"), sharedTmux) + gitCommitAll(t, s.Repo, "baseline tmux") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("setup apply exited %d; stderr:\n%s", code, errOut) + } + + // Edit the live file: add a literal secret inside a quoted tmux option value. + secretLine := "set -g @token '" + tmuxSecret + "'\n" + if err := os.WriteFile(s.HomePath(".tmux.conf"), []byte(sharedTmux+secretLine), 0o644); err != nil { + t.Fatalf("stage tmux secret: %v", err) + } + + // Accept the drifted hunk (y), route the blocked secret span to the store (x), + // then route the (now placeholder-bearing) change to shared (s). + out, errOut, _ := s.FerryWithInput("y\nx\ns\n", "capture") + combined := out + errOut + + // HARD: the literal secret bytes never land in any repo file or git history. + s.AssertNoSecretInRepo(t, tmuxSecret) + + // The shared repo source must carry a ferry placeholder in the option line — + // the `set -g @token` prefix and the quotes preserved, only the value swapped. + repoShared, err := os.ReadFile(s.RepoPath("dotfiles", "tmux.conf")) + if err != nil { + t.Fatalf("read repo shared tmux.conf: %v", err) + } + if !strings.Contains(string(repoShared), "set -g @token '{{ferry.secret") { + t.Errorf("repo shared tmux.conf did not gain a column-grained placeholder in the option line:\n%s\ncapture output:\n%s", repoShared, combined) + } + if strings.Contains(string(repoShared), tmuxSecret) { + t.Fatalf("the literal secret reached the shared repo source:\n%s", repoShared) + } + + // After a full span-store capture, status must read CLEAN: last-applied + // records the RENDERED-effective hash, so the still-live token is not + // reported as spurious drift against the placeholder-bearing repo source. + statusOut, statusErr, _ := s.Ferry("status") + statusCombined := statusOut + statusErr + if !containsAnyFold(statusCombined, + "no drift", "clean", "up to date", "up-to-date", "no changes", "nothing to", "in sync") { + t.Errorf("status did not read clean after a span-store capture (last-applied render regression?)\n%s", statusCombined) + } + + // Deploy renders the placeholder back to the literal token: remove the live + // file so apply re-materialises it fresh from the (placeholder-bearing) repo. + if err := os.Remove(s.HomePath(".tmux.conf")); err != nil { + t.Fatalf("remove live tmux.conf: %v", err) + } + // A secret-routed target is always risky (its deployed bytes hold a plaintext + // secret), so confirm the guided-apply risk gate on the redeploy. + if _, errOut, code := s.ApplyConfirmed(); code != 0 { + t.Fatalf("redeploy apply exited %d; stderr:\n%s", code, errOut) + } + deployed, err := os.ReadFile(s.HomePath(".tmux.conf")) + if err != nil { + t.Fatalf("read redeployed ~/.tmux.conf: %v", err) + } + if !strings.Contains(string(deployed), secretLine) { + t.Errorf("deploy did not render the secret back literally into ~/.tmux.conf:\n%s", deployed) + } +} + +// TestTmux_EnvRefLeftVerbatim is the negative to the secret gate: a +// `set -g @token '${TMUX_TOKEN}'` env-ref is NOT a literal secret (tmux expands +// it at read time), so it is never gate-blocked and reaches the shared repo +// verbatim — the ${...} exemption the recogniser inherits from +// secret.IsNonPlaceholderSecret. +func TestTmux_EnvRefLeftVerbatim(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + s.InitGitRepo(t) + s.SeedSharedManifest(t, tmuxManifest) + + const sharedTmux = "# managed tmux.conf\nset -g mouse on\n" + s.WriteRepoFile(t, filepath.Join("dotfiles", "tmux.conf"), sharedTmux) + s.WriteRepoFile(t, filepath.Join("dotfiles", ".tmux.conf"), sharedTmux) + gitCommitAll(t, s.Repo, "baseline tmux") + + if _, errOut, code := s.Ferry("apply"); code != 0 { + t.Fatalf("setup apply exited %d; stderr:\n%s", code, errOut) + } + + const envRef = "${TMUX_TOKEN}" + envLine := "set -g @token '" + envRef + "'\n" + if err := os.WriteFile(s.HomePath(".tmux.conf"), []byte(sharedTmux+envLine), 0o644); err != nil { + t.Fatalf("stage tmux env-ref: %v", err) + } + + // The env-ref is not a secret: accept the hunk (y) and route to shared (s). + s.FerryWithInput("y\ns\n", "capture") + + repoShared, err := os.ReadFile(s.RepoPath("dotfiles", "tmux.conf")) + if err != nil { + t.Fatalf("read repo shared tmux.conf: %v", err) + } + if !strings.Contains(string(repoShared), envLine) { + t.Errorf("the ${ENV} env-ref was not carried to the shared repo verbatim:\n%s", repoShared) + } +} diff --git a/internal/bundle/bundle_test.go b/internal/bundle/bundle_test.go index cd564f5..0ac29be 100644 --- a/internal/bundle/bundle_test.go +++ b/internal/bundle/bundle_test.go @@ -614,3 +614,45 @@ func TestExtractIntoFreshStagingDir(t *testing.T) { t.Errorf("extraction leaked into the outside dir: %v", ents) } } + +// TestWriteReproducibleSHA is the reproducibility guarantee: two exports of +// BYTE-IDENTICAL sources produce a BYTE-IDENTICAL bundle and therefore the same +// overall SHA256. bundle.Write injects no timestamps or randomness — member order +// is a deterministic sort and zip mtimes are zero-valued — so the returned anchor +// is a pure function of the tracked inputs. This is what lets a user recompute the +// SHA and confirm a bundle was produced from the same sources. +func TestWriteReproducibleSHA(t *testing.T) { + dir := t.TempDir() + a := filepath.Join(dir, "a.txt") + b := filepath.Join(dir, "b.txt") + if err := os.WriteFile(a, []byte("alpha\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(b, []byte("beta\n"), 0o755); err != nil { + t.Fatal(err) + } + sources := []Source{ + {RelPath: "z/b.txt", AbsPath: b, Data: []byte("beta\n")}, + {RelPath: "a.txt", AbsPath: a, Data: []byte("alpha\n")}, + } + + out1 := filepath.Join(dir, "one.zip") + out2 := filepath.Join(dir, "two.zip") + sha1, err := Write(out1, "1.2.3", false, sources) + if err != nil { + t.Fatalf("Write one: %v", err) + } + sha2, err := Write(out2, "1.2.3", false, sources) + if err != nil { + t.Fatalf("Write two: %v", err) + } + + if sha1 != sha2 { + t.Errorf("reproducibility broken: two writes of identical sources gave different SHAs\n one=%s\n two=%s", sha1, sha2) + } + bytes1, _ := os.ReadFile(out1) + bytes2, _ := os.ReadFile(out2) + if !bytes.Equal(bytes1, bytes2) { + t.Errorf("reproducibility broken: two bundles of identical sources are not byte-identical (%d vs %d bytes)", len(bytes1), len(bytes2)) + } +} diff --git a/internal/deps/npm.go b/internal/deps/npm.go new file mode 100644 index 0000000..3d77715 --- /dev/null +++ b/internal/deps/npm.go @@ -0,0 +1,301 @@ +package deps + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// npmBin is the npm executable name, resolved through PATH by the runner (npm is +// NOT a root rail — it runs as the invoking user, never under sudo — so it stays +// $PATH-resolved like brew, which also lets the eval harness shadow it with a stub). +const npmBin = "npm" + +// npmGlobalsFileName is the committed, cross-machine list of globally-installed +// npm package NAMES (never versions/tree — a version-pinned list churns on every +// minor bump). It lives beside the Brewfile/apt manifests in the repo's deps/ dir. +const npmGlobalsFileName = "npm-globals.txt" + +// NpmGlobalsFile returns the absolute path of the committed npm globals list +// under depsDir. Its on-disk existence is the caller's concern (an absent list is +// a valid empty one). +func NpmGlobalsFile(depsDir string) string { + return filepath.Join(depsDir, npmGlobalsFileName) +} + +// npmGlobalsQuery is the read-only listing that names the globally-installed +// packages. `--depth=0` keeps it to the top level (no dependency tree) and +// `--json` gives a stable, parseable shape; NAMES are taken from the top-level +// dependencies object and versions are discarded. +var npmGlobalsQuery = []string{"ls", "-g", "--json", "--depth=0"} + +// npmLsOutput is the subset of `npm ls -g --json --depth=0` we read: the +// top-level dependencies object, keyed by package name. Versions are ignored on +// purpose (names-only carry). +type npmLsOutput struct { + Dependencies map[string]json.RawMessage `json:"dependencies"` +} + +// DumpNpmGlobals returns the sorted, de-duplicated NAMES of the globally-installed +// npm packages on THIS machine, excluding npm itself (npm is the manager, never a +// carried package). It runs the read-only `npm ls -g --json --depth=0` through the +// runner and reads names only — never versions. `npm ls` can exit non-zero when +// the global tree has peer-dependency warnings while still emitting valid JSON, so +// a parseable body is used even when the runner reports an error; only an +// unparseable body is a hard failure. +func DumpNpmGlobals(runner CommandRunner) ([]string, error) { + if runner == nil { + return nil, fmt.Errorf("deps: nil CommandRunner") + } + out, runErr := runner.Run(append([]string{npmBin}, npmGlobalsQuery...)...) + var parsed npmLsOutput + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + if runErr != nil { + return nil, fmt.Errorf("deps: npm ls -g: %w", runErr) + } + return nil, fmt.Errorf("deps: parse npm ls -g output: %w", err) + } + var names []string + for name := range parsed.Dependencies { + if name == "" || name == npmBin { + continue + } + names = append(names, name) + } + sort.Strings(names) + return names, nil +} + +// ReDumpNpmGlobals re-dumps THIS machine's global npm package NAMES to +// deps/npm-globals.txt (deterministic, sorted, one name per line). It is the +// npm analogue of ReDumpManifest for brew: capture calls it. The target is +// symlink-guarded BEFORE the write (ferry only writes regular files under deps/, +// so a symlinked npm-globals.txt or a symlinked deps/ directory is refused, never +// written through). Returns the absolute path written. +func ReDumpNpmGlobals(depsDir string, runner CommandRunner) (string, error) { + if depsDir == "" { + return "", fmt.Errorf("deps: empty deps directory") + } + names, err := DumpNpmGlobals(runner) + if err != nil { + return "", err + } + target := NpmGlobalsFile(depsDir) + if err := refuseSymlinkTarget(target); err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return "", fmt.Errorf("deps: create deps dir for %s: %w", target, err) + } + if err := os.WriteFile(target, []byte(renderNpmGlobals(names)), 0o644); err != nil { + return "", fmt.Errorf("deps: write %s: %w", target, err) + } + return target, nil +} + +// renderNpmGlobals renders names as a deterministic file body: one name per line, +// sorted (the caller sorts), with a trailing newline. An empty list yields an +// empty file, which reads back as an empty set. +func renderNpmGlobals(names []string) string { + if len(names) == 0 { + return "" + } + return strings.Join(names, "\n") + "\n" +} + +// ReadNpmGlobals reads and validates the committed npm globals list. A missing +// file is a valid empty set (not an error). Every surviving entry is validated as +// a plain npm package NAME (ValidateNpmName): the list comes from a cloned config +// repo and its entries reach `npm i -g` as arguments, so a spec such as a git URL, +// a leading "-" flag, or a local path is REFUSED before install — fail closed, +// mirroring the apt/Brewfile gates. +func ReadNpmGlobals(depsDir string) ([]string, error) { + if depsDir == "" { + return nil, fmt.Errorf("deps: empty deps directory") + } + target := NpmGlobalsFile(depsDir) + // Symlink-guard the repo-side list BEFORE os.ReadFile, mirroring the Brewfile/ + // apt.txt guard: a symlinked npm-globals.txt (or symlinked deps/) is refused, + // never read through. + safe, err := safeRepoManifest(filepath.Dir(filepath.Dir(target)), target) + if err != nil { + return nil, err + } + data, err := os.ReadFile(safe) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("deps: read %s: %w", safe, err) + } + return parseNpmGlobals(string(data)) +} + +// parseNpmGlobals returns the validated, de-duplicated, sorted package names in an +// npm-globals.txt: one name per line, blank lines and # comments ignored. A name +// that fails ValidateNpmName refuses the WHOLE list (fail closed). +func parseNpmGlobals(s string) ([]string, error) { + seen := map[string]struct{}{} + var out []string + for _, raw := range strings.Split(s, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if err := ValidateNpmName(line); err != nil { + return nil, err + } + if _, dup := seen[line]; dup { + continue + } + seen[line] = struct{}{} + out = append(out, line) + } + sort.Strings(out) + return out, nil +} + +// InstallNpmGlobals reconciles this machine's global npm packages to the committed +// list: `npm i -g -- `. It is INSTALL/RECONCILE-ONLY — it never uninstalls +// (mirroring the Homebrew cleanup-out decision), so packages present locally but +// not in the list are left alone. An empty list is a clean no-op. `--` ends npm's +// option parsing so a name can never be read as a flag (defence in depth on top of +// ValidateNpmName). Returns the reconciled names. All npm invocation goes through +// runner, so unit tests drive it with a stub — no real npm. +func InstallNpmGlobals(depsDir string, runner CommandRunner) ([]string, error) { + if runner == nil { + return nil, fmt.Errorf("deps: nil CommandRunner") + } + names, err := ReadNpmGlobals(depsDir) + if err != nil { + return nil, err + } + if len(names) == 0 { + return nil, nil + } + args := append([]string{npmBin, "i", "-g", "--"}, names...) + if _, err := runner.Run(args...); err != nil { + return nil, fmt.Errorf("deps: %s: %w", joinArgs(args), err) + } + return names, nil +} + +// NpmGlobalsDrift reports how THIS machine's globally-installed npm packages +// differ from the committed list, WITHOUT installing anything. Added = installed +// live but not in the list (capture would record); Removed = in the list but not +// installed (apply --deps would install). Names-only, so a version bump never +// shows as drift. Presence of npm is the caller's gate (platform.HasNpm); this +// runs only the read-only listing through the runner. +func NpmGlobalsDrift(depsDir string, runner CommandRunner) (Drift, error) { + listed, err := ReadNpmGlobals(depsDir) + if err != nil { + return Drift{}, err + } + live, err := DumpNpmGlobals(runner) + if err != nil { + return Drift{}, err + } + repoSet := toSet(listed) + liveSet := toSet(live) + return diffDrift(repoSet, liveSet), nil +} + +// toSet builds a presence set from a name slice. +func toSet(names []string) map[string]struct{} { + set := make(map[string]struct{}, len(names)) + for _, n := range names { + set[n] = struct{}{} + } + return set +} + +// ValidateNpmName refuses an npm-globals.txt entry that is not a plain package +// name. It is a trust boundary: npm-globals.txt comes from a cloned config repo +// and every entry reaches `npm i -g` as an argument, where `npm install` accepts +// far more than package names — git URLs, tarball URLs, `` local paths, +// and `@scope/name@version` specs — any of which can fetch and run arbitrary +// install-time code (npm lifecycle scripts). Only a bare registry package name is +// allowed: +// +// - optionally scoped: a single leading "@scope/" segment; +// - the (scope and) package part is lowercase-name-shaped: URL-safe characters +// only (letters, digits, and - . _ ~), no version/tag ("@" mid-name), no path +// or URL punctuation (":" "/" beyond the one scope separator), no leading "-" +// (an npm flag) and no "..". +// +// This admits `typescript`, `@angular/cli`, `npm-check-updates`, while refusing +// `git+https://…`, `../evil`, `-g`, and `left-pad@1.0.0`. +func ValidateNpmName(name string) error { + if name == "" { + return fmt.Errorf("deps: refusing empty npm globals entry") + } + // npm's own arg parser (npm-package-arg) classifies ANY spec ending in + // `.tgz`/`.tar`/`.tar.gz` (case-insensitive) as a local FILE spec — not a + // registry package — WITHOUT needing a path separator or URL punctuation. The + // charset check below permits "." and so would let `foo.tgz` through; `npm i -g + // foo.tgz` then resolves it as a tarball relative to CWD and runs its lifecycle + // scripts. Refuse the filename shape up front (mirrors npm's isFilename), so a + // cloned repo's list can never smuggle a tarball spec past the "registry names + // only" guarantee. + if npmFilenameRe.MatchString(name) { + return fmt.Errorf("deps: refusing npm globals entry %q: names ending in .tgz/.tar/.tar.gz are local tarball specs npm would run install-time code from, not registry package names", name) + } + scope, pkg := name, "" + if strings.HasPrefix(name, "@") { + slash := strings.IndexByte(name, '/') + if slash < 0 { + return fmt.Errorf("deps: refusing npm globals entry %q: a scoped name must be `@scope/name`", name) + } + scope = name[1:slash] // the part after "@", before "/" + pkg = name[slash+1:] + if pkg == "" || strings.Contains(pkg, "/") { + return fmt.Errorf("deps: refusing npm globals entry %q: a scoped name must be exactly `@scope/name`", name) + } + if !isNpmSegmentShaped(scope) { + return fmt.Errorf("deps: refusing npm globals entry %q: the scope %q is not a plain name", name, scope) + } + if !isNpmSegmentShaped(pkg) { + return fmt.Errorf("deps: refusing npm globals entry %q: the package part %q is not a plain name", name, pkg) + } + return nil + } + if !isNpmSegmentShaped(name) { + return fmt.Errorf("deps: refusing npm globals entry %q: not a plain npm package name (a URL, version tag, path, or flag is not allowed)", name) + } + return nil +} + +// npmFilenameRe matches npm's local-tarball filename heuristic (npm-package-arg's +// isFilename): a trailing `.tgz`, `.tar`, or `.tar.gz`, case-insensitive. A name +// matching it is a FILE spec to `npm install`, never a registry package. +var npmFilenameRe = regexp.MustCompile(`(?i)\.(tgz|tar\.gz|tar)$`) + +// isNpmSegmentShaped reports whether seg is a single npm name segment: it starts +// with an ASCII letter or digit and otherwise contains only npm's URL-safe name +// characters (letters, digits, and "-", ".", "_", "~"), with no "..". A leading +// "-" (an npm flag), "@" (a version/tag), ":" or "/" (a URL/path), or any other +// punctuation is refused. +func isNpmSegmentShaped(seg string) bool { + if seg == "" { + return false + } + if first := seg[0]; !(first >= 'a' && first <= 'z' || first >= 'A' && first <= 'Z' || first >= '0' && first <= '9') { + return false + } + if strings.Contains(seg, "..") { + return false + } + for _, r := range seg { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + case r == '-' || r == '.' || r == '_' || r == '~': + default: + return false + } + } + return true +} diff --git a/internal/deps/npm_status_test.go b/internal/deps/npm_status_test.go new file mode 100644 index 0000000..ff47cd0 --- /dev/null +++ b/internal/deps/npm_status_test.go @@ -0,0 +1,315 @@ +package deps + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/REPPL/ferry/internal/platform" +) + +// --- Homebrew status drift (read-only) -------------------------------------- + +func TestBrewDrift_ReportsAddedAndRemoved(t *testing.T) { + depsDir := t.TempDir() + // Repo (desired) Brewfile: a + b. + writeFile(t, filepath.Join(depsDir, "Brewfile.darwin"), `brew "a" +cask "b" +`) + m := Manifest{Manager: platform.ManagerBrew, GOOS: "darwin", + Shared: filepath.Join(depsDir, "Brewfile.darwin"), + Local: filepath.Join(depsDir, "Brewfile.darwin.local")} + + // Live (installed) set: a + c. `a` is common, `cask b` is only in the repo + // (would be installed), `brew c` is only live (would be captured). + r := newFakeRunner() + r.replies["bundle dump"] = "brew \"a\"\nbrew \"c\"\n" + + drift, ok, err := brewDrift(m, r) + if err != nil { + t.Fatalf("brewDrift: %v", err) + } + if !ok { + t.Fatalf("brewDrift: want supported=true for brew") + } + if drift.Empty() { + t.Fatalf("brewDrift: want drift, got empty") + } + if want := []string{"brew c"}; !reflect.DeepEqual(drift.Added, want) { + t.Errorf("Added = %v, want %v", drift.Added, want) + } + if want := []string{"cask b"}; !reflect.DeepEqual(drift.Removed, want) { + t.Errorf("Removed = %v, want %v", drift.Removed, want) + } + // The live read MUST be the read-only stdout dump, never an install. + if !r.invoked("bundle dump --file=-") { + t.Errorf("brewDrift did not run the read-only `brew bundle dump --file=-`: %v", r.calls) + } + for _, c := range r.calls { + joined := strings.Join(c, " ") + if strings.Contains(joined, "bundle --file") || strings.Contains(joined, "bundle install") { + t.Errorf("brewDrift ran an INSTALL (%q) — status must be read-only", joined) + } + } +} + +func TestBrewDrift_CleanWhenMatch(t *testing.T) { + depsDir := t.TempDir() + writeFile(t, filepath.Join(depsDir, "Brewfile.darwin"), "brew \"a\"\ncask \"b\"\n") + m := Manifest{Manager: platform.ManagerBrew, GOOS: "darwin", + Shared: filepath.Join(depsDir, "Brewfile.darwin"), + Local: filepath.Join(depsDir, "Brewfile.darwin.local")} + + r := newFakeRunner() + // Same identities, DIFFERENT options — must still read as clean (identity-grained). + r.replies["bundle dump"] = "cask \"b\"\nbrew \"a\", link: false\n" + + drift, ok, err := brewDrift(m, r) + if err != nil || !ok { + t.Fatalf("brewDrift: ok=%v err=%v", ok, err) + } + if !drift.Empty() { + t.Errorf("brewDrift: want clean, got Added=%v Removed=%v", drift.Added, drift.Removed) + } +} + +func TestBrewDrift_LenientLiveParseIgnoresNoise(t *testing.T) { + depsDir := t.TempDir() + writeFile(t, filepath.Join(depsDir, "Brewfile.darwin"), "brew \"a\"\n") + m := Manifest{Manager: platform.ManagerBrew, GOOS: "darwin", + Shared: filepath.Join(depsDir, "Brewfile.darwin"), + Local: filepath.Join(depsDir, "Brewfile.darwin.local")} + + r := newFakeRunner() + // Homebrew's first-run auto-update banner interleaved with the dump. The live + // parse must drop non-directive lines, not choke on them. + r.replies["bundle dump"] = strings.Join([]string{ + "==> Auto-updating Homebrew...", + "Warning: No remote 'origin'", + "# a comment", + "brew \"a\"", + "Error: update-report should not be called directly!", + }, "\n") + + drift, ok, err := brewDrift(m, r) + if err != nil || !ok { + t.Fatalf("brewDrift: ok=%v err=%v", ok, err) + } + if !drift.Empty() { + t.Errorf("brewDrift: banner noise leaked into drift: Added=%v Removed=%v", drift.Added, drift.Removed) + } +} + +func TestBrewDrift_AptUnsupported(t *testing.T) { + m := Manifest{Manager: platform.ManagerApt, GOOS: "linux", Shared: "/repo/deps/apt.txt"} + _, ok, err := brewDrift(m, newFakeRunner()) + if err != nil { + t.Fatalf("brewDrift(apt): %v", err) + } + if ok { + t.Errorf("brewDrift(apt): want supported=false (apt has no clean dump)") + } +} + +// --- npm globals capture ---------------------------------------------------- + +func TestDumpNpmGlobals_NamesOnlySortedExcludesNpm(t *testing.T) { + r := newFakeRunner() + r.replies["ls -g"] = `{ + "name": "lib", + "dependencies": { + "typescript": {"version": "5.4.0"}, + "npm": {"version": "11.0.0"}, + "@angular/cli": {"version": "17.0.0"}, + "pyright": {"version": "1.1.0"} + } + }` + + names, err := DumpNpmGlobals(r) + if err != nil { + t.Fatalf("DumpNpmGlobals: %v", err) + } + want := []string{"@angular/cli", "pyright", "typescript"} // sorted, npm excluded, no versions + if !reflect.DeepEqual(names, want) { + t.Errorf("DumpNpmGlobals = %v, want %v", names, want) + } +} + +func TestDumpNpmGlobals_ToleratesNonZeroExitWithJSON(t *testing.T) { + // `npm ls` exits non-zero on peer-dep warnings while still emitting valid JSON. + r := &jsonErrRunner{body: `{"dependencies":{"typescript":{"version":"5.4.0"}}}`} + names, err := DumpNpmGlobals(r) + if err != nil { + t.Fatalf("DumpNpmGlobals with non-zero exit + valid JSON: %v", err) + } + if want := []string{"typescript"}; !reflect.DeepEqual(names, want) { + t.Errorf("names = %v, want %v", names, want) + } +} + +func TestReDumpNpmGlobals_WritesSortedList(t *testing.T) { + depsDir := t.TempDir() + r := newFakeRunner() + r.replies["ls -g"] = `{"dependencies":{"zed":{"version":"1"},"apple":{"version":"2"},"npm":{"version":"3"}}}` + + path, err := ReDumpNpmGlobals(depsDir, r) + if err != nil { + t.Fatalf("ReDumpNpmGlobals: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read written list: %v", err) + } + if want := "apple\nzed\n"; string(got) != want { + t.Errorf("npm-globals.txt = %q, want %q", got, want) + } +} + +func TestReDumpNpmGlobals_RefusesSymlinkTarget(t *testing.T) { + depsDir := t.TempDir() + // Point the target at a symlink: the write-through guard must refuse. + target := filepath.Join(depsDir, "npm-globals.txt") + if err := os.Symlink(filepath.Join(depsDir, "elsewhere"), target); err != nil { + t.Fatalf("symlink: %v", err) + } + r := newFakeRunner() + r.replies["ls -g"] = `{"dependencies":{"a":{"version":"1"}}}` + if _, err := ReDumpNpmGlobals(depsDir, r); err == nil { + t.Errorf("ReDumpNpmGlobals wrote through a symlink target — must refuse") + } +} + +// --- npm globals install ---------------------------------------------------- + +func TestInstallNpmGlobals_InvokesGlobalInstallWithNames(t *testing.T) { + depsDir := t.TempDir() + writeFile(t, filepath.Join(depsDir, "npm-globals.txt"), "typescript\n@angular/cli\n") + r := newFakeRunner() + + names, err := InstallNpmGlobals(depsDir, r) + if err != nil { + t.Fatalf("InstallNpmGlobals: %v", err) + } + // Reconciled the sorted, validated set. + if want := []string{"@angular/cli", "typescript"}; !reflect.DeepEqual(names, want) { + t.Errorf("names = %v, want %v", names, want) + } + // Exactly one `npm i -g -- ` invocation with the end-of-options guard. + if len(r.calls) != 1 { + t.Fatalf("want 1 npm call, got %d: %v", len(r.calls), r.calls) + } + got := r.calls[0] + want := []string{"npm", "i", "-g", "--", "@angular/cli", "typescript"} + if !reflect.DeepEqual(got, want) { + t.Errorf("npm install argv = %v, want %v", got, want) + } +} + +func TestInstallNpmGlobals_EmptyListNoInvocation(t *testing.T) { + depsDir := t.TempDir() + // No npm-globals.txt at all: a clean no-op that never touches npm. + r := newFakeRunner() + names, err := InstallNpmGlobals(depsDir, r) + if err != nil { + t.Fatalf("InstallNpmGlobals empty: %v", err) + } + if len(names) != 0 { + t.Errorf("names = %v, want empty", names) + } + if len(r.calls) != 0 { + t.Errorf("empty list invoked npm %d times (must be a no-op): %v", len(r.calls), r.calls) + } +} + +func TestInstallNpmGlobals_RefusesInjectionSpec(t *testing.T) { + depsDir := t.TempDir() + // A git-URL spec would fetch+run arbitrary install-time code: refuse before npm. + writeFile(t, filepath.Join(depsDir, "npm-globals.txt"), "typescript\ngit+https://evil.example/x.git\n") + r := newFakeRunner() + if _, err := InstallNpmGlobals(depsDir, r); err == nil { + t.Errorf("InstallNpmGlobals accepted a git-URL spec — must refuse") + } + if len(r.calls) != 0 { + t.Errorf("refused list still invoked npm: %v", r.calls) + } +} + +// --- npm globals drift ------------------------------------------------------ + +func TestNpmGlobalsDrift_AddedAndRemoved(t *testing.T) { + depsDir := t.TempDir() + writeFile(t, filepath.Join(depsDir, "npm-globals.txt"), "typescript\npyright\n") + r := newFakeRunner() + // Live: typescript + eslint. pyright is listed-not-installed (to install); + // eslint is installed-not-listed (to capture). + r.replies["ls -g"] = `{"dependencies":{"typescript":{"version":"5"},"eslint":{"version":"9"},"npm":{"version":"11"}}}` + + drift, err := NpmGlobalsDrift(depsDir, r) + if err != nil { + t.Fatalf("NpmGlobalsDrift: %v", err) + } + if want := []string{"eslint"}; !reflect.DeepEqual(drift.Added, want) { + t.Errorf("Added = %v, want %v", drift.Added, want) + } + if want := []string{"pyright"}; !reflect.DeepEqual(drift.Removed, want) { + t.Errorf("Removed = %v, want %v", drift.Removed, want) + } +} + +// --- validation ------------------------------------------------------------- + +func TestValidateNpmName_AcceptsPlainAndScoped(t *testing.T) { + for _, ok := range []string{"typescript", "npm-check-updates", "@angular/cli", "pyright", "vite", "@a/b.c_d~e"} { + if err := ValidateNpmName(ok); err != nil { + t.Errorf("ValidateNpmName(%q) rejected a valid name: %v", ok, err) + } + } +} + +func TestValidateNpmName_RefusesSpecsAndFlags(t *testing.T) { + for _, bad := range []string{ + "git+https://evil/x.git", // URL spec + "../evil", // local path + "-g", // a flag + "left-pad@1.0.0", // version tag + "@scope", // no package part + "@scope/", // empty package part + "@scope/a/b", // extra segment + "a b", // whitespace + "..", // traversal + "https://x/y.tgz", // tarball URL + "foo.tgz", // local tarball spec (npm classifies as type=file) + "bar.tar", // local tarball spec + "baz.tar.gz", // local tarball spec + "1.tgz", // digit-led tarball spec + "foo.TGZ", // case-insensitive suffix + "typescript.tar.gz", // plausible-looking tarball spec + } { + if err := ValidateNpmName(bad); err == nil { + t.Errorf("ValidateNpmName(%q) accepted an unsafe entry — must refuse", bad) + } + } +} + +// --- regression: the Brewfile gate still refuses an npm directive ------------ + +func TestValidateBrewfileDirective_StillRefusesNpm(t *testing.T) { + // npm globals get their OWN file/manager; an `npm` line must NEVER be accepted + // into a Brewfile (it is not in the allow-list and `brew bundle` would run it). + if err := ValidateBrewfileDirective(`npm "typescript"`); err == nil { + t.Errorf("ValidateBrewfileDirective accepted an `npm` directive — the allow-list must refuse it") + } +} + +// --- helpers ---------------------------------------------------------------- + +// jsonErrRunner returns a fixed body together with a non-nil error, modelling +// `npm ls` exiting non-zero (peer-dep warnings) while still emitting valid JSON. +type jsonErrRunner struct{ body string } + +func (j *jsonErrRunner) Run(_ ...string) (string, error) { + return j.body, errors.New("npm ls: exit status 1") +} diff --git a/internal/deps/status.go b/internal/deps/status.go new file mode 100644 index 0000000..0f9a5d9 --- /dev/null +++ b/internal/deps/status.go @@ -0,0 +1,150 @@ +package deps + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/REPPL/ferry/internal/platform" +) + +// Drift is a READ-ONLY comparison between a repo dependency manifest (the +// desired state) and the packages actually installed on THIS machine (the live +// state). It is names/identity-grained, never version-grained: two entries for +// the same package with different options/versions are the SAME identity, so a +// benign option or version bump never shows as drift. +// +// - Added — installed live but absent from the repo manifest. `ferry capture` +// would record these. +// - Removed — declared in the repo manifest but not installed live. +// `ferry apply --deps` would install these. +type Drift struct { + Added []string + Removed []string +} + +// Empty reports whether the machine matches the repo manifest (no drift). +func (d Drift) Empty() bool { return len(d.Added) == 0 && len(d.Removed) == 0 } + +// BrewDrift reports how THIS machine's installed Homebrew set differs from the +// repo Brewfile (shared + per-machine overlay), WITHOUT installing anything and +// WITHOUT rewriting the repo Brewfile. The live set is read through a read-only +// `brew bundle dump --file=-` (stdout, creates no file); the repo set is parsed +// through the SAME allow-list gate `apply --deps` uses. +// +// The returned bool is whether Homebrew drift is SUPPORTED on this machine: it +// is false (with a nil error) when no package manager is present or the detected +// manager is not brew (apt has no clean installed-set dump — `ferry status` +// reports it as n/a rather than guessing). depsDir is the repo's deps/ directory. +func BrewDrift(depsDir string, runner CommandRunner) (Drift, bool, error) { + m, err := SelectManifest(depsDir) + if err != nil { + if errors.Is(err, ErrNoPackageManager) { + return Drift{}, false, nil + } + return Drift{}, false, err + } + return brewDrift(m, runner) +} + +// brewDrift is the testable core: the manifest is pre-selected so a unit test +// drives it with a fake runner and a fixed live-dump reply — no real brew. +func brewDrift(m Manifest, runner CommandRunner) (Drift, bool, error) { + if m.Manager != platform.ManagerBrew { + // apt/none: no clean installed-set dump, so drift is unsupported (not empty). + return Drift{}, false, nil + } + if runner == nil { + return Drift{}, false, fmt.Errorf("deps: nil CommandRunner") + } + + // Repo (desired) set: parsed + allow-list gated exactly as the install path. + repoEntries, err := m.Entries() + if err != nil { + return Drift{}, true, err + } + repoKeys := brewKeySet(repoEntries) + + // Live set: a read-only dump to stdout. `--file=-` pipes the Brewfile to + // stdout and creates NO file, so status never writes the repo Brewfile (that + // is capture's job). The dump is brew's OWN output — never handed back to + // `brew bundle` — so it is parsed LENIENTLY (any non-directive line, e.g. + // Homebrew's auto-update banner mixed into combined output, is ignored) + // rather than through the fail-closed install allow-list. + out, err := runner.Run(brewBin, "bundle", "dump", "--file=-") + if err != nil { + return Drift{}, true, fmt.Errorf("deps: brew bundle dump --file=-: %w", err) + } + liveKeys := brewKeySetLenient(out) + + return diffDrift(repoKeys, liveKeys), true, nil +} + +// brewKeySet builds the identity set of already-parsed, allow-list-validated +// Brewfile directive lines. Each key is the package IDENTITY (directive keyword +// + name), so option/version differences on the same package are one key. +func brewKeySet(entries []string) map[string]struct{} { + set := map[string]struct{}{} + for _, e := range entries { + if key, ok := brewEntryKey(e); ok { + set[key] = struct{}{} + } + } + return set +} + +// brewKeySetLenient builds the identity set from raw `brew bundle dump` output, +// keeping ONLY lines whose first token is an allowed Brewfile directive and +// silently dropping everything else (blank lines, comments, and any banner/warning +// text brew may interleave). Safe because this text is never executed. +func brewKeySetLenient(dump string) map[string]struct{} { + set := map[string]struct{}{} + for _, raw := range strings.Split(dump, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if key, ok := brewEntryKey(line); ok { + set[key] = struct{}{} + } + } + return set +} + +// brewEntryKey reduces one Brewfile directive line to its package IDENTITY key +// (" ", e.g. `brew ripgrep`, `cask iterm2`, `mas Xcode`). It +// returns ok=false for a line whose first token is not an allowed directive, so +// unknown/noise lines are ignored by the drift comparison. Options (link:, +// id:, restart_service:) are deliberately NOT part of the key — comparing on +// identity keeps a benign option change from reading as drift. +func brewEntryKey(line string) (string, bool) { + kw, rest := splitFirstField(line) + if !allowedBrewDirectives[kw] { + return "", false + } + name, _, ok := firstQuotedArg(rest) + if !ok { + return "", false + } + return kw + " " + name, true +} + +// diffDrift computes the two-way difference between a repo (desired) key set and +// a live (installed) key set, sorted for a stable report. +func diffDrift(repo, live map[string]struct{}) Drift { + var d Drift + for k := range live { + if _, ok := repo[k]; !ok { + d.Added = append(d.Added, k) + } + } + for k := range repo { + if _, ok := live[k]; !ok { + d.Removed = append(d.Removed, k) + } + } + sort.Strings(d.Added) + sort.Strings(d.Removed) + return d +} diff --git a/internal/domains/domains.go b/internal/domains/domains.go new file mode 100644 index 0000000..68e6dc0 --- /dev/null +++ b/internal/domains/domains.go @@ -0,0 +1,143 @@ +// Package domains is the converged domain registry for ferry (fn-5). +// +// Historically ferry's extension mechanisms were parallel, hand-written +// dispatch arms in cmd/: the isZsh() oracle routed dotfiles onto the +// include-sidecar vs whole-file strategy, a hardcoded {"iterm2","terminal"} +// literal enumerated the native preference domains, and dotfiles/agents/ +// terminals each got their own planKind and mutate() arm. fn-5 converges those +// arms onto ONE registry exposing two interfaces: +// +// - FileDomain — a domain whose managed state is a set of regular files under +// $HOME, each reconciled by content -> target -> dotfile.ApplyContentDeferred +// (dotfiles, zsh, agents, termcfg, and the post-fn-5 config plugins: git, +// Emacs, iTerm2 Dynamic Profiles, tmux, npm, key-bindings). +// - ResourceDomain — a domain whose managed state is an opaque backup-engine +// resource (a macOS `defaults` preference domain), reconciled by +// defaults export/import (native terminals, and later the iTerm2 global +// plist). +// +// This file is the FREEZE GATE: it defines the interfaces and their carrier +// types ONLY. It deliberately does NOT construct a DefaultRegistry — the +// concrete domain adapters (the zsh FileDomain, the dotfile/agents/termcfg +// adapters, the PreferenceDomain wrapper) land in the porting step. The freeze +// exists so the interface shape can be proven to express a non-zsh FileDomain +// (the git `[include]` shape) WITHOUT leaking identity keys before any dispatch +// code in cmd/ is touched. See gitfixture_test.go for that proof. +package domains + +import ( + "github.com/REPPL/ferry/internal/backup" + "github.com/REPPL/ferry/internal/config" + "github.com/REPPL/ferry/internal/dotfile" + "github.com/REPPL/ferry/internal/secret" + "github.com/REPPL/ferry/internal/terminal" +) + +// FileDomain is a domain whose managed state is a set of regular files under +// $HOME, each reconciled by content -> target -> dotfile.ApplyContentDeferred. +// It is the converged replacement for today's dotfile/agents/termcfg planning +// arms, and the extension seam every post-fn-5 config plugin registers on. +type FileDomain interface { + // Name is the [manage] scope key (e.g. "dotfiles", "agents", "terminals", + // "git", "emacs", "tmux", "npm", "keybindings"). The registry driver gates + // each domain on Scope.IsManaged(Name()) before it calls Plan. + Name() string + + // Plan expands the domain into strictly 1:1 file items. Each item's Target + // is built via dotfile.TargetFor (flat) or dotfile.NestedTarget (nested); + // any {{ferry.secret ...}} placeholders are pre-rendered into Content. A + // per-target refusal (an ~/.ssh / $HOME-escape / symlink / missing-secret) + // becomes a warning and skips that item — it never aborts the whole plan. + Plan(in PlanInput) (items []FileItem, warnings []string, err error) + + // Overlay reports, per bare/leaf key, whether this domain composes its + // per-machine `.local` overlay as an include-style sidecar or a whole-file + // replace. This is the DATA-DRIVEN replacement for the isZsh() oracle: the + // zsh domain returns OverlayIncludeSidecar for zshrc/zshenv/zprofile; most + // domains return the constant OverlayWholeFileReplace. The two-strip + // contract keys its trigger off this value, not off a hardcoded name. + Overlay(key string) dotfile.OverlayMode + + // Captures reports whether capture offers this domain's targets back as + // candidates. termcfg returns false (repo-authoritative — no capture pass); + // dotfiles and agents return true. This lets the registry drive the capture + // passes without a per-domain hand-coded gate, preserving termcfg's + // deliberate capture asymmetry. + Captures() bool +} + +// FileItem is the unit every FileDomain plan produces — the converged shape of +// today's dotfile planItem, agents.Item, and termcfg.Item. The registry driver +// carries one FileItem straight into dotfile.ApplyContentDeferred. +type FileItem struct { + // Key is the domain-namespaced last-applied store key (e.g. "agents/claude", + // "terminals/alacritty/foo.toml", or a dotfile's bare name). + Key string + // Label is the human-facing name reports print (e.g. "agents:claude"). + Label string + // Target is the validated $HOME destination (built via dotfile.TargetFor or + // dotfile.NestedTarget, so ~/.ssh and $HOME-escapes are impossible). + Target dotfile.Target + // Content is the effective bytes to materialise, with any + // {{ferry.secret ...}} placeholders already rendered. + Content []byte + // Exec preserves the repo source's executable bit on a first-ever write. + Exec bool + // SecretRouted marks a plaintext-credential target: ApplyContentDeferred + // materialises it 0600 and the apply command records only its hash — never + // the bytes — in the last-applied snapshot. + SecretRouted bool +} + +// PlanInput carries what a FileDomain needs to plan. It is intentionally the +// minimal-but-sufficient union of what planDotfiles, agents.Plan, and +// termcfg.Plan consume today: +// +// - RepoRoot + Home build every Target (dotfile.TargetFor / NestedTarget). +// - Scope supplies the dotfiles domain its declared list +// (Scope.DeclaredDotfiles); the registry driver has already gated the +// domain on Scope.IsManaged(Name()) before calling Plan. +// - Secrets renders {{ferry.secret ...}} placeholders into FileItem.Content +// (a missing ref skips the item, never a literal placeholder deployed). +// +// The config-repo symlink-refusing read validator (safeRepoPath) is NOT a +// PlanInput field: each per-domain planner applies its own guard internally, so +// exposing one here would be a dead field no Plan implementation reads. +// +// A domain's own configuration (which agents/terminals are declared) is held by +// the domain adapter itself — assembled by the porting step's DefaultRegistry — +// so it is not a PlanInput field. +type PlanInput struct { + RepoRoot string + Home string + Scope config.Scope + Secrets *secret.Store +} + +// ResourceDomain is a domain whose managed state is an opaque backup-engine +// resource — a macOS `defaults` preference domain, reconciled by +// defaults export/import rather than a file copy. It is the converged +// replacement for the hardcoded {"iterm2","terminal"} preference-domain arm. +type ResourceDomain interface { + // backup.Resource is Domain() / Backup() / Restore() — the engine drives + // pre-mutation capture and rollback through it. + backup.Resource + // Name is the [manage] scope key (e.g. "iterm2", "terminal") — distinct from + // backup.Resource.Domain(), which is the `defaults` identifier + // (e.g. "com.googlecode.iterm2"). + Name() string + // Plan emits the preference-domain PlanEntry the diff/status renderer and the + // AC-terminal-config tripwire key on (Kind == "preference-domain"). + Plan() terminal.PlanEntry +} + +// Registry is the single converged domain registry fn-5 drives dispatch from: +// an ordered set of FileDomains (planned + reconciled through +// dotfile.ApplyContentDeferred) and ResourceDomains (reconciled through the +// backup engine's Resource hook). The porting step adds a DefaultRegistry +// constructor that assembles the built-in set; this freeze defines the carrier +// only. +type Registry struct { + FileDomains []FileDomain + ResourceDomains []ResourceDomain +} diff --git a/internal/domains/gitfixture_test.go b/internal/domains/gitfixture_test.go new file mode 100644 index 0000000..210bafc --- /dev/null +++ b/internal/domains/gitfixture_test.go @@ -0,0 +1,223 @@ +package domains + +// FREEZE PROOF (fn-5, plan §3.1 / §5 STOP condition). +// +// gitIncludeDomain is a TOY FileDomain modelling the git `[include]` shape: a +// shared ~/.gitconfig whose LAST directive is `[include] path = ~/.gitconfig.local` +// (git's native, last-wins overlay — the equivalent of ferry's `.local` sidecar), +// with the machine identity keys forced LOCAL-only so a WholeFile deploy can never +// carry one machine's commit identity onto another. +// +// It exists only to prove that the FROZEN FileDomain interface can express a +// non-zsh domain WITHOUT leaking identity — the plan's freeze gate before any +// port. It is never registered and never wired to cmd/; if the interface could +// NOT express this (e.g. Plan had no way to emit the native include directive, or +// FileItem could not carry the filtered shared content), that is the plan STOP +// condition "the fn-5 interface cannot express the git FileDomain without leaking +// -> re-cut before any port." + +import ( + "strings" + "testing" + + "github.com/REPPL/ferry/internal/dotfile" +) + +// gitIdentityKeys are the git config keys that must NEVER be shared across +// machines (plan §3.1): sharing one machine's identity would silently corrupt +// another's commit authorship / signing. They live only in ~/.gitconfig.local. +var gitIdentityKeys = []string{ + "user.email", + "user.name", + "user.signingkey", + "gpg.program", + "credential.helper", +} + +// gitIncludeDomain is the toy non-zsh FileDomain. +type gitIncludeDomain struct{} + +func (gitIncludeDomain) Name() string { return "git" } + +// Overlay: git carries its own native `[include]` mechanism, so the per-machine +// overlay is a WHOLE-FILE replace of a separate ~/.gitconfig.local that git +// itself pulls in — NOT ferry's injected shell-style sidecar. Every key resolves +// to OverlayWholeFileReplace. +func (gitIncludeDomain) Overlay(key string) dotfile.OverlayMode { + return dotfile.OverlayWholeFileReplace +} + +// Captures: git is a capture candidate (an edited ~/.gitconfig can be pulled +// back), like dotfiles and agents. +func (gitIncludeDomain) Captures() bool { return true } + +// Plan emits ONE FileItem for the shared ~/.gitconfig. Its content is the shared +// config with every identity key dropped (forced local-only) and a trailing +// native `[include]` directive so git last-wins-merges ~/.gitconfig.local. +func (gitIncludeDomain) Plan(in PlanInput) ([]FileItem, []string, error) { + // A representative shared config that ALSO contains identity keys, to prove + // the domain drops them rather than never having had them. + sharedSource := strings.Join([]string{ + "[core]", + "\teditor = vim", + "[init]", + "\tdefaultBranch = main", + "[alias]", + "\tst = status", + // Identity keys that MUST be forced local-only — present in the source, + // dropped from the shared output. + "[user]", + "\temail = alice@example.com", + "\tname = Alice Example", + "\tsigningkey = ABCD1234", + "[gpg]", + "\tprogram = gpg2", + "[credential]", + "\thelper = osxkeychain", + "", + }, "\n") + + shared := dropIdentityKeys(sharedSource) + shared = appendGitInclude(shared, "~/.gitconfig.local") + + target, err := dotfile.TargetFor(in.RepoRoot, in.Home, ".gitconfig") + if err != nil { + // A per-target refusal is a warning + skip, never a plan abort. + return nil, []string{"git: " + err.Error()}, nil + } + + return []FileItem{{ + Key: "gitconfig", + Label: "git:gitconfig", + Target: target, + Content: []byte(shared), + }}, nil, nil +} + +// dropIdentityKeys removes any line assigning one of the identity keys, keyed by +// the current INI section header. It is deliberately simple — a proof, not the +// production extractor. +func dropIdentityKeys(config string) string { + section := "" + var kept []string + for _, line := range strings.Split(config, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + section = strings.ToLower(strings.Trim(trimmed, "[]")) + // Drop a bare section header (e.g. [gpg]) whose only keys are identity. + if sectionIsIdentityOnly(section) { + continue + } + kept = append(kept, line) + continue + } + if eq := strings.IndexByte(trimmed, '='); eq >= 0 && section != "" { + key := section + "." + strings.TrimSpace(trimmed[:eq]) + if isIdentityKey(key) { + continue + } + } + kept = append(kept, line) + } + return strings.Join(kept, "\n") +} + +// sectionIsIdentityOnly reports whether every identity key we drop under this +// section would empty it — so the section header itself is dropped too, leaving +// no orphan `[user]`/`[gpg]`/`[credential]` block in the shared output. +func sectionIsIdentityOnly(section string) bool { + switch section { + case "user", "gpg", "credential": + return true + } + return false +} + +func isIdentityKey(key string) bool { + for _, id := range gitIdentityKeys { + if key == id { + return true + } + } + return false +} + +// appendGitInclude appends the native git `[include]` directive as the LAST +// block, so git applies it last (last-wins), giving the machine-local file the +// final say — the native equivalent of ferry's overlay. +func appendGitInclude(config, path string) string { + if !strings.HasSuffix(config, "\n") { + config += "\n" + } + return config + "[include]\n\tpath = " + path + "\n" +} + +// TestGitIncludeDomain_FreezeProof is the freeze gate: it proves the frozen +// FileDomain interface expresses the git `[include]` shape without leaking any +// identity key into the shared content. +func TestGitIncludeDomain_FreezeProof(t *testing.T) { + // The toy domain must satisfy the frozen interface at compile time. + var d FileDomain = gitIncludeDomain{} + + if d.Name() != "git" { + t.Fatalf("Name() = %q, want %q", d.Name(), "git") + } + + // Overlay: git uses its own native include, NOT ferry's injected sidecar. + if got := d.Overlay("gitconfig"); got != dotfile.OverlayWholeFileReplace { + t.Errorf("Overlay(gitconfig) = %q, want OverlayWholeFileReplace (git uses a native [include], not ferry's sidecar)", got) + } + + // Captures: git is a capture candidate. + if !d.Captures() { + t.Errorf("Captures() = false, want true (an edited ~/.gitconfig can be captured back)") + } + + items, warnings, err := d.Plan(PlanInput{ + RepoRoot: t.TempDir(), + Home: t.TempDir(), + }) + if err != nil { + t.Fatalf("Plan returned err: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("Plan returned warnings: %v", warnings) + } + if len(items) != 1 { + t.Fatalf("Plan returned %d items, want exactly 1 (the shared ~/.gitconfig)", len(items)) + } + + item := items[0] + content := string(item.Content) + + // (1) The shared FileItem carries the native `[include]` directive naming the + // per-machine file — the git overlay mechanism. + if !strings.Contains(content, "[include]") { + t.Errorf("shared ~/.gitconfig content has no [include] section — git overlay directive missing:\n%s", content) + } + if !strings.Contains(content, "path = ~/.gitconfig.local") { + t.Errorf("shared ~/.gitconfig content does not include ~/.gitconfig.local — the per-machine overlay is not wired:\n%s", content) + } + + // (2) The FREEZE PROOF: NONE of the identity keys leak into the shared content. + for _, key := range gitIdentityKeys { + bare := key[strings.IndexByte(key, '.')+1:] // e.g. "email" + if strings.Contains(content, bare+" =") || strings.Contains(content, bare+"=") { + t.Errorf("IDENTITY LEAK: shared ~/.gitconfig content contains identity key %q — it must be forced LOCAL-only (plan §3.1):\n%s", key, content) + } + } + // The dropped section headers must not survive as orphan blocks either. + for _, section := range []string{"[user]", "[gpg]", "[credential]"} { + if strings.Contains(content, section) { + t.Errorf("IDENTITY LEAK: shared ~/.gitconfig content contains an orphan %s block — identity section must be dropped whole:\n%s", section, content) + } + } + + // The `[include]` directive must be LAST so git applies the machine-local + // file last (last-wins overlay). + trimmed := strings.TrimRight(content, "\n") + lastBlock := trimmed[strings.LastIndex(trimmed, "["):] + if !strings.HasPrefix(lastBlock, "[include]") { + t.Errorf("[include] is not the last block — git would not last-wins the overlay:\n%s", content) + } +} diff --git a/internal/dotfile/apply.go b/internal/dotfile/apply.go index 8804965..6ba5567 100644 --- a/internal/dotfile/apply.go +++ b/internal/dotfile/apply.go @@ -352,10 +352,15 @@ func stripFerryOverlayDirective(content []byte) []byte { kept := make([]string, 0, len(lines)) for i := 0; i < len(lines); i++ { if strings.TrimSpace(lines[i]) == ferryOverlayMarker { - // Drop the marker, and the next line too when it is ferry's generated - // include directive (`[ -f ~/… ] && source ~/…`). A non-matching next - // line is left in place (so nothing user-authored is silently removed). - if i+1 < len(lines) && isFerryOverlayInclude(lines[i+1]) { + // Drop the marker plus ferry's generated include directive that follows. + // The zsh/tmux directive is ONE line (`[ -f ~/… ] && source ~/…` / + // `source-file -q ~/…`); the git directive is a TWO-line `[include]` + // block (`[include]` then `\tpath = ~/…`). Consume whichever follows; a + // non-matching next line is left in place (nothing user-authored removed). + if i+1 < len(lines) && strings.TrimSpace(lines[i+1]) == "[include]" && + i+2 < len(lines) && isFerryGitIncludePath(lines[i+2]) { + i += 2 + } else if i+1 < len(lines) && isFerryOverlayInclude(lines[i+1]) { i++ } continue @@ -366,12 +371,38 @@ func stripFerryOverlayDirective(content []byte) []byte { } // isFerryOverlayInclude reports whether line is ferry's generated per-machine -// overlay include — the exact `[ -f ~/ ] && source ~/` shape -// appendSourceDirective emits. Matching the guarded `[ -f ~/… ] && source ~/…` -// structure (not a bare `source …`) keeps stripping narrow to ferry's own output. +// overlay include, in any include-style file format ferry emits: +// - shell/zsh: the guarded `[ -f ~/ ] && source ~/` shape; +// - tmux: the `source-file -q ~/` shape. +// +// This is the SHAPE-keyed half of the two-strip contract: it recognises ferry's +// own generated line by its structure (never a bare user `source …`), and only +// ever runs on the line that FOLLOWS ferry's marker, so recognising both formats +// stays narrow to ferry's own output. Matching the precise structure (a guarded +// shell include, or `source-file -q ~/`, not a bare directive) keeps a +// user-authored line from being stripped. func isFerryOverlayInclude(line string) bool { t := strings.TrimSpace(line) - return strings.HasPrefix(t, "[ -f ~/") && strings.Contains(t, "] && source ~/") + if strings.HasPrefix(t, "[ -f ~/") && strings.Contains(t, "] && source ~/") { + return true + } + return strings.HasPrefix(t, "source-file -q ~/") +} + +// isFerryGitIncludePath reports whether line is the `path = ~/…` line of ferry's +// generated git `[include]` block (the second line of the two-line git-INI +// directive). It is the git-INI branch of the shape-keyed strip: recognised by +// the `path` key with a `~/`-anchored value, never a bare user directive, and only +// ever tested on the line that follows ferry's marker + `[include]` header. +func isFerryGitIncludePath(line string) bool { + t := strings.TrimSpace(line) + eq := strings.IndexByte(t, '=') + if eq < 0 { + return false + } + key := strings.ToLower(strings.TrimSpace(t[:eq])) + val := strings.TrimSpace(t[eq+1:]) + return key == "path" && strings.HasPrefix(val, "~/") } // StripFerryOverlayDirective is the exported form of stripFerryOverlayDirective diff --git a/internal/dotfile/doc.go b/internal/dotfile/doc.go index 4aa327a..4a16d8a 100644 --- a/internal/dotfile/doc.go +++ b/internal/dotfile/doc.go @@ -47,9 +47,10 @@ // Per-domain `.local` overlay (PLAN.md "Per-domain overlay strategy"). A // Target's OverlayMode tells the apply command how the per-machine overlay // composes: OverlayIncludeSidecar for an include-style domain (zsh: shared -// ~/.zshrc sources ~/.zshrc.local) where the overlay is a separate sidecar file; -// OverlayWholeFileReplace (the TargetFor default) for a generic dotfile with no -// include point (e.g. .gitconfig) where the per-machine copy in local// +// ~/.zshrc sources ~/.zshrc.local; also tmux and git) where the overlay is a +// separate sidecar file; OverlayWholeFileReplace (the TargetFor default) for a +// generic dotfile with no include point (e.g. .vimrc) where the per-machine copy +// in local// // is deployed INSTEAD OF the shared content. The apply command composes that // local-vs-shared choice into the effective content and deploys it through the // shared apply core (ApplyContentDeferred); sidecar materialization stays the diff --git a/internal/dotfile/dotfile.go b/internal/dotfile/dotfile.go index d235e39..f33f5b1 100644 --- a/internal/dotfile/dotfile.go +++ b/internal/dotfile/dotfile.go @@ -37,7 +37,7 @@ const ( // `[l]ocal` routing is allowed. OverlayIncludeSidecar OverlayMode = "include-sidecar" // OverlayWholeFileReplace: a generic dotfile WITHOUT an include mechanism - // (e.g. .gitconfig) has NO safe merge point, so `.local` is WHOLE-FILE: a + // (e.g. .vimrc) has NO safe merge point, so `.local` is WHOLE-FILE: a // per-machine full copy in local// is deployed INSTEAD OF the shared // content (local wins). When no local copy exists the shared content is // deployed. Hunk-level `[l]ocal` routing is DISALLOWED for these. diff --git a/internal/emacs/doc.go b/internal/emacs/doc.go new file mode 100644 index 0000000..ef44608 --- /dev/null +++ b/internal/emacs/doc.go @@ -0,0 +1,34 @@ +// Package emacs carries a developer's Emacs configuration tree +// (~/.emacs.d/) across machines, modelled on internal/termcfg's config-file +// tree pattern: the config repo's emacs/ area is fanned out file-by-file into +// per-leaf nested targets under ~/.emacs.d/, each deployed as a regular-file +// COPY reconciled by hash and written through the standard +// dotfile.ApplyContentDeferred path (os.Root-confined). Nothing under $HOME is +// ever symlinked — the historical `ln -s repo ~/.emacs.d` habit is replaced by +// the apply cycle (edit the config-repo emacs/ source, then `ferry apply`). +// +// The domain is repo-authoritative (Captures() == false, like termcfg): edit +// the config-repo copy and `apply` deploys it; a live edit shows as drift and +// apply skips it. For a literate config (init.el bootstrapping a tangled +// inits/repp.org) this adds one `apply` step between editing inits/repp.org and +// Emacs re-tangling it — the accepted trade for cross-machine carry, the +// per-machine .local overlay, and secret handling. Bidirectional capture-back +// is a deliberate out-of-scope follow-up. +// +// Carry/exclude. The carry set is everything committed under emacs/ (init.el, +// early-init.el, inits/repp.org, docs/, README, LICENSE, …). Even so, the +// domain defensively EXCLUDES the volatile, machine-generated paths during the +// walk so they are never deployed even if a source tree contains them: package +// stores (elpa/, eln-cache/, *.elc), the tangled inits/repp.el, and session +// state (auto-save-list/, transient/, url/, network-security.data, recentf, +// savehist, saveplace). See exclude.go for the exact predicate. +// +// Per-machine overlay. The termcfg per-file local-wins overlay applies: a file +// at local/emacs/ in the repo overrides the shared emacs/ on +// deploy (whole-file, no Emacs-Lisp parsing), the natural home for a +// Customize-written inits/custom.el or a hand-authored init.local.el. +// +// Deploy home is ~/.emacs.d (matching the maintainer's model). Note ~/.emacs.d +// shadows the XDG location ~/.config/emacs: with ~/.emacs.d present, Emacs reads +// it and never consults ~/.config/emacs. +package emacs diff --git a/internal/emacs/emacs_test.go b/internal/emacs/emacs_test.go new file mode 100644 index 0000000..65c27c5 --- /dev/null +++ b/internal/emacs/emacs_test.go @@ -0,0 +1,206 @@ +package emacs + +import ( + "os" + "path/filepath" + "reflect" + "sort" + "testing" +) + +// mkfile writes body to root/rel, creating parents. +func mkfile(t *testing.T, root, rel, body string) { + t.Helper() + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func planKeys(items []Item) []string { + keys := make([]string, len(items)) + for i, it := range items { + keys[i] = it.Key + } + sort.Strings(keys) + return keys +} + +// TestExcluded pins the carry/exclude predicate: every volatile path the domain +// must never deploy is excluded, and the carry set is included. +func TestExcluded(t *testing.T) { + excludedCases := []string{ + "elpa/foo.el", + "elpa", + "eln-cache/native.eln", + "inits/repp.elc", + "init.elc", + "inits/repp.el", // the tangled output specifically + "auto-save-list", + "auto-save-list/.saves-123", + "transient/history.el", + "url/cookies", + "network-security.data", + "recentf", + "savehist", + "saveplace", + } + for _, rel := range excludedCases { + if !excluded(rel) { + t.Errorf("excluded(%q) = false, want true (volatile path must be pruned)", rel) + } + } + carryCases := []string{ + "init.el", + "early-init.el", + "inits/repp.org", // the literate source is carried; only the tangled .el is not + "docs/README.md", + "README", + "LICENSE", + "inits/custom.el", // an overlay-friendly file, not itself excluded + } + for _, rel := range carryCases { + if excluded(rel) { + t.Errorf("excluded(%q) = true, want false (carry-set file must deploy)", rel) + } + } +} + +// TestPlan_treeMapping proves the emacs/ tree fans out to per-file targets under +// ~/.emacs.d/, preserving the relpath: init.el maps to ~/.emacs.d/init.el and a +// nested inits/repp.org to ~/.emacs.d/inits/repp.org. +func TestPlan_treeMapping(t *testing.T) { + repo := t.TempDir() + home := t.TempDir() + mkfile(t, repo, "emacs/init.el", "shared-init") + mkfile(t, repo, "emacs/early-init.el", "shared-early") + mkfile(t, repo, "emacs/inits/repp.org", "shared-literate") + + items, warnings, err := Plan(PlanInput{RepoRoot: repo, Home: home}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if len(warnings) != 0 { + t.Errorf("warnings = %v, want none", warnings) + } + + got := planKeys(items) + want := []string{"emacs/early-init.el", "emacs/init.el", "emacs/inits/repp.org"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("keys = %v, want %v", got, want) + } + + byKey := map[string]Item{} + for _, it := range items { + byKey[it.Key] = it + } + if h := byKey["emacs/init.el"].Target.Home; h != filepath.Join(home, ".emacs.d/init.el") { + t.Errorf("init.el home = %s, want ~/.emacs.d/init.el", h) + } + if h := byKey["emacs/inits/repp.org"].Target.Home; h != filepath.Join(home, ".emacs.d/inits/repp.org") { + t.Errorf("repp.org home = %s, want ~/.emacs.d/inits/repp.org", h) + } + if string(byKey["emacs/init.el"].Content) != "shared-init" { + t.Errorf("init.el content = %q", byKey["emacs/init.el"].Content) + } +} + +// TestPlan_excludesVolatilePaths proves the excluded volatile paths are pruned +// during the walk and never become items, while the carry set does. +func TestPlan_excludesVolatilePaths(t *testing.T) { + repo := t.TempDir() + home := t.TempDir() + mkfile(t, repo, "emacs/init.el", "keep") + mkfile(t, repo, "emacs/inits/repp.org", "keep") + mkfile(t, repo, "emacs/inits/repp.el", "tangled-drop") + mkfile(t, repo, "emacs/init.elc", "bytecode-drop") + mkfile(t, repo, "emacs/elpa/some-pkg/foo.el", "pkg-drop") + mkfile(t, repo, "emacs/eln-cache/x.eln", "eln-drop") + mkfile(t, repo, "emacs/recentf", "session-drop") + mkfile(t, repo, "emacs/transient/history.el", "session-drop") + + items, _, err := Plan(PlanInput{RepoRoot: repo, Home: home}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + got := planKeys(items) + want := []string{"emacs/init.el", "emacs/inits/repp.org"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("keys = %v, want %v (only the carry set deploys)", got, want) + } +} + +// TestPlan_localOverlayWins proves the per-machine overlay at +// local/emacs/ overrides the shared emacs/ for just that file, +// while a non-overridden file still deploys the shared content. +func TestPlan_localOverlayWins(t *testing.T) { + repo := t.TempDir() + home := t.TempDir() + mkfile(t, repo, "emacs/init.el", "shared-init") + mkfile(t, repo, "emacs/inits/custom.el", "shared-custom") + // Per-machine override of just custom.el. + mkfile(t, repo, "local/emacs/inits/custom.el", "MACHINE-custom") + + items, _, err := Plan(PlanInput{RepoRoot: repo, Home: home}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + byKey := map[string]Item{} + for _, it := range items { + byKey[it.Key] = it + } + if got := string(byKey["emacs/inits/custom.el"].Content); got != "MACHINE-custom" { + t.Errorf("custom.el content = %q, want the local overlay to win", got) + } + if got := string(byKey["emacs/init.el"].Content); got != "shared-init" { + t.Errorf("non-overridden init.el = %q, want shared", got) + } +} + +// TestPlan_absentSourceDeploysNothing proves an absent emacs/ tree deploys +// nothing without warning. +func TestPlan_absentSourceDeploysNothing(t *testing.T) { + repo := t.TempDir() + home := t.TempDir() + items, warnings, err := Plan(PlanInput{RepoRoot: repo, Home: home}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if len(items) != 0 || len(warnings) != 0 { + t.Errorf("items=%v warnings=%v, want both empty for a repo with no emacs/ tree", items, warnings) + } +} + +// TestPlan_refusesSymlinkInTree proves a symlinked file inside the managed tree +// is refused with a warning and skipped (a symlinked directory prunes its +// subtree), so ferry never reads a config through a symlink. +func TestPlan_refusesSymlinkInTree(t *testing.T) { + repo := t.TempDir() + home := t.TempDir() + mkfile(t, repo, "emacs/init.el", "real") + // A symlinked file inside the tree. + linkPath := filepath.Join(repo, "emacs", "linked.el") + if err := os.Symlink(filepath.Join(repo, "emacs", "init.el"), linkPath); err != nil { + t.Fatal(err) + } + // A symlinked directory inside the tree. + linkDir := filepath.Join(repo, "emacs", "linkdir") + if err := os.Symlink(filepath.Join(repo, "emacs"), linkDir); err != nil { + t.Fatal(err) + } + + items, warnings, err := Plan(PlanInput{RepoRoot: repo, Home: home}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + // Only the real init.el deploys; both symlinks are refused. + if got := planKeys(items); !reflect.DeepEqual(got, []string{"emacs/init.el"}) { + t.Errorf("keys = %v, want only emacs/init.el (symlinks refused)", got) + } + if len(warnings) != 2 { + t.Errorf("warnings = %v, want two symlink refusals (file + dir)", warnings) + } +} diff --git a/internal/emacs/exclude.go b/internal/emacs/exclude.go new file mode 100644 index 0000000..b7402a0 --- /dev/null +++ b/internal/emacs/exclude.go @@ -0,0 +1,65 @@ +package emacs + +import ( + "path/filepath" + "strings" +) + +// excludedDirs are directory names whose whole subtree is volatile, +// machine-generated state that must never be carried: package stores and +// installed bytecode (elpa/, eln-cache/), and per-machine session/network +// caches (auto-save-list/, transient/, url/). Matched on ANY path component so +// a nested occurrence (e.g. inits/elpa/) is pruned too. +var excludedDirs = map[string]bool{ + "elpa": true, + "eln-cache": true, + "auto-save-list": true, + "transient": true, + "url": true, +} + +// excludedFiles are exact base names of volatile session-state files Emacs +// rewrites at runtime: the network-security cache and the recentf/savehist/ +// saveplace persistence files. Carrying them would churn on every session and +// leak one machine's history to another. +var excludedFiles = map[string]bool{ + "network-security.data": true, + "recentf": true, + "savehist": true, + "saveplace": true, +} + +// excludedRelPaths are exact slash-relative paths excluded regardless of their +// base name: the tangled Emacs-Lisp output inits/repp.el, which is regenerated +// from the literate inits/repp.org at load time and must not be carried. +var excludedRelPaths = map[string]bool{ + "inits/repp.el": true, +} + +// excluded reports whether a tree entry at the slash-or-OS relative path rel +// (relative to the emacs/ source root) is a volatile path the domain must never +// deploy. It is deterministic and side-effect-free, so the walk can call it on +// every entry — a directory match prunes the whole subtree, a file match skips +// the one file. The rules are: any path component that names an excluded +// directory; an exact excluded relative path; an exact excluded base name; or a +// compiled-bytecode file (a .elc extension). +func excluded(rel string) bool { + rel = filepath.ToSlash(rel) + if excludedRelPaths[rel] { + return true + } + parts := strings.Split(rel, "/") + for _, p := range parts { + if excludedDirs[p] { + return true + } + } + base := parts[len(parts)-1] + if excludedFiles[base] { + return true + } + if strings.HasSuffix(base, ".elc") { + return true + } + return false +} diff --git a/internal/emacs/plan.go b/internal/emacs/plan.go new file mode 100644 index 0000000..c9f718e --- /dev/null +++ b/internal/emacs/plan.go @@ -0,0 +1,244 @@ +package emacs + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/REPPL/ferry/internal/dotfile" +) + +// RepoSubdir is the config-repo subdirectory that holds the Emacs source tree. +const RepoSubdir = "emacs" + +// LocalSubdir is the gitignored per-machine overlay root inside the repo. A +// file's per-machine override lives at local/emacs/, mirroring the +// dotfile, agents-asset, and terminal .local layers — the natural home for a +// Customize-written inits/custom.el or a hand-authored init.local.el. +const LocalSubdir = "local" + +// TargetHome is the home-relative destination the emacs/ tree deploys to. It is +// inside $HOME, so dotfile.NestedTarget's containment guard passes it. Note +// ~/.emacs.d shadows the XDG ~/.config/emacs — Emacs reads ~/.emacs.d when it +// exists and never consults ~/.config/emacs. +const TargetHome = ".emacs.d" + +// KeyPrefix namespaces the domain's records in the shared last-applied store, so +// the de-scope pass can tell them apart from dotfiles/agents/terminals/ +// keybindings targets. +const KeyPrefix = "emacs/" + +// Item is one (content, target) pair the Emacs domain deploys: the planner's +// one-to-many expansion produces these (one per carried regular file in the +// tree), and the write path stays strictly 1:1. It mirrors termcfg.Item's shape +// so the command layer's per-target planning is identical. +type Item struct { + // Key is the stable last-applied store key ("emacs/"). The "emacs/" + // prefix namespaces the domain in the shared last-applied store. + Key string + // Label is the human-facing name reports print (e.g. "emacs:init.el"). + Label string + // Target carries the validated $HOME destination (built via + // dotfile.NestedTarget, so ~/.ssh and $HOME-escapes are impossible). + Target dotfile.Target + // Content is the exact bytes to materialise (the per-machine overlay when one + // exists, else the shared repo source), with any {{ferry.secret ...}} + // placeholders already rendered by the caller. + Content []byte + // Exec preserves the repo source's executable bit on a first-ever write (an + // Emacs config tree may ship a helper script). + Exec bool +} + +// PlanInput carries the planner's inputs. Guard validates a repo-side path +// before it is read (the caller passes its symlink-refusing repo guard) and +// returns the safe path; nil means no extra validation (tests). +type PlanInput struct { + RepoRoot string + Home string + Guard func(candidate string) (string, error) +} + +// Plan expands the Emacs domain into its 1:1 (content, target) items: one per +// carried regular file under the repo's emacs/ tree, in lexical walk order, each +// destined for the same relative path under ~/.emacs.d/ and carried like a +// dotfile. Content is the per-machine overlay (local/emacs/) when +// present — local wins, mirroring the dotfile, agents-asset, and terminal +// domains — else the shared repo source. Volatile, machine-generated paths (see +// excluded) are pruned and never deployed. It reads only the config repo, never +// $HOME. +// +// An absent emacs/ tree deploys nothing (the domain is enabled but nothing was +// committed). A symlinked source (the tree root or any entry inside it) is +// refused. Recoverable per-target problems (a refused/escaping path) become +// warnings and the item is skipped; only an unexpected read failure aborts. +func Plan(in PlanInput) (items []Item, warnings []string, err error) { + root := filepath.Join(in.RepoRoot, RepoSubdir) + safeRoot, gerr := guardPath(in.Guard, root) + if gerr != nil { + return nil, []string{refusal("source", RepoSubdir, gerr)}, nil + } + fi, serr := os.Lstat(safeRoot) + if serr != nil { + // An absent (or unreadable-as-absent) source deploys nothing. + if errors.Is(serr, fs.ErrNotExist) { + return nil, nil, nil + } + return nil, nil, serr + } + if fi.Mode()&fs.ModeSymlink != 0 { + return nil, []string{fmt.Sprintf( + "emacs: refusing %s: symlink not allowed in the managed repo tree (copy the real dir in)", RepoSubdir)}, nil + } + if !fi.IsDir() { + return nil, []string{fmt.Sprintf( + "emacs: refusing %s: the Emacs source must be a directory tree", RepoSubdir)}, nil + } + + walkErr := filepath.WalkDir(safeRoot, func(path string, d fs.DirEntry, werr error) error { + if werr != nil { + return werr + } + rel, rerr := filepath.Rel(safeRoot, path) + if rerr != nil { + return rerr + } + if rel == "." { + return nil + } + // Prune volatile, machine-generated paths: a whole excluded subtree is + // skipped, a single excluded file is dropped — never deployed even if the + // source tree contains it. + if excluded(rel) { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + if d.Type()&fs.ModeSymlink != 0 { + warnings = append(warnings, fmt.Sprintf( + "emacs: refusing %s: symlink not allowed in the managed repo tree (copy the real file in)", + filepath.Join(RepoSubdir, rel))) + if d.IsDir() { + return fs.SkipDir + } + return nil + } + if d.IsDir() || !d.Type().IsRegular() { + return nil + } + info, ierr := d.Info() + if ierr != nil { + return ierr + } + item, warn, berr := buildItem(in, path, rel, info) + if berr != nil { + return berr + } + if warn != "" { + warnings = append(warnings, warn) + return nil + } + items = append(items, item) + return nil + }) + if walkErr != nil { + return nil, nil, walkErr + } + return items, warnings, nil +} + +// buildItem resolves one file's deployable content (overlay-or-shared) and its +// validated $HOME destination under ~/.emacs.d/, returning a ready Item. A +// recoverable per-file problem (a refused target path) is returned as a +// non-empty warning with a zero Item; only an unexpected read failure returns +// err. +func buildItem(in PlanInput, repoFile, rel string, info fs.FileInfo) (Item, string, error) { + content, _, _, cerr := emacsContent(in.RepoRoot, repoFile, in.Guard) + if cerr != nil { + return Item{}, "", cerr + } + key := filepath.ToSlash(rel) + t, terr := dotfile.NestedTarget(in.Home, filepath.Join(TargetHome, rel), KeyPrefix+key) + if terr != nil { + return Item{}, refusal("target", "emacs:"+key, terr), nil + } + return Item{ + Key: KeyPrefix + key, + Label: "emacs:" + key, + Target: t, + Content: content, + Exec: info.Mode()&0o111 != 0, + }, "", nil +} + +// emacsContent resolves the bytes apply deploys for an Emacs file: the +// gitignored per-machine overlay at local/emacs/ when it exists (local +// wins), else the shared repo file. Both reads route through guard (the caller's +// symlink-refusing repo guard), so a symlink inside the managed tree is refused, +// never read through. It returns the resolved content, the overlay path (always +// computed), and whether the overlay was used. +func emacsContent(repoRoot, repoFile string, guard func(string) (string, error)) (content []byte, overlayPath string, usedOverlay bool, err error) { + overlayPath = overlayPathFor(repoRoot, repoFile) + if overlayPath != "" { + safe, gerr := guardPath(guard, overlayPath) + if gerr == nil { + if fi, serr := os.Lstat(safe); serr == nil && fi.Mode().IsRegular() { + data, rerr := os.ReadFile(safe) + if rerr != nil { + return nil, overlayPath, false, rerr + } + return data, overlayPath, true, nil + } + } + } + safe, gerr := guardPath(guard, repoFile) + if gerr != nil { + return nil, overlayPath, false, gerr + } + data, rerr := os.ReadFile(safe) + if rerr != nil { + return nil, overlayPath, false, rerr + } + return data, overlayPath, false, nil +} + +// overlayPathFor maps an Emacs file's shared repo source +// (/emacs/) to its per-machine overlay +// (/local/emacs/) — the SAME relative shape under local/. It +// returns "" when repoFile is not under /emacs (defensive; every +// planned file is), so callers treat that as "no overlay". +func overlayPathFor(repoRoot, repoFile string) string { + emacsRoot := filepath.Join(repoRoot, RepoSubdir) + rel, err := filepath.Rel(emacsRoot, repoFile) + if err != nil || rel == ".." || filepath.IsAbs(rel) || + len(rel) >= 3 && rel[:3] == ".."+string(filepath.Separator) { + return "" + } + return filepath.Join(repoRoot, LocalSubdir, RepoSubdir, rel) +} + +// guardPath routes a repo-side candidate through the caller's guard (when +// provided), so every repo read in this package honours the same +// symlink-refusing policy as the rest of ferry. +func guardPath(guard func(string) (string, error), candidate string) (string, error) { + if guard == nil { + return candidate, nil + } + return guard(candidate) +} + +// refusal renders a clear, user-facing warning for a skipped Emacs target, +// mirroring the dotfile, termcfg, and keybindings domains' refusal wording. +func refusal(what, name string, err error) string { + switch { + case errors.Is(err, dotfile.ErrForbiddenSSHPath): + return fmt.Sprintf("emacs: refusing %s %s: ferry never manages paths under ~/.ssh", what, name) + case errors.Is(err, dotfile.ErrPathEscapesHome): + return fmt.Sprintf("emacs: refusing %s %s: invalid managed path (escapes $HOME)", what, name) + default: + return fmt.Sprintf("emacs: refusing %s %s: %v", what, name, err) + } +} diff --git a/internal/gitconfig/analyze.go b/internal/gitconfig/analyze.go new file mode 100644 index 0000000..9398778 --- /dev/null +++ b/internal/gitconfig/analyze.go @@ -0,0 +1,190 @@ +package gitconfig + +import "strings" + +// identityKeys are the git config keys that must NEVER reach the shared +// ~/.gitconfig (plan §3.1): sharing one machine's identity would silently +// corrupt another machine's commit authorship, signing, or credential access. +// They are forced to the never-shared ~/.gitconfig.local layer. +// +// - user.email / user.name — commit authorship. +// - user.signingkey — the signing key id / public-key path (identity, NOT a +// secret: it is safe as plaintext in the LOCAL file, never routed through the +// secret store). +// - gpg.program — the local signing binary path. +// - credential.helper — the local credential backend (must be osxkeychain, +// never `store`; see CredentialHelperStore). +// - credential.username — the account login name (account identity, not a +// secret and not commit identity: kept plaintext-local, never routed to the +// secret store). FullKey collapses the subsection, so this drops both a bare +// credential.username AND a per-host credential..username. +var identityKeys = map[string]bool{ + "user.email": true, + "user.name": true, + "user.signingkey": true, + "gpg.program": true, + "credential.helper": true, + "credential.username": true, +} + +// isIdentityKey reports whether a canonical dotted key (lowercased, +// "section.name") is machine identity that must live only in ~/.gitconfig.local. +func isIdentityKey(fullKey string) bool { return identityKeys[fullKey] } + +// isIdentitySection reports whether a section's keys are ALL identity — so a +// header left with no non-identity keys after the partition is dropped whole, +// never left as an orphan `[user]` / `[gpg]` / `[credential]` block in the shared +// output. A [credential "https://host"] SUBSECTION is NOT treated as +// identity-only here (its non-helper keys — e.g. `provider`, `useHttpPath` — are +// shared), so only the bare, subsection-less credential section drops its header. +// Note the identity KEY `credential.helper` is dropped for EVERY host (FullKey +// collapses the subsection), the conservative direction: a per-host helper never +// reaches the shared repo even though its subsection header may remain. +func isIdentitySection(section, subsection string) bool { + switch section { + case "user", "gpg": + return true + case "credential": + return subsection == "" + } + return false +} + +// isIdentityBlockSection reports whether an ENTIRE section block routes to the +// local layer regardless of its keys: an [includeIf …] block carries per- +// directory identity (plan §3.1 / R8) and must never appear in the shared +// ~/.gitconfig. A plain [include] (ferry's own overlay, or a user's shared +// include) is NOT dropped. +func isIdentityBlockSection(section string) bool { + return section == "includeif" +} + +// SharedContent partitions a git-config's bytes into the SHARED representation: +// every identity key (user.email/name/signingkey, gpg.program, +// credential.helper) and every [includeIf …] block is dropped, and any section +// header left empty by that drop is dropped too. Everything else — aliases, +// core.*, init.defaultBranch, [url]/[http] ergonomics, unconditional [include], +// comments, blank lines, indentation — is preserved BYTE-FOR-BYTE (the shared +// output is a subsequence of the input's lines, each verbatim). +// +// This is the identity firewall enforced on the DEPLOY composition and the +// shared-capture write: even a git-config that mistakenly carries identity in the +// committed shared source can never deploy it into the shared ~/.gitconfig or be +// written back to the shared repo. On a config that already holds no identity it +// is a no-op and returns the input byte-for-byte (Reassemble(Parse(x)) == x with +// nothing dropped), so the git round-trip stays byte-stable. +func SharedContent(content []byte) []byte { + lines := Parse(content) + drop := identityDropSet(lines) + kept := make([]Line, 0, len(lines)) + for i, l := range lines { + if drop[i] { + continue + } + kept = append(kept, l) + } + return Reassemble(kept) +} + +// identityDropSet marks, per line index, whether the line belongs to the LOCAL +// (dropped-from-shared) identity partition: an identity key line, any line inside +// an [includeIf …] block, or a section header whose section becomes empty once +// its identity keys are removed. It is the single source of truth SharedContent +// partitions on (it keeps the un-marked lines). The machine identity layer itself +// is NOT reconstructed here — it comes from the local/git/gitconfig.local overlay +// the user maintains — so a "LocalContent" complement would be dead scaffolding. +func identityDropSet(lines []Line) map[int]bool { + drop := make(map[int]bool, len(lines)) + + // Group line indices by their owning section header (-1 = pre-section). + headerAt := -1 + members := map[int][]int{} + for i, l := range lines { + if l.Kind == Section { + headerAt = i + members[i] = append(members[i], i) + continue + } + members[headerAt] = append(members[headerAt], i) + } + + for h, idxs := range members { + if h < 0 { + continue + } + hdr := lines[h] + // An [includeIf …] block routes to local WHOLE — header, keys, and its + // interleaved comment/blank lines. + if isIdentityBlockSection(hdr.Section) { + for _, i := range idxs { + drop[i] = true + } + continue + } + // Otherwise drop identity KEY lines individually. + droppedKV, keptKV := 0, 0 + for _, i := range idxs { + l := lines[i] + if l.Kind != KeyValue { + continue + } + if isIdentityKey(l.FullKey()) { + drop[i] = true + droppedKV++ + } else { + keptKV++ + } + } + // If the section is an identity-only section AND every kv it held was + // dropped, drop the now-empty header (and its trailing blank/comment lines + // that no longer front any kept key) so no orphan [user]/[gpg]/[credential] + // block survives in the shared output. + if keptKV == 0 && droppedKV > 0 && isIdentitySection(hdr.Section, hdr.Subsection) { + for _, i := range idxs { + if lines[i].Kind == Section || lines[i].Kind == Comment || lines[i].Kind == Blank { + drop[i] = true + } + } + } + } + return drop +} + +// CredentialHelperStore reports whether content sets `credential.helper = store` +// — the backend that writes credentials as PLAINTEXT into ~/.git-credentials. +// ferry refuses to carry it (it must be `osxkeychain`); a caller warns and drops +// it. Detection is exact: only a real credential.helper KeyValue whose value's +// first token is `store` trips it, so a `credential.helper = osxkeychain` line or +// a helper named `store-something` is left alone. +func CredentialHelperStore(content []byte) bool { + for _, l := range Parse(content) { + if l.Kind != KeyValue || l.FullKey() != "credential.helper" { + continue + } + if valueFirstToken(l.Raw) == "store" { + return true + } + } + return false +} + +// valueFirstToken returns the first whitespace-delimited token of a KeyValue +// line's value (the bytes after the first '='), lowercased. It is used only to +// classify credential.helper's backend name. +func valueFirstToken(raw string) string { + body := strings.TrimRight(raw, "\n") + body = strings.TrimRight(body, "\r") + eq := strings.IndexByte(body, '=') + if eq < 0 { + return "" + } + val := strings.TrimSpace(body[eq+1:]) + if val == "" { + return "" + } + // Strip a surrounding quote pair so a quoted `helper = "store"` is recognised + // too (security-review F4); an inner-token comparison follows. + tok := strings.Fields(val)[0] + tok = strings.Trim(tok, `"'`) + return strings.ToLower(tok) +} diff --git a/internal/gitconfig/analyze_test.go b/internal/gitconfig/analyze_test.go new file mode 100644 index 0000000..e5b1308 --- /dev/null +++ b/internal/gitconfig/analyze_test.go @@ -0,0 +1,221 @@ +package gitconfig + +import ( + "strings" + "testing" +) + +const identityConfig = `[core] + editor = vim +[init] + defaultBranch = main +[alias] + st = status +[user] + email = alice@example.com + name = Alice Example + signingkey = ABCD1234 +[gpg] + program = gpg2 +[credential] + helper = osxkeychain +[includeIf "gitdir:~/work/"] + path = ~/work/.gitconfig +` + +func TestSharedContentDropsIdentity(t *testing.T) { + shared := string(SharedContent([]byte(identityConfig))) + + for _, leak := range []string{ + "alice@example.com", "Alice Example", "ABCD1234", "gpg2", "osxkeychain", + "[user]", "[gpg]", "[credential]", "[includeIf", "gitdir:", "signingkey", + } { + if strings.Contains(shared, leak) { + t.Errorf("IDENTITY LEAK: shared content still contains %q:\n%s", leak, shared) + } + } + // Non-identity content is preserved verbatim. + for _, keep := range []string{"[core]", "editor = vim", "[init]", "defaultBranch = main", "[alias]", "st = status"} { + if !strings.Contains(shared, keep) { + t.Errorf("shared content dropped non-identity %q:\n%s", keep, shared) + } + } +} + +func TestSharedContentNoOpOnCleanConfig(t *testing.T) { + clean := "[core]\n\teditor = vim\n[alias]\n\tst = status\n\n[include]\n\tpath = ~/.gitconfig.local\n" + got := string(SharedContent([]byte(clean))) + if got != clean { + t.Errorf("SharedContent must be a byte-for-byte no-op on an identity-free config\n in: %q\nout: %q", clean, got) + } +} + +func TestSharedContentKeepsPlainInclude(t *testing.T) { + // A plain [include] is NOT identity — only [includeIf …] routes to local. + in := "[include]\n\tpath = ~/.shared-extra\n[includeIf \"gitdir:~/w/\"]\n\tpath = ~/w/.gitconfig\n" + shared := string(SharedContent([]byte(in))) + if !strings.Contains(shared, "[include]") || !strings.Contains(shared, "~/.shared-extra") { + t.Errorf("plain [include] was wrongly dropped:\n%s", shared) + } + if strings.Contains(shared, "includeIf") || strings.Contains(shared, "~/w/.gitconfig") { + t.Errorf("[includeIf …] leaked into shared:\n%s", shared) + } +} + +func TestSharedContentKeepsCredentialSubsection(t *testing.T) { + // A [credential "https://host"] subsection is per-host config (shared), NOT + // the identity-only bare [credential] helper. + in := "[credential \"https://ghe.example.com\"]\n\tprovider = generic\n[credential]\n\thelper = osxkeychain\n" + shared := string(SharedContent([]byte(in))) + if !strings.Contains(shared, "[credential \"https://ghe.example.com\"]") || !strings.Contains(shared, "provider = generic") { + t.Errorf("credential subsection wrongly dropped:\n%s", shared) + } + if strings.Contains(shared, "helper = osxkeychain") { + t.Errorf("bare credential.helper leaked into shared:\n%s", shared) + } +} + +// TestSharedContentInlineHeaderNoLeak is the ruthless-review FINDING-1 +// regression: git parses `[section] key = value` (and `[section]key=value` with +// no spaces) as a header PLUS an assignment, so an identity key written inline +// with its header must STILL be firewalled out of the shared output. The parser +// splits the physical line so the trailing identity key is dropped. +func TestSharedContentInlineHeaderNoLeak(t *testing.T) { + cases := []string{ + "[user] email = inline@leak.example\n", + "[user]email=nospace@leak.example\n", + "[user] name = Inline Name\n", + "[gpg] program = /leak/gpg\n", + "[user] email = a@leak.example\n\tname = B Leak\n\tsigningkey = LEAKKEY\n", + } + for _, in := range cases { + shared := string(SharedContent([]byte(in))) + for _, leak := range []string{"leak.example", "Inline Name", "/leak/gpg", "B Leak", "LEAKKEY"} { + if strings.Contains(shared, leak) { + t.Errorf("IDENTITY LEAK via inline header:\n in: %q\nshared: %q (found %q)", in, shared, leak) + } + } + } + // A NON-identity inline header is preserved byte-for-byte. + nonID := "[core] editor = vim\n" + if got := string(SharedContent([]byte(nonID))); got != nonID { + t.Errorf("non-identity inline header must be a no-op\n in: %q\nout: %q", nonID, got) + } +} + +// TestCredentialHelperStoreInline is the FINDING-1 companion: an inline +// `[credential] helper = store` must be detected (so it is warned and stripped). +func TestCredentialHelperStoreInline(t *testing.T) { + for _, in := range []string{ + "[credential] helper = store\n", + "[credential]helper=store\n", + } { + if !CredentialHelperStore([]byte(in)) { + t.Errorf("inline credential.helper=store not detected: %q", in) + } + if strings.Contains(string(SharedContent([]byte(in))), "store") { + t.Errorf("inline credential.helper=store leaked into shared: %q", in) + } + } +} + +// TestSharedContentBackslashContinuationNoLeak is the FINDING-2 regression: git +// joins a value across physical lines when a line ends in an ODD run of trailing +// backslashes. The continuation fragment must be dropped WITH its owning identity +// key, never survive as a fresh non-identity key. +func TestSharedContentBackslashContinuationNoLeak(t *testing.T) { + cases := []struct{ name, in string }{ + {"fragment", "[user]\n\temail = foo\\\nbar@leak.example\n"}, + {"whole-on-continuation", "[user]\n\temail = \\\nalice@leak.example\n"}, + {"multi-continuation", "[user]\n\temail = a\\\nb\\\nc@leak.example\n"}, + {"inline-then-continuation", "[user] email = foo\\\nbar@leak.example\n"}, + } + for _, tc := range cases { + shared := string(SharedContent([]byte(tc.in))) + for _, leak := range []string{"leak.example", "bar@", "alice@", "c@leak", "foo", "bar"} { + if strings.Contains(shared, leak) { + t.Errorf("%s: IDENTITY LEAK via backslash continuation:\n in: %q\nshared: %q (found %q)", tc.name, tc.in, shared, leak) + } + } + } + // An EVEN backslash run is NOT a continuation: the next line is a fresh key and + // stands on its own (here a non-identity key, kept). + even := "[user]\n\temail = a@x.com\n[core]\n\teditor = vi\\\\\n" + if got := string(SharedContent([]byte(even))); !strings.Contains(got, "editor = vi\\\\") { + t.Errorf("even backslash run wrongly treated as continuation:\n%s", got) + } +} + +// TestSharedContentMalformedHeaderNoLeak is the security-review F1 regression: +// an identity key under an UNCLOSED section header (`[user` with no `]`, which +// git itself rejects) must STILL be firewalled out of the shared output — it is +// classified leniently as the `user` section and dropped, never mis-attributed +// to a previous section and carried through. +func TestSharedContentMalformedHeaderNoLeak(t *testing.T) { + cases := []string{ + "[user\n\temail = secret@corp.com\n\tname = Real Name\n", + "[alias]\n\tco = checkout\n[user\n\temail = secret@corp.com\n", + "[USER\n\tEMAIL = secret@corp.com\n", + } + for _, in := range cases { + shared := string(SharedContent([]byte(in))) + if strings.Contains(shared, "secret@corp.com") || strings.Contains(shared, "Real Name") { + t.Errorf("IDENTITY LEAK via malformed header:\n in: %q\nshared: %q", in, shared) + } + } +} + +// TestSharedContentMalformedHeaderRoundTripUnaffected confirms the lenient header +// parse never breaks the byte-faithful reassembly. +func TestSharedContentMalformedHeaderRoundTripUnaffected(t *testing.T) { + for _, in := range []string{"[user\n\temail = x\n", "[]\nfoo = bar\n", "[unclosed"} { + if got := string(Reassemble(Parse([]byte(in)))); got != in { + t.Errorf("round-trip broke on malformed header\n in: %q\nout: %q", in, got) + } + } +} + +// TestSharedContentDropsCredentialUsername proves credential.username (account +// identity) is forced local — dropped from shared for a bare [credential], a +// per-host [credential "https://host"] subsection (FullKey collapses it), and the +// inline `[credential] username = x` form, on canonical and inline spellings. +func TestSharedContentDropsCredentialUsername(t *testing.T) { + cases := []string{ + "[credential]\n\tusername = alex\n", + "[credential \"https://github.example.com\"]\n\tusername = alex\n", + "[credential] username = alex\n", + "[credential \"https://ghe.example.com\"] username = alex\n", + } + for _, in := range cases { + shared := string(SharedContent([]byte(in))) + if strings.Contains(shared, "alex") || strings.Contains(shared, "username") { + t.Errorf("IDENTITY LEAK: credential.username reached shared\n in: %q\nshared: %q", in, shared) + } + } + // A non-username per-host credential key is still shared (only username/helper + // are identity under credential). + keep := "[credential \"https://ghe.example.com\"]\n\tprovider = generic\n" + if got := string(SharedContent([]byte(keep))); !strings.Contains(got, "provider = generic") { + t.Errorf("non-identity per-host credential key wrongly dropped:\n%s", got) + } +} + +func TestCredentialHelperStore(t *testing.T) { + cases := []struct { + name string + in string + want bool + }{ + {"store", "[credential]\n\thelper = store\n", true}, + {"quoted-store", "[credential]\n\thelper = \"store\"\n", true}, + {"osxkeychain", "[credential]\n\thelper = osxkeychain\n", false}, + {"store-with-args", "[credential]\n\thelper = store --file ~/x\n", true}, + {"other-helper", "[credential]\n\thelper = manager-core\n", false}, + {"no-helper", "[core]\n\teditor = vim\n", false}, + } + for _, tc := range cases { + if got := CredentialHelperStore([]byte(tc.in)); got != tc.want { + t.Errorf("%s: CredentialHelperStore = %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/internal/gitconfig/parse.go b/internal/gitconfig/parse.go new file mode 100644 index 0000000..a770f99 --- /dev/null +++ b/internal/gitconfig/parse.go @@ -0,0 +1,322 @@ +// Package gitconfig holds ferry's git-INI-format knowledge that must NOT live in +// the generic dotfile / secret machinery: a byte-faithful git-config parser +// (Reassemble(Parse(x)) == x), the identity partition (user.email / user.name / +// user.signingkey / gpg.program / credential.helper and every [includeIf …] +// block are forced to the never-shared ~/.gitconfig.local layer), the +// credential.helper=store refusal, and the sub-value secret recogniser for +// URL-embedded tokens and http.extraHeader credentials. +// +// It CALLS the generic secret primitives (secret.ScanText, +// secret.IsNonPlaceholderSecret, secret.SwapColumns) but never teaches +// internal/secret any git syntax — the same boundary the tmux and zsh plugins +// keep. The parser is hand-written (no ini / go-git dependency): git-config's +// grammar is small and ferry needs byte-for-byte round-trip fidelity a general +// library would not promise. +package gitconfig + +import "strings" + +// Kind classifies a physical git-config line for the analysis passes. It never +// affects Reassemble — reassembly is a verbatim concatenation of every line's +// Raw bytes — so an unrecognised line (Junk) still round-trips exactly. +type Kind int + +const ( + // Blank is a whitespace-only line. + Blank Kind = iota + // Comment is a line whose first non-whitespace rune is '#' or ';'. + Comment + // Section is a `[section]` / `[section "sub"]` / `[section.sub]` header line. + Section + // KeyValue is a `name = value` (or bare boolean `name`) assignment line. + KeyValue + // Junk is a non-blank, non-comment line that is neither a section header nor a + // recognisable assignment. It is preserved verbatim and owns no section key. + Junk +) + +// Line is one physical line of a git-config file. Raw is the EXACT bytes of the +// line INCLUDING its trailing '\n' (the final line of a file with no trailing +// newline omits it), so Reassemble is a plain concatenation and +// Reassemble(Parse(x)) == x holds byte-for-byte — comments, blank lines, tabs, +// indentation, subsection headers, and multivalue keys included. +// +// Section (canonical, lowercased, no subsection), Subsection (raw), and Key +// (lowercased variable name, KeyValue lines only) are the ANALYSIS metadata the +// identity partition keys off; they are derived, never used for reassembly. +type Line struct { + Raw string + Kind Kind + Section string // canonical lowercased section name in effect for this line + Subsection string // raw subsection text of the section in effect ("" if none) + Key string // lowercased variable name (KeyValue only) +} + +// FullKey is the canonical dotted key of a KeyValue line: "
." +// (both lowercased). It is "" for a line the section context could not be +// resolved for (a KeyValue before any section header — invalid git config). +func (l Line) FullKey() string { + if l.Kind != KeyValue || l.Section == "" { + return "" + } + return l.Section + "." + l.Key +} + +// Parse splits content into logical lines, classifying each and threading the +// current section context onto every line, modelling git-config's real grammar so +// the identity firewall sees every key git would: +// +// - INLINE assignment: git parses `[section] key = value` (and `[section]key=x`) +// as a header PLUS an assignment. Parse splits such a physical line into two +// logical Lines — a Section Line (the `[section]` bytes) and a KeyValue Line +// (the trailing `key = value` bytes) — so the trailing key is classified and +// the firewall can drop an inline identity key (security-review F1). +// - VALUE CONTINUATION: git joins a value across physical lines when a line ends +// in an ODD run of trailing backslashes. Parse marks the continuation physical +// line as a KeyValue INHERITING the owning key's section/name, so a +// backslash-continued identity value is dropped with its key rather than +// surviving as a fresh non-identity key (security-review F2). +// +// It is TOTAL: any byte sequence parses (an unrecognised line becomes Junk), and +// Reassemble(Parse(x)) == x for every input — the split preserves the physical +// line's exact bytes ACROSS the two logical Lines' Raw fields, and every other +// line keeps its bytes verbatim, so reassembly is byte-exact even though +// classification changed. Non-UTF-8 bytes pass through untouched inside Raw; a +// CRLF line keeps its '\r' inside Raw. +func Parse(content []byte) []Line { + if len(content) == 0 { + return nil + } + raws := splitKeepEnds(content) + lines := make([]Line, 0, len(raws)) + + curSection, curSub := "", "" + // Continuation state: contActive means the previous physical value line ended + // in an odd backslash run, so THIS physical line continues that value and + // inherits contSection/contKey (never opening a new key). + contActive := false + contSection, contKey := "", "" + + for _, raw := range raws { + if contActive { + l := Line{Raw: raw, Kind: KeyValue, Section: contSection, Subsection: curSub, Key: contKey} + lines = append(lines, l) + contActive = endsInOddBackslash(raw) + continue + } + + trimmed := strings.TrimSpace(trimNL(raw)) + if trimmed != "" && trimmed[0] == '[' { + headerRaw, restRaw, hasRest := splitInlineHeader(raw) + if sec, sub, ok := parseSectionHeader(strings.TrimSpace(trimNL(headerRaw))); ok { + curSection, curSub = sec, sub + // When there is no inline rest, the Section line owns the WHOLE + // physical line (including its newline / any trailing blank) so + // reassembly stays byte-exact; only a real inline assignment splits the + // physical line's bytes across two logical Lines. + secRaw := raw + if hasRest { + secRaw = headerRaw + } + lines = append(lines, Line{Raw: secRaw, Kind: Section, Section: sec, Subsection: sub}) + if hasRest { + rl := classifyBodyLine(restRaw, curSection, curSub) + lines = append(lines, rl) + if rl.Kind == KeyValue { + contActive = endsInOddBackslash(restRaw) + contSection, contKey = rl.Section, rl.Key + } + } + continue + } + // A `[`-leading line with no usable section name (a bare `[]`) is Junk AND + // resets the section context to "" so a following key is never + // MIS-attributed to the previous section (defensive; git rejects such a + // file). The WHOLE physical line is preserved as one Junk Line. + lines = append(lines, Line{Raw: raw, Kind: Junk}) + curSection, curSub = "", "" + continue + } + + l := classifyBodyLine(raw, curSection, curSub) + lines = append(lines, l) + if l.Kind == KeyValue { + contActive = endsInOddBackslash(raw) + contSection, contKey = l.Section, l.Key + } + } + return lines +} + +// classifyBodyLine classifies a NON-header physical line (blank / comment / +// assignment / junk) under the current section context. A KeyValue requires a +// section context (a section-less assignment is Junk, matching git's error). +func classifyBodyLine(raw, section, subsection string) Line { + l := Line{Raw: raw, Section: section, Subsection: subsection} + trimmed := strings.TrimSpace(trimNL(raw)) + switch { + case trimmed == "": + l.Kind = Blank + case trimmed[0] == '#' || trimmed[0] == ';': + l.Kind = Comment + default: + if key, ok := parseKey(trimmed); ok && section != "" { + l.Kind = KeyValue + l.Key = key + } else { + l.Kind = Junk + } + } + return l +} + +// splitInlineHeader splits a `[`-leading physical line into the `[section]` header +// bytes and any trailing assignment/comment bytes git would parse as a separate +// unit. headerRaw + restRaw == raw byte-for-byte. hasRest is true only when +// restRaw carries non-blank content (a trailing assignment or comment). When the +// header does not close on this line (no `]`), the whole line is the header +// (restRaw empty) — parseSectionHeader parses it leniently. +func splitInlineHeader(raw string) (headerRaw, restRaw string, hasRest bool) { + c := inlineCloseIdx(raw) + if c < 0 { + return raw, "", false + } + headerRaw = raw[:c+1] + restRaw = raw[c+1:] + return headerRaw, restRaw, strings.TrimSpace(trimNL(restRaw)) != "" +} + +// inlineCloseIdx returns the byte index of the `]` that CLOSES the section header +// on this physical line, respecting a quoted subsection (a `]` inside `"…"` does +// not close the header) and never crossing a newline. It returns -1 when the +// header does not close on this line. +func inlineCloseIdx(raw string) int { + open := strings.IndexByte(raw, '[') + if open < 0 { + return -1 + } + inQuote := false + for i := open + 1; i < len(raw); i++ { + switch raw[i] { + case '"': + inQuote = !inQuote + case ']': + if !inQuote { + return i + } + case '\n': + return -1 + } + } + return -1 +} + +// endsInOddBackslash reports whether a physical line's content (its bytes with the +// trailing newline / CR stripped) ends in an ODD number of backslashes — git's +// value-continuation signal (an even run is escaped backslashes, not a +// continuation). +func endsInOddBackslash(raw string) bool { + body := trimNL(raw) + n := 0 + for i := len(body) - 1; i >= 0 && body[i] == '\\'; i-- { + n++ + } + return n%2 == 1 +} + +// trimNL strips a single trailing '\n' and then a trailing '\r' (a CRLF line +// ending), leaving the line's content bytes. +func trimNL(s string) string { + s = strings.TrimSuffix(s, "\n") + return strings.TrimSuffix(s, "\r") +} + +// Reassemble concatenates every line's Raw bytes. Because Parse keeps each +// line's exact bytes (trailing newline included), Reassemble(Parse(x)) == x for +// every input — the round-trip property fuzzed in parse_test.go and required by +// the plan's STOP condition. +func Reassemble(lines []Line) []byte { + var b strings.Builder + for _, l := range lines { + b.WriteString(l.Raw) + } + return []byte(b.String()) +} + +// splitKeepEnds splits content into physical lines, each KEEPING its trailing +// '\n' (the final line may lack one). The concatenation of the result equals +// content exactly. +func splitKeepEnds(content []byte) []string { + var out []string + start := 0 + for i, b := range content { + if b == '\n' { + out = append(out, string(content[start:i+1])) + start = i + 1 + } + } + if start < len(content) { + out = append(out, string(content[start:])) + } + return out +} + +// parseSectionHeader parses a trimmed `[section]` / `[section "subsection"]` / +// `[section.sub]` header. section is lowercased (git section names are +// case-insensitive); subsection is returned VERBATIM (a "sub" subsection is +// case-sensitive, a .sub dotted one is not — for identity matching only the +// section name and, for includeIf, its mere presence matter). +// +// A missing closing ']' is parsed LENIENTLY from what follows '[' (up to the end +// of the line) rather than rejected: git itself errors on `[user` ("missing ]"), +// but ferry must still classify that section so an identity key on the following +// line cannot slip past the firewall into the shared repo (security-review F1). +// ok is false only when no section name can be recovered at all (a bare `[]`), +// which the caller treats as a context-resetting Junk line. Either way the line's +// Raw bytes are preserved and Reassemble(Parse(x)) == x still holds. +func parseSectionHeader(trimmed string) (section, subsection string, ok bool) { + if !strings.HasPrefix(trimmed, "[") { + return "", "", false + } + inner := trimmed[1:] + if close := strings.IndexByte(inner, ']'); close >= 0 { + inner = inner[:close] + } + inner = strings.TrimSpace(inner) + if inner == "" { + return "", "", false + } + if q := strings.IndexByte(inner, '"'); q >= 0 { + // [section "subsection"] — subsection is the quoted remainder. + sec := strings.TrimSpace(inner[:q]) + sub := inner[q+1:] + if end := strings.LastIndexByte(sub, '"'); end >= 0 { + sub = sub[:end] + } + return strings.ToLower(sec), sub, true + } + if dot := strings.IndexByte(inner, '.'); dot >= 0 { + // [section.subsection] — deprecated dotted form, case-insensitive. + return strings.ToLower(inner[:dot]), inner[dot+1:], true + } + return strings.ToLower(inner), "", true +} + +// parseKey extracts the lowercased variable name from a trimmed assignment line +// ("name = value", "name", or "name = value ; comment"). The name runs up to the +// first '=', whitespace, or inline comment marker. ok is false when no name +// character precedes those delimiters. +func parseKey(trimmed string) (key string, ok bool) { + end := len(trimmed) + for i, r := range trimmed { + if r == '=' || r == ' ' || r == '\t' || r == '#' || r == ';' { + end = i + break + } + } + name := strings.TrimSpace(trimmed[:end]) + if name == "" { + return "", false + } + return strings.ToLower(name), true +} diff --git a/internal/gitconfig/parse_test.go b/internal/gitconfig/parse_test.go new file mode 100644 index 0000000..2625fb6 --- /dev/null +++ b/internal/gitconfig/parse_test.go @@ -0,0 +1,100 @@ +package gitconfig + +import ( + "strings" + "testing" +) + +// realisticConfigs are byte-for-byte round-trip seeds: comments, blank lines, +// tabs vs spaces, `[section "subsection"]` headers, multivalue keys, dotted +// subsections, [includeIf "gitdir:…"], a trailing-newline-less file, and a +// CRLF line. Each must satisfy Reassemble(Parse(x)) == x exactly. +var realisticConfigs = []string{ + "", + "\n", + "\n\n\n", + "# a lone comment\n", + "[core]\n\teditor = vim\n", + "[user]\n\temail = me@example.com\n\tname = Me\n", + "[core]\n\teditor = vim\n\tautocrlf = input\n\n[alias]\n\tst = status\n\tlg = log --oneline\n", + "[user]\n email = spaces@example.com\n name = Spaces Person\n", + "[includeIf \"gitdir:~/work/\"]\n\tpath = ~/work/.gitconfig\n", + "[url \"https://github.com/\"]\n\tinsteadOf = git://github.com/\n\tinsteadOf = ssh://git@github.com/\n", + "[remote \"origin\"]\n\turl = git@github.com:me/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n", + "[core.subsection]\n\tfoo = bar\n", + "; semicolon comment\n[core] ; inline after header\n\tbare\n", + "[http]\n\textraHeader = Authorization: Bearer sometoken\n", + "no leading section is junk\n[core]\n\teditor = nano\n", + "[core]\n\teditor = vim", // no trailing newline + "[core]\r\n\teditor = vim\r\n", + "[core]\n\teditor = vim\n\n\n", // trailing blank run + "[include]\n\tpath = ~/.gitconfig.local\n", + // Inline `[section] key = value` and no-space forms. + "[user] email = inline@example.com\n", + "[user]email=nospace@example.com\n", + "[core] editor = vim\n\tpager = less\n", + "[core] ; trailing comment\n", + "[url \"https://x]y/\"] insteadOf = git://x/\n", // ']' inside a quoted subsection + // Backslash value-continuation (odd run continues, even run does not). + "[user]\n\temail = foo\\\nbar@example.com\n", + "[user]\n\temail = \\\nalice@example.com\n", + "[core]\n\teditor = vi\\\\\n", +} + +func TestReassembleParseRoundTrip(t *testing.T) { + for _, in := range realisticConfigs { + got := string(Reassemble(Parse([]byte(in)))) + if got != in { + t.Errorf("round-trip mismatch\n input: %q\noutput: %q", in, got) + } + } +} + +func TestParseSectionContext(t *testing.T) { + lines := Parse([]byte("[user]\n\temail = me@x.com\n[core]\n\teditor = vim\n")) + var got []string + for _, l := range lines { + if l.Kind == KeyValue { + got = append(got, l.FullKey()) + } + } + want := []string{"user.email", "core.editor"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("FullKey context = %v, want %v", got, want) + } +} + +func TestParseSubsectionHeader(t *testing.T) { + lines := Parse([]byte("[url \"https://github.com/\"]\n\tinsteadOf = git://github.com/\n")) + if lines[0].Kind != Section || lines[0].Section != "url" || lines[0].Subsection != "https://github.com/" { + t.Fatalf("subsection header parsed as %+v", lines[0]) + } + if lines[1].FullKey() != "url.insteadof" { + t.Errorf("insteadOf key = %q, want url.insteadof", lines[1].FullKey()) + } +} + +func TestParseIncludeIfContext(t *testing.T) { + lines := Parse([]byte("[includeIf \"gitdir:~/work/\"]\n\tpath = ~/work/.gitconfig\n")) + if lines[0].Section != "includeif" { + t.Errorf("includeIf section = %q, want includeif", lines[0].Section) + } +} + +// FuzzReassembleParseRoundTrip is the STOP-condition proof: for ANY input, +// Reassemble(Parse(x)) == x byte-for-byte. A failure here is the plan's +// "any parser fails Reassemble(Parse(x))==x -> stop" condition. +func FuzzReassembleParseRoundTrip(f *testing.F) { + for _, seed := range realisticConfigs { + f.Add([]byte(seed)) + } + // Extra structural seeds the corpus should explore mutations around. + f.Add([]byte("[a]\nb=c\n[d \"e\"]\nf = g\n; h\n\n[i.j]\nk\n")) + f.Add([]byte("[\tweird ]\n = novalue\n[]\n[unclosed\n")) + f.Fuzz(func(t *testing.T, in []byte) { + got := Reassemble(Parse(in)) + if string(got) != string(in) { + t.Fatalf("round-trip mismatch\n input: %q\noutput: %q", in, got) + } + }) +} diff --git a/internal/gitconfig/secret.go b/internal/gitconfig/secret.go new file mode 100644 index 0000000..ce25234 --- /dev/null +++ b/internal/gitconfig/secret.go @@ -0,0 +1,107 @@ +package gitconfig + +import ( + "regexp" + "strings" + + "github.com/REPPL/ferry/internal/secret" +) + +// urlCredentialRe matches a URL with an embedded userinfo credential inside a +// git-config value or subsection header: `scheme://[user:]@host`. Group 1 +// is the TOKEN — the password when the userinfo has a `user:pass` shape +// (`https://oauth2:@…`), or the whole userinfo when it does not +// (`https://@…`). Its FindStringSubmatchIndex byte offsets become the +// SecretSpan column range, so ONLY the token is replaced and the surrounding +// scheme, optional `user:`, `@host`, and quotes survive byte-for-byte. +// +// The optional `(?:[^:/@\s]*:)?` consumes a leading `user:` so a `user:pass` +// URL isolates the password; a plain `token@` URL isolates the token. +var urlCredentialRe = regexp.MustCompile(`://(?:[^:/@\s]*:)?([^:/@\s]+)@`) + +// extraHeaderRe matches the credential after an HTTP auth scheme in an +// http.extraHeader value: `Authorization: Bearer ` / `Basic `. +// Group 1 is the TOKEN; its byte offsets narrow the SecretSpan so the +// `Authorization:` name and the `Bearer `/`Basic ` scheme prefix are preserved. +// The token group stops before whitespace AND before a trailing quote, so a +// quoted `extraHeader = "Authorization: Bearer "` isolates without +// swallowing the closing quote into the placeholder or the stored value +// (security-review F3). +var extraHeaderRe = regexp.MustCompile(`(?i)\b(?:Bearer|Basic)\s+([^\s"']+)`) + +// SecretSpans returns the column-grained secret spans for git-config lines whose +// value embeds a real, high-confidence, literal credential: +// +// - a token inside a URL userinfo (url..insteadOf / insteadOf / +// pushInsteadOf values, and a `[url "…"]` subsection header) — the token +// between `://[user:]` and `@`; +// - the credential after `Bearer `/`Basic ` in an http.extraHeader value. +// +// A span carries the byte range of ONLY the token (StartCol/EndCol on a single +// line), so the capture site replaces just that range via secret.SwapColumns and +// leaves every surrounding byte — the URL scheme and host, the `user:` prefix, +// the `Authorization: Bearer ` prefix, and any quotes — byte-identical. +// +// A token is a secret only when BOTH gates hold, exactly as the tmux/zsh +// sub-value recognisers require: +// - secret.IsNonPlaceholderSecret(token) — a `${GIT_TOKEN}` env-ref, a ferry +// `{{…}}` placeholder, or an empty value is NOT a secret and is left in the +// repo verbatim; and +// - secret.ScanText(token).HasHigh() — the token is a real high-confidence +// secret, not an ordinary username or host fragment. +// +// If a git secret shape cannot be isolated to a clean span it contributes +// nothing here; the capture caller then degrades to a REFUSAL (never a whole-line +// clobber, never a repo write), mirroring tmux. +func SecretSpans(text string) []secret.SecretSpan { + var spans []secret.SecretSpan + for i, line := range strings.Split(text, "\n") { + lineNo := i + 1 + spans = appendMatchSpans(spans, line, lineNo, urlCredentialRe) + if isExtraHeaderLine(line) { + spans = appendMatchSpans(spans, line, lineNo, extraHeaderRe) + } + } + return spans +} + +// appendMatchSpans finds every group-1 match of re in line and appends a +// column-grained span for each token that passes both secret gates. It uses byte +// offsets (FindAllStringSubmatchIndex) so the span range slices line on byte +// boundaries — the surrounding syntax is preserved verbatim by SwapColumns. +func appendMatchSpans(spans []secret.SecretSpan, line string, lineNo int, re *regexp.Regexp) []secret.SecretSpan { + for _, m := range re.FindAllStringSubmatchIndex(line, -1) { + if len(m) < 4 || m[2] < 0 || m[3] < 0 { + continue + } + start, end := m[2], m[3] + token := line[start:end] + if !secret.IsNonPlaceholderSecret(token) { + continue // ${ENV} / {{placeholder}} / empty: left verbatim, never stored + } + if !secret.ScanText(token).HasHigh() { + continue // not a real high-confidence secret + } + spans = append(spans, secret.SecretSpan{ + StartLine: lineNo, + EndLine: lineNo, + Value: token, + StartCol: start, + EndCol: end, + }) + } + return spans +} + +// isExtraHeaderLine reports whether a line is (or plausibly is) an +// http.extraHeader assignment — the only place a `Bearer `/`Basic ` credential +// scan is applied, so an unrelated line mentioning "Bearer" is not scanned. It +// matches on the key text before '=' so leading `[http]`-section indentation and +// spacing do not matter. +func isExtraHeaderLine(line string) bool { + eq := strings.IndexByte(line, '=') + if eq < 0 { + return false + } + return strings.Contains(strings.ToLower(line[:eq]), "extraheader") +} diff --git a/internal/gitconfig/secret_test.go b/internal/gitconfig/secret_test.go new file mode 100644 index 0000000..e1d21df --- /dev/null +++ b/internal/gitconfig/secret_test.go @@ -0,0 +1,107 @@ +package gitconfig + +import "testing" + +// ghToken is a GitHub-token-shaped High-confidence secret (named-token rule), +// with no placeholder words so IsNonPlaceholderSecret accepts it. +const ghToken = "ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8" + +func TestSecretSpans_URLTokenBare(t *testing.T) { + line := "\tinsteadOf = https://" + ghToken + "@github.com/" + text := "[url \"x\"]\n" + line + "\n" + spans := SecretSpans(text) + if len(spans) != 1 { + t.Fatalf("want 1 span, got %d: %+v", len(spans), spans) + } + sp := spans[0] + if sp.Value != ghToken { + t.Errorf("span value = %q, want the bare token %q", sp.Value, ghToken) + } + // The column range must isolate ONLY the token: line[StartCol:EndCol] == token, + // and the scheme+host survive. + if sp.StartLine != 2 || !sp.HasColumns() { + t.Fatalf("span not column-grained on line 2: %+v", sp) + } + if line[sp.StartCol:sp.EndCol] != ghToken { + t.Errorf("column range does not isolate the token: got %q", line[sp.StartCol:sp.EndCol]) + } +} + +func TestSecretSpans_URLTokenAfterUser(t *testing.T) { + line := "\tinsteadOf = https://oauth2:" + ghToken + "@github.com/" + spans := SecretSpans("[url \"x\"]\n" + line + "\n") + if len(spans) != 1 { + t.Fatalf("want 1 span, got %d: %+v", len(spans), spans) + } + if spans[0].Value != ghToken { + t.Errorf("span value = %q, want %q (only the password, not oauth2:)", spans[0].Value, ghToken) + } + if line[spans[0].StartCol:spans[0].EndCol] != ghToken { + t.Errorf("column range wrong: got %q", line[spans[0].StartCol:spans[0].EndCol]) + } +} + +func TestSecretSpans_ExtraHeaderBearer(t *testing.T) { + line := "\textraHeader = Authorization: Bearer " + ghToken + spans := SecretSpans("[http]\n" + line + "\n") + if len(spans) != 1 { + t.Fatalf("want 1 span, got %d: %+v", len(spans), spans) + } + if line[spans[0].StartCol:spans[0].EndCol] != ghToken { + t.Errorf("Bearer token not isolated: got %q", line[spans[0].StartCol:spans[0].EndCol]) + } +} + +func TestSecretSpans_ExtraHeaderBasic(t *testing.T) { + // A base64-shaped Basic credential of sufficient length trips the entropy / + // hex heuristics; use a long token to guarantee High confidence. + basic := "dXNlcjpnaHBfQTFiMkMzZDRFNWY2RzdoOEk5ajBLMWwyTTNuNE81cDZRN3I4" + line := "\textraHeader = Authorization: Basic " + basic + spans := SecretSpans("[http]\n" + line + "\n") + if len(spans) != 1 { + t.Fatalf("want 1 span, got %d: %+v", len(spans), spans) + } + if line[spans[0].StartCol:spans[0].EndCol] != basic { + t.Errorf("Basic token not isolated: got %q", line[spans[0].StartCol:spans[0].EndCol]) + } +} + +func TestSecretSpans_ExtraHeaderQuotedNoQuoteCapture(t *testing.T) { + // A QUOTED extraHeader value: the token span must stop BEFORE the closing + // quote (security-review F3), so the quote is preserved outside the span. + line := "\textraHeader = \"Authorization: Bearer " + ghToken + "\"" + spans := SecretSpans("[http]\n" + line + "\n") + if len(spans) != 1 { + t.Fatalf("want 1 span, got %d: %+v", len(spans), spans) + } + if got := line[spans[0].StartCol:spans[0].EndCol]; got != ghToken { + t.Errorf("token span captured a trailing quote: got %q, want %q", got, ghToken) + } +} + +func TestSecretSpans_EnvRefLeftVerbatim(t *testing.T) { + // ${GIT_TOKEN} is an env-ref, not a literal secret — never flagged. + for _, line := range []string{ + "\tinsteadOf = https://${GIT_TOKEN}@github.com/", + "\textraHeader = Authorization: Bearer ${GIT_TOKEN}", + } { + if spans := SecretSpans("[x]\n" + line + "\n"); len(spans) != 0 { + t.Errorf("env-ref wrongly flagged as a secret in %q: %+v", line, spans) + } + } +} + +func TestSecretSpans_PlaceholderLeftVerbatim(t *testing.T) { + line := ` insteadOf = https://{{ferry.secret "git.tok"}}@github.com/` + if spans := SecretSpans("[x]\n" + line + "\n"); len(spans) != 0 { + t.Errorf("ferry placeholder wrongly flagged: %+v", spans) + } +} + +func TestSecretSpans_NoCredentialNoSpan(t *testing.T) { + // An ordinary URL with no embedded credential and no Bearer token: nothing. + text := "[remote \"origin\"]\n\turl = https://github.com/me/repo.git\n[core]\n\teditor = vim\n" + if spans := SecretSpans(text); len(spans) != 0 { + t.Errorf("plain config flagged a secret: %+v", spans) + } +} diff --git a/internal/iterm2profiles/doc.go b/internal/iterm2profiles/doc.go new file mode 100644 index 0000000..ec0cdf1 --- /dev/null +++ b/internal/iterm2profiles/doc.go @@ -0,0 +1,31 @@ +// Package iterm2profiles models iTerm2's Dynamic Profiles as a ferry FileDomain: +// the config repo's iterm2/DynamicProfiles/ tree of *.json files, deployed +// file-by-file to ~/Library/Application Support/iTerm2/DynamicProfiles/ as +// regular-file COPIES reconciled by hash (never symlinks — ferry's core +// invariant), exactly like the config-file terminal (termcfg) and Emacs domains. +// +// Dynamic Profiles are plain, live-reloaded JSON, so they are the reviewable, +// mergeable representation of iTerm2 profiles (the opaque global plist is a +// separate ResourceDomain). This domain is REPO-AUTHORITATIVE: you edit the JSON +// in the repo and `ferry apply` deploys it; there is no capture pass, so a live +// edit shows as drift and apply skips it (Captures() is false). "Rewritable": +// false in a carried profile documents the same rule to iTerm2 itself. +// +// Two invariants make this safe to carry: +// +// - FROZEN GUID. A profile's "Guid" is its identity; ferry never generates or +// rewrites one. There is no capture pass and apply copies bytes verbatim, so +// a GUID is byte-stable by construction — a changed GUID would orphan the +// deployed profile. +// - VALIDATED JSON. One malformed file disables ALL of iTerm2's dynamic +// profiles, so each file is validated (encoding/json validity on every +// platform, plus `plutil -convert xml1` on macOS — not `-lint`, which rejects +// valid JSON) BEFORE it is deployed; a file that +// fails is warned about and skipped, never deployed. +// +// Per-machine divergence rides the termcfg-style per-file overlay: a file at +// local/iterm2-profiles/ wins over the shared iterm2/DynamicProfiles/, +// and a file present ONLY under local/iterm2-profiles/ deploys as a machine-only +// profile (e.g. a child Dynamic Profile referencing a shared parent's GUID). ferry +// performs no JSON surgery — the overlay is purely file-level. +package iterm2profiles diff --git a/internal/iterm2profiles/lint.go b/internal/iterm2profiles/lint.go new file mode 100644 index 0000000..e414edc --- /dev/null +++ b/internal/iterm2profiles/lint.go @@ -0,0 +1,55 @@ +package iterm2profiles + +import ( + "errors" + "os/exec" + "strings" + + "github.com/REPPL/ferry/internal/platform" +) + +// ErrNotDarwin is returned by Linter.Lint on a non-macOS host: `plutil` ships only +// with macOS, so the property-list validation is skipped cleanly there (the pure-Go +// encoding/json validity check still runs on every platform). It mirrors +// terminal.ErrNotDarwin's and keybindings.ErrNotDarwin's clean-skip contract. +// +// NOTE (consolidation): this Linter/PlutilLinter pair mirrors the one in +// internal/keybindings, but validates DIFFERENTLY on purpose. `plutil -lint` +// guesses the OLD-STYLE (NeXTSTEP) parser for `{`-leading content and REJECTS valid +// JSON, so a Dynamic Profile is validated with `plutil -convert xml1 -o /dev/null` +// (which does parse JSON) instead. keybindings' dict is genuinely old-style and uses +// `-lint`. A third plutil consumer should hoist a shared helper parametrised by the +// check rather than copy either. +var ErrNotDarwin = errors.New("iterm2profiles: plutil validation is macOS only; skipped on this platform") + +// Linter is the injectable seam over the macOS `plutil` property-list validator. +// Production uses PlutilLinter (the real tool on macOS); tests pass a fake so the +// darwin-only path is exercised deterministically on any host. +type Linter interface { + // Lint validates the JSON property-list file at path. nil means well-formed; + // ErrNotDarwin means the check was skipped (non-macOS host); any other error is + // a validation failure carrying the tool's message. + Lint(path string) error +} + +// PlutilLinter validates a Dynamic Profile with the real `plutil`. It is +// darwin-only: on any other platform it returns ErrNotDarwin (a clean skip). +type PlutilLinter struct{} + +// Lint runs `plutil -convert xml1 -o /dev/null `: it parses the file through +// plutil's property-list reader (which accepts JSON) and discards the output, so a +// zero exit means the JSON is a well-formed property list and a non-zero exit +// surfaces plutil's own message. `-lint` is NOT used: it rejects valid JSON. +func (PlutilLinter) Lint(path string) error { + if !platform.IsDarwin() { + return ErrNotDarwin + } + out, err := exec.Command("plutil", "-convert", "xml1", "-o", "/dev/null", path).CombinedOutput() + if err != nil { + if msg := strings.TrimSpace(string(out)); msg != "" { + return errors.New(msg) + } + return err + } + return nil +} diff --git a/internal/iterm2profiles/plan.go b/internal/iterm2profiles/plan.go new file mode 100644 index 0000000..d873516 --- /dev/null +++ b/internal/iterm2profiles/plan.go @@ -0,0 +1,262 @@ +package iterm2profiles + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/REPPL/ferry/internal/dotfile" +) + +// RepoSubdir is the config-repo subdirectory holding the Dynamic Profiles source +// (a tree of *.json files). It sits beside the global-plist file the iTerm2 +// ResourceDomain reads (iterm2/com.googlecode.iterm2.plist). +var RepoSubdir = filepath.Join("iterm2", "DynamicProfiles") + +// LocalName is the per-machine overlay directory under the repo's gitignored +// local/ root: local/iterm2-profiles/ overrides (or, when present only there, +// adds) a Dynamic Profile for this machine — the natural home for a child profile +// referencing a shared parent's GUID. +const LocalName = "iterm2-profiles" + +// LocalSubdir is the gitignored per-machine overlay root inside the repo. +const LocalSubdir = "local" + +// TargetHome is the home-relative destination the profiles deploy to. It is inside +// $HOME, so dotfile.NestedTarget's containment guard passes it. +var TargetHome = filepath.Join("Library", "Application Support", "iTerm2", "DynamicProfiles") + +// KeyPrefix namespaces the domain's records in the shared last-applied store, so +// the de-scope pass can tell them apart from dotfiles/agents/terminals/emacs/ +// keybindings targets. +const KeyPrefix = "iterm2-profiles/" + +// Item is one (content, target) pair the domain deploys — one per carried *.json +// file. It mirrors emacs.Item/termcfg.Item so the command layer's per-target +// planning is identical. +type Item struct { + // Key is the stable last-applied store key ("iterm2-profiles/"). + Key string + // Label is the human-facing name reports print ("iterm2-profiles:"). + Label string + // Target is the validated $HOME destination (built via dotfile.NestedTarget, so + // ~/.ssh and $HOME-escapes are impossible). + Target dotfile.Target + // Content is the exact bytes to materialise (the per-machine overlay when one + // exists, else the shared repo source). GUIDs are byte-preserved: ferry never + // mutates the JSON. + Content []byte +} + +// PlanInput carries the planner's inputs. Guard validates a repo-side path before +// it is read (the caller passes its symlink-refusing repo guard) and returns the +// safe path; nil means no extra validation (tests). Linter validates JSON format on +// macOS; nil skips the plutil check (tests / non-macOS). +type PlanInput struct { + RepoRoot string + Home string + Guard func(candidate string) (string, error) + Linter Linter +} + +// Plan expands the Dynamic Profiles domain into its 1:1 (content, target) items: +// one per carried *.json file, destined for the same relative path under +// ~/Library/Application Support/iTerm2/DynamicProfiles/ and carried like a dotfile. +// Content is the per-machine overlay (local/iterm2-profiles/) when present — +// local wins — else the shared repo source; a file present ONLY under the local +// overlay deploys as a machine-only profile. Each file is validated (JSON validity +// on every platform, plus `plutil -lint` on macOS) before it is carried; a +// malformed file becomes a warning and is skipped, never deployed (one bad file +// disables ALL of iTerm2's dynamic profiles). It reads only the config repo, never +// $HOME. +func Plan(in PlanInput) (items []Item, warnings []string, err error) { + sources, warns, err := resolveSources(in) + warnings = append(warnings, warns...) + if err != nil { + return nil, warnings, err + } + + rels := make([]string, 0, len(sources)) + for rel := range sources { + rels = append(rels, rel) + } + sort.Strings(rels) // deterministic plan order + + for _, rel := range rels { + src := sources[rel] + safe, gerr := guardPath(in.Guard, src) + if gerr != nil { + warnings = append(warnings, refusal("source", filepath.ToSlash(rel), gerr)) + continue + } + data, rerr := os.ReadFile(safe) + if rerr != nil { + return nil, warnings, rerr + } + if w := validateJSON(data, in.Linter, safe, rel); w != "" { + warnings = append(warnings, w) + continue + } + key := KeyPrefix + filepath.ToSlash(rel) + t, terr := dotfile.NestedTarget(in.Home, filepath.Join(TargetHome, rel), key) + if terr != nil { + warnings = append(warnings, refusal("target", filepath.ToSlash(rel), terr)) + continue + } + items = append(items, Item{ + Key: key, + Label: "iterm2-profiles:" + filepath.ToSlash(rel), + Target: t, + Content: data, + }) + } + return items, warnings, nil +} + +// resolveSources returns the union of *.json profile relpaths to deploy, each +// mapped to its winning source path: the shared iterm2/DynamicProfiles/ unless +// a local/iterm2-profiles/ overlay exists (local wins), plus any file present +// ONLY under the local overlay (a machine-only profile). Symlinks in either tree +// are refused with a warning and skipped. +func resolveSources(in PlanInput) (map[string]string, []string, error) { + var warnings []string + sources := map[string]string{} + + sharedRoot := filepath.Join(in.RepoRoot, RepoSubdir) + localRoot := filepath.Join(in.RepoRoot, LocalSubdir, LocalName) + + // Shared tree: the authoritative set of profiles. + sw, err := walkJSON(in.Guard, sharedRoot, RepoSubdir) + if err != nil { + return nil, sw.warnings, err + } + warnings = append(warnings, sw.warnings...) + for rel := range sw.files { + sources[rel] = filepath.Join(sharedRoot, rel) + } + + // Local overlay: overrides a shared rel, or adds a machine-only profile. + lw, err := walkJSON(in.Guard, localRoot, filepath.Join(LocalSubdir, LocalName)) + if err != nil { + return nil, warnings, err + } + warnings = append(warnings, lw.warnings...) + for rel := range lw.files { + sources[rel] = filepath.Join(localRoot, rel) + } + return sources, warnings, nil +} + +type walkResult struct { + files map[string]bool + warnings []string +} + +// walkJSON enumerates the *.json regular files under root (relative paths, slash +// form), refusing symlinks. An absent root is normal (nothing to deploy). label is +// the repo-relative directory name used in warnings. +func walkJSON(guard func(string) (string, error), root, label string) (walkResult, error) { + res := walkResult{files: map[string]bool{}} + safeRoot, gerr := guardPath(guard, root) + if gerr != nil { + res.warnings = append(res.warnings, refusal("source", label, gerr)) + return res, nil + } + fi, serr := os.Lstat(safeRoot) + if serr != nil { + if errors.Is(serr, fs.ErrNotExist) { + return res, nil // absent tree: nothing to deploy + } + return res, serr + } + if fi.Mode()&fs.ModeSymlink != 0 { + res.warnings = append(res.warnings, fmt.Sprintf( + "iterm2-profiles: refusing %s: symlink not allowed in the managed repo tree (copy the real dir in)", label)) + return res, nil + } + if !fi.IsDir() { + return res, nil + } + walkErr := filepath.WalkDir(safeRoot, func(path string, d fs.DirEntry, werr error) error { + if werr != nil { + return werr + } + rel, rerr := filepath.Rel(safeRoot, path) + if rerr != nil { + return rerr + } + if rel == "." { + return nil + } + if d.Type()&fs.ModeSymlink != 0 { + res.warnings = append(res.warnings, fmt.Sprintf( + "iterm2-profiles: refusing %s: symlink not allowed in the managed repo tree (copy the real file in)", + filepath.ToSlash(filepath.Join(label, rel)))) + if d.IsDir() { + return fs.SkipDir + } + return nil + } + if d.IsDir() || !d.Type().IsRegular() { + return nil + } + if !strings.EqualFold(filepath.Ext(rel), ".json") { + return nil // only *.json files are Dynamic Profiles + } + res.files[rel] = true + return nil + }) + if walkErr != nil { + return res, walkErr + } + return res, nil +} + +// validateJSON enforces the domain's format hygiene, returning a user-facing +// warning when the file is not valid (empty means it passed). The pure-Go +// encoding/json validity check runs on EVERY platform (so a malformed profile is +// refused off macOS too); `plutil -lint` runs only on macOS (a nil or ErrNotDarwin +// lint result passes). +func validateJSON(data []byte, linter Linter, path, rel string) string { + if !json.Valid(data) { + return fmt.Sprintf( + "iterm2-profiles: refusing %s: not valid JSON — one malformed Dynamic Profile disables ALL of iTerm2's dynamic profiles; fix it, then re-run `ferry apply`", + filepath.ToSlash(rel)) + } + if linter != nil { + if err := linter.Lint(path); err != nil && !errors.Is(err, ErrNotDarwin) { + return fmt.Sprintf( + "iterm2-profiles: refusing %s: property-list lint failed (%v) — fix the JSON, then re-run `ferry apply`", + filepath.ToSlash(rel), err) + } + } + return "" +} + +// guardPath routes a repo-side candidate through the caller's guard (when +// provided), so every repo read in this package honours the same symlink-refusing +// policy as the rest of ferry. +func guardPath(guard func(string) (string, error), candidate string) (string, error) { + if guard == nil { + return candidate, nil + } + return guard(candidate) +} + +// refusal renders a clear, user-facing warning for a skipped target, mirroring the +// emacs, termcfg, and keybindings domains' refusal wording. +func refusal(what, name string, err error) string { + switch { + case errors.Is(err, dotfile.ErrForbiddenSSHPath): + return fmt.Sprintf("iterm2-profiles: refusing %s %s: ferry never manages paths under ~/.ssh", what, name) + case errors.Is(err, dotfile.ErrPathEscapesHome): + return fmt.Sprintf("iterm2-profiles: refusing %s %s: invalid managed path (escapes $HOME)", what, name) + default: + return fmt.Sprintf("iterm2-profiles: refusing %s %s: %v", what, name, err) + } +} diff --git a/internal/iterm2profiles/plan_test.go b/internal/iterm2profiles/plan_test.go new file mode 100644 index 0000000..7d39df8 --- /dev/null +++ b/internal/iterm2profiles/plan_test.go @@ -0,0 +1,171 @@ +package iterm2profiles + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// fakeLinter records the paths it was asked to lint and can be told to fail a +// specific base name, so the plutil-lint gate is exercised without shelling out. +type fakeLinter struct { + failBase string + linted []string +} + +func (f *fakeLinter) Lint(path string) error { + f.linted = append(f.linted, path) + if f.failBase != "" && filepath.Base(path) == f.failBase { + return errFakeLint + } + return nil +} + +var errFakeLint = &lintErr{"fake plutil failure"} + +type lintErr struct{ msg string } + +func (e *lintErr) Error() string { return e.msg } + +const workProfile = `{"Profiles":[{"Name":"Work","Guid":"WORK-GUID-1234","Rewritable":false}]}` + +func writeFile(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestPlanCarriesValidProfilePreservingGUID(t *testing.T) { + repo := t.TempDir() + home := t.TempDir() + writeFile(t, filepath.Join(repo, RepoSubdir, "Work.json"), workProfile) + + items, warns, err := Plan(PlanInput{RepoRoot: repo, Home: home}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if len(warns) != 0 { + t.Fatalf("unexpected warnings: %v", warns) + } + if len(items) != 1 { + t.Fatalf("got %d items, want 1", len(items)) + } + it := items[0] + if it.Key != KeyPrefix+"Work.json" { + t.Errorf("key = %q", it.Key) + } + if !strings.HasSuffix(it.Target.Home, filepath.Join(TargetHome, "Work.json")) { + t.Errorf("target home = %q, want under %s", it.Target.Home, TargetHome) + } + // GUID and the whole body are byte-preserved (ferry never rewrites the JSON). + if string(it.Content) != workProfile { + t.Errorf("content mutated:\n got: %s\nwant: %s", it.Content, workProfile) + } +} + +func TestPlanSkipsMalformedJSON(t *testing.T) { + repo := t.TempDir() + home := t.TempDir() + writeFile(t, filepath.Join(repo, RepoSubdir, "Good.json"), workProfile) + writeFile(t, filepath.Join(repo, RepoSubdir, "Bad.json"), `{ this is not json`) + + items, warns, err := Plan(PlanInput{RepoRoot: repo, Home: home}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if len(items) != 1 || items[0].Key != KeyPrefix+"Good.json" { + t.Fatalf("expected only Good.json to be carried, got %+v", items) + } + if !containsSubstr(warns, "Bad.json") || !containsSubstr(warns, "not valid JSON") { + t.Errorf("expected a refusal warning for Bad.json, got %v", warns) + } +} + +func TestPlanLocalOverlayWinsAndAddsMachineOnly(t *testing.T) { + repo := t.TempDir() + home := t.TempDir() + writeFile(t, filepath.Join(repo, RepoSubdir, "Work.json"), workProfile) + // Override Work.json for this machine. + local := `{"Profiles":[{"Name":"Work","Guid":"WORK-GUID-1234","Rewritable":false,"Normal Font":"Menlo 14"}]}` + writeFile(t, filepath.Join(repo, LocalSubdir, LocalName, "Work.json"), local) + // A machine-only child profile present ONLY in the local overlay. + child := `{"Profiles":[{"Name":"Work-laptop","Guid":"CHILD-1","Dynamic Profile Parent GUID":"WORK-GUID-1234"}]}` + writeFile(t, filepath.Join(repo, LocalSubdir, LocalName, "Work-laptop.json"), child) + + items, warns, err := Plan(PlanInput{RepoRoot: repo, Home: home}) + if err != nil || len(warns) != 0 { + t.Fatalf("Plan err=%v warns=%v", err, warns) + } + got := map[string]string{} + for _, it := range items { + got[it.Key] = string(it.Content) + } + if got[KeyPrefix+"Work.json"] != local { + t.Errorf("local overlay did not win for Work.json: %q", got[KeyPrefix+"Work.json"]) + } + if got[KeyPrefix+"Work-laptop.json"] != child { + t.Errorf("machine-only child profile not deployed: %q", got[KeyPrefix+"Work-laptop.json"]) + } +} + +func TestPlanPlutilLintGates(t *testing.T) { + repo := t.TempDir() + home := t.TempDir() + // Valid JSON but plutil rejects it (simulated). + writeFile(t, filepath.Join(repo, RepoSubdir, "Work.json"), workProfile) + fl := &fakeLinter{failBase: "Work.json"} + + items, warns, err := Plan(PlanInput{RepoRoot: repo, Home: home, Linter: fl}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if len(items) != 0 { + t.Fatalf("a plutil-rejected file was carried: %+v", items) + } + if !containsSubstr(warns, "lint failed") { + t.Errorf("expected a lint-failure warning, got %v", warns) + } + if len(fl.linted) == 0 { + t.Errorf("linter was never invoked") + } +} + +func TestPlanRefusesSymlinkedFile(t *testing.T) { + repo := t.TempDir() + home := t.TempDir() + dir := filepath.Join(repo, RepoSubdir) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + target := filepath.Join(repo, "outside.json") + if err := os.WriteFile(target, []byte(workProfile), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(dir, "Work.json")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + items, warns, err := Plan(PlanInput{RepoRoot: repo, Home: home}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if len(items) != 0 { + t.Fatalf("a symlinked profile was carried: %+v", items) + } + if !containsSubstr(warns, "symlink not allowed") { + t.Errorf("expected a symlink refusal, got %v", warns) + } +} + +func containsSubstr(ss []string, sub string) bool { + for _, s := range ss { + if strings.Contains(s, sub) { + return true + } + } + return false +} diff --git a/internal/keybindings/doc.go b/internal/keybindings/doc.go new file mode 100644 index 0000000..7581e4b --- /dev/null +++ b/internal/keybindings/doc.go @@ -0,0 +1,25 @@ +// Package keybindings carries the macOS Cocoa key-bindings file +// (~/Library/KeyBindings/DefaultKeyBinding.dict) across machines as a single +// repo-authoritative, whole-file target. +// +// The file is an old-style (NeXT/ASCII) property-list dict the Cocoa text +// system reads once at app launch; the OS never rewrites it, so a hash +// reconcile shows drift only when the user actually edits it. Modelled on +// internal/termcfg's file-tree pattern but degenerate to one fixed source -> +// one fixed nested target: keybindings/DefaultKeyBinding.dict in the config repo +// deploys to ~/Library/KeyBindings/DefaultKeyBinding.dict via +// dotfile.NestedTarget (inside $HOME, so it passes containment) and the standard +// dotfile.ApplyContentDeferred write path. +// +// The domain is repo-authoritative (Captures() == false, like termcfg): edit the +// config-repo copy and `apply` deploys it; a live edit shows as drift and apply +// skips it. There is no `.local` overlay — keyboard behaviour is machine-agnostic +// and the old-style dict format has no include/merge mechanism, so a whole-file +// swap would earn nothing. +// +// Format hygiene keeps the file the readable text form: a bplist00 binary-plist +// header is refused, a UTF-8 BOM is refused, non-UTF-8 bytes are refused, and on +// macOS the source is validated with `plutil -lint` before the copy lands. ferry +// never runs `plutil -convert` on it (convert would destroy the readable +// old-style diff). +package keybindings diff --git a/internal/keybindings/keybindings_test.go b/internal/keybindings/keybindings_test.go new file mode 100644 index 0000000..20f868a --- /dev/null +++ b/internal/keybindings/keybindings_test.go @@ -0,0 +1,195 @@ +package keybindings + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/REPPL/ferry/internal/dotfile" +) + +// validDict is a minimal readable old-style (NeXT/ASCII) key-bindings dict. +const validDict = "{\n \"~f\" = \"moveWordForward:\";\n}\n" + +// fakeLinter records whether Lint was called and returns a configurable result, +// so the darwin-only plutil path is exercised deterministically on any host. +type fakeLinter struct { + called bool + err error +} + +func (f *fakeLinter) Lint(string) error { + f.called = true + return f.err +} + +// writeSource writes body to /keybindings/DefaultKeyBinding.dict. +func writeSource(t *testing.T, repo string, body []byte) { + t.Helper() + dir := filepath.Join(repo, RepoSubdir) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, SourceName), body, 0o644); err != nil { + t.Fatalf("write source: %v", err) + } +} + +func TestPlan_SourceToTargetMapping(t *testing.T) { + repo := t.TempDir() + home := t.TempDir() + writeSource(t, repo, []byte(validDict)) + + items, warnings, err := Plan(PlanInput{RepoRoot: repo, Home: home}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + if len(items) != 1 { + t.Fatalf("got %d items, want 1", len(items)) + } + it := items[0] + if it.Key != "keybindings/DefaultKeyBinding.dict" { + t.Errorf("Key = %q", it.Key) + } + if it.Label != "keybindings" { + t.Errorf("Label = %q", it.Label) + } + wantHome := filepath.Join(home, "Library", "KeyBindings", "DefaultKeyBinding.dict") + if it.Target.Home != wantHome { + t.Errorf("Target.Home = %q, want %q", it.Target.Home, wantHome) + } + if it.Target.Overlay != dotfile.OverlayWholeFileReplace { + t.Errorf("Target.Overlay = %q, want whole-file-replace", it.Target.Overlay) + } + if string(it.Content) != validDict { + t.Errorf("Content = %q, want the source bytes", it.Content) + } +} + +func TestPlan_AbsentSourceDeploysNothing(t *testing.T) { + items, warnings, err := Plan(PlanInput{RepoRoot: t.TempDir(), Home: t.TempDir()}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if len(items) != 0 || len(warnings) != 0 { + t.Errorf("absent source: items=%v warnings=%v, want none", items, warnings) + } +} + +func TestPlan_RejectsBinaryPlist(t *testing.T) { + repo := t.TempDir() + // A binary plist an editor silently saved: bplist00 magic header. + writeSource(t, repo, append([]byte("bplist00"), 0x01, 0x02, 0x03)) + + items, warnings, err := Plan(PlanInput{RepoRoot: repo, Home: t.TempDir()}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if len(items) != 0 { + t.Fatalf("binary plist deployed %d items, want 0", len(items)) + } + if len(warnings) != 1 || !contains(warnings[0], "BINARY plist") { + t.Errorf("warnings = %v, want a bplist00 refusal", warnings) + } +} + +func TestPlan_RejectsUTF8BOM(t *testing.T) { + repo := t.TempDir() + writeSource(t, repo, append([]byte{0xEF, 0xBB, 0xBF}, []byte(validDict)...)) + + items, warnings, err := Plan(PlanInput{RepoRoot: repo, Home: t.TempDir()}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if len(items) != 0 { + t.Fatalf("BOM source deployed %d items, want 0", len(items)) + } + if len(warnings) != 1 || !contains(warnings[0], "byte-order mark") { + t.Errorf("warnings = %v, want a BOM refusal", warnings) + } +} + +func TestPlan_RejectsInvalidUTF8(t *testing.T) { + repo := t.TempDir() + writeSource(t, repo, []byte{0xff, 0xfe, 0x00}) + + items, warnings, err := Plan(PlanInput{RepoRoot: repo, Home: t.TempDir()}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if len(items) != 0 { + t.Fatalf("non-UTF-8 source deployed %d items, want 0", len(items)) + } + if len(warnings) != 1 || !contains(warnings[0], "valid UTF-8") { + t.Errorf("warnings = %v, want a UTF-8 refusal", warnings) + } +} + +func TestPlan_LintGatesTheCopy(t *testing.T) { + repo := t.TempDir() + writeSource(t, repo, []byte(validDict)) + + // A lint failure refuses the copy. + fl := &fakeLinter{err: errors.New("Malformed dict")} + items, warnings, err := Plan(PlanInput{RepoRoot: repo, Home: t.TempDir(), Linter: fl}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if !fl.called { + t.Error("linter was not called") + } + if len(items) != 0 || len(warnings) != 1 || !contains(warnings[0], "lint failed") { + t.Errorf("lint failure: items=%v warnings=%v, want a lint refusal", items, warnings) + } +} + +func TestPlan_LintSkippedOnNonDarwin(t *testing.T) { + repo := t.TempDir() + writeSource(t, repo, []byte(validDict)) + + // ErrNotDarwin means the lint was skipped (non-macOS host) — the copy proceeds. + fl := &fakeLinter{err: ErrNotDarwin} + items, warnings, err := Plan(PlanInput{RepoRoot: repo, Home: t.TempDir(), Linter: fl}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if len(warnings) != 0 { + t.Errorf("ErrNotDarwin lint produced warnings: %v", warnings) + } + if len(items) != 1 { + t.Errorf("ErrNotDarwin lint skipped deploy: got %d items, want 1", len(items)) + } +} + +func TestPlan_RefusesSymlinkedSource(t *testing.T) { + repo := t.TempDir() + dir := filepath.Join(repo, RepoSubdir) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + realFile := filepath.Join(repo, "elsewhere.dict") + if err := os.WriteFile(realFile, []byte(validDict), 0o644); err != nil { + t.Fatalf("write real: %v", err) + } + if err := os.Symlink(realFile, filepath.Join(dir, SourceName)); err != nil { + t.Fatalf("symlink: %v", err) + } + + items, warnings, err := Plan(PlanInput{RepoRoot: repo, Home: t.TempDir()}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if len(items) != 0 { + t.Fatalf("symlinked source deployed %d items, want 0", len(items)) + } + if len(warnings) != 1 || !contains(warnings[0], "symlink not allowed") { + t.Errorf("warnings = %v, want a symlink refusal", warnings) + } +} + +func contains(s, sub string) bool { return strings.Contains(s, sub) } diff --git a/internal/keybindings/lint.go b/internal/keybindings/lint.go new file mode 100644 index 0000000..86cb239 --- /dev/null +++ b/internal/keybindings/lint.go @@ -0,0 +1,49 @@ +package keybindings + +import ( + "errors" + "os/exec" + "strings" + + "github.com/REPPL/ferry/internal/platform" +) + +// ErrNotDarwin is returned by Linter.Lint on a non-macOS host: `plutil` ships +// only with macOS, so the property-list lint is skipped cleanly there (never an +// internal failure). Callers treat it as "lint not run on this platform" and +// proceed — the pure-Go hygiene checks (bplist00 header, UTF-8/BOM) still run on +// every platform. It mirrors terminal.ErrNotDarwin's clean-skip contract. +var ErrNotDarwin = errors.New("keybindings: plutil lint is macOS only; skipped on this platform") + +// Linter is the injectable seam over `plutil -lint`. Production uses +// PlutilLinter (the real tool on macOS); tests pass a fake that never shells out +// so the darwin-only path is exercised deterministically on any host. +type Linter interface { + // Lint validates the property-list file at path. A nil error means the file + // is a well-formed plist; ErrNotDarwin means the check was skipped (non-macOS + // host); any other error is a lint failure carrying the tool's message. + Lint(path string) error +} + +// PlutilLinter validates a property-list file with the real `plutil -lint`. It +// is darwin-only: on any other platform it returns ErrNotDarwin (a clean skip) +// rather than shelling out to a binary that does not exist there. It NEVER runs +// `plutil -convert` — convert would rewrite the readable old-style dict into +// xml1/binary1/json and destroy the reviewable diff. +type PlutilLinter struct{} + +// Lint runs `plutil -lint `. A zero exit is a valid plist (nil error); a +// non-zero exit surfaces plutil's own message so the user can fix the source. +func (PlutilLinter) Lint(path string) error { + if !platform.IsDarwin() { + return ErrNotDarwin + } + out, err := exec.Command("plutil", "-lint", path).CombinedOutput() + if err != nil { + if msg := strings.TrimSpace(string(out)); msg != "" { + return errors.New(msg) + } + return err + } + return nil +} diff --git a/internal/keybindings/plan.go b/internal/keybindings/plan.go new file mode 100644 index 0000000..6190f58 --- /dev/null +++ b/internal/keybindings/plan.go @@ -0,0 +1,159 @@ +package keybindings + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "unicode/utf8" + + "github.com/REPPL/ferry/internal/dotfile" +) + +// RepoSubdir is the config-repo subdirectory holding the key-bindings source. +const RepoSubdir = "keybindings" + +// SourceName is the single source file this domain carries, under RepoSubdir. +const SourceName = "DefaultKeyBinding.dict" + +// TargetRel is the home-relative destination the source deploys to. It is inside +// $HOME, so dotfile.NestedTarget's containment guard passes it. +const TargetRel = "Library/KeyBindings/DefaultKeyBinding.dict" + +// KeyPrefix namespaces the domain's records in the shared last-applied store, so +// the de-scope pass can tell them apart from dotfiles/agents/terminals targets. +const KeyPrefix = "keybindings/" + +// Key is the stable last-applied store key for the single carried file. +const Key = KeyPrefix + SourceName + +// Label is the human-facing domain name reports print. +const Label = "keybindings" + +// Item is the (content, target) pair the domain deploys. It mirrors +// termcfg.Item's shape so the command layer's per-target planning is identical, +// but the domain carries exactly one Item (a single fixed file) with no secret +// routing and no per-machine overlay. +type Item struct { + // Key is the stable last-applied store key ("keybindings/DefaultKeyBinding.dict"). + Key string + // Label is the name reports print ("keybindings"). + Label string + // Target is the validated $HOME destination (built via dotfile.NestedTarget, + // so ~/.ssh and $HOME-escapes are impossible). + Target dotfile.Target + // Content is the exact bytes to materialise (the repo source; there is no + // overlay and no secret rendering for this domain). + Content []byte +} + +// PlanInput carries the planner's inputs. Guard validates a repo-side path +// before it is read (the caller passes its symlink-refusing repo guard) and +// returns the safe path; nil means no extra validation (tests). Linter validates +// the plist format on macOS; nil skips the plutil check (tests / already +// validated). +type PlanInput struct { + RepoRoot string + Home string + Guard func(candidate string) (string, error) + Linter Linter +} + +// Plan expands the key-bindings domain into its at-most-one (content, target) +// item. An absent source deploys nothing (the domain is enabled but the file was +// never committed). A symlinked or non-regular source is refused. The source is +// validated for format hygiene — a binary plist (bplist00), a UTF-8 BOM, +// non-UTF-8 bytes, or (on macOS) a `plutil -lint` failure — before it is carried; +// a hygiene failure becomes a warning and the item is skipped, never an abort. It +// reads only the config repo, never $HOME. +func Plan(in PlanInput) (items []Item, warnings []string, err error) { + src := filepath.Join(in.RepoRoot, RepoSubdir, SourceName) + rel := filepath.Join(RepoSubdir, SourceName) + + safe, gerr := guardPath(in.Guard, src) + if gerr != nil { + return nil, []string{refusal("source", rel, gerr)}, nil + } + fi, serr := os.Lstat(safe) + if serr != nil { + // An absent (or unreadable-as-absent) source deploys nothing. + if errors.Is(serr, fs.ErrNotExist) { + return nil, nil, nil + } + return nil, nil, serr + } + if fi.Mode()&fs.ModeSymlink != 0 { + return nil, []string{fmt.Sprintf( + "keybindings: refusing %s: symlink not allowed in the managed repo tree (copy the real file in)", rel)}, nil + } + if !fi.Mode().IsRegular() { + return nil, []string{fmt.Sprintf( + "keybindings: refusing %s: not a regular file", rel)}, nil + } + + data, rerr := os.ReadFile(safe) + if rerr != nil { + return nil, nil, rerr + } + if w := validateFormat(data, in.Linter, safe); w != "" { + return nil, []string{w}, nil + } + + t, terr := dotfile.NestedTarget(in.Home, TargetRel, Key) + if terr != nil { + return nil, []string{refusal("target", Label, terr)}, nil + } + return []Item{{Key: Key, Label: Label, Target: t, Content: data}}, nil, nil +} + +// validateFormat enforces the domain's format hygiene, returning a user-facing +// warning when the source is not the readable old-style/UTF-8 dict ferry carries +// (empty means the file passed every check). The pure-Go checks (bplist00 magic, +// UTF-8 BOM, valid UTF-8) run on EVERY platform; the `plutil -lint` check runs +// only on macOS (a nil or ErrNotDarwin lint result passes). +func validateFormat(data []byte, linter Linter, path string) string { + if bytes.HasPrefix(data, []byte("bplist00")) { + return fmt.Sprintf( + "keybindings: refusing %s: file is a BINARY plist (bplist00 header) — ferry carries only the readable old-style/UTF-8 dict; re-save it as text (never `plutil -convert binary1`)", SourceName) + } + if bytes.HasPrefix(data, []byte{0xEF, 0xBB, 0xBF}) { + return fmt.Sprintf( + "keybindings: refusing %s: file has a UTF-8 byte-order mark (BOM) — save it as plain UTF-8 with no BOM", SourceName) + } + if !utf8.Valid(data) { + return fmt.Sprintf( + "keybindings: refusing %s: file is not valid UTF-8 — the key-bindings dict must be a UTF-8 text property list", SourceName) + } + if linter != nil { + if err := linter.Lint(path); err != nil && !errors.Is(err, ErrNotDarwin) { + return fmt.Sprintf( + "keybindings: refusing %s: property-list lint failed (%v) — fix the dict, then re-run `ferry apply`", SourceName, err) + } + } + return "" +} + +// guardPath routes a repo-side candidate through the caller's guard (when +// provided), so every repo read in this package honours the same +// symlink-refusing policy as the rest of ferry. +func guardPath(guard func(string) (string, error), candidate string) (string, error) { + if guard == nil { + return candidate, nil + } + return guard(candidate) +} + +// refusal renders a clear, user-facing warning for a skipped key-bindings +// target, mirroring the dotfile and termcfg domains' refusal wording. +func refusal(what, name string, err error) string { + switch { + case errors.Is(err, dotfile.ErrForbiddenSSHPath): + return fmt.Sprintf("keybindings: refusing %s %s: ferry never manages paths under ~/.ssh", what, name) + case errors.Is(err, dotfile.ErrPathEscapesHome): + return fmt.Sprintf("keybindings: refusing %s %s: invalid managed path (escapes $HOME)", what, name) + default: + return fmt.Sprintf("keybindings: refusing %s %s: %v", what, name, err) + } +} diff --git a/internal/platform/platform.go b/internal/platform/platform.go index 71eeb25..ec25400 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -61,6 +61,18 @@ func HasApt() bool { return false } +// HasNpm reports whether an npm executable is resolvable on the current PATH. +// npm globals are ORTHOGONAL to the OS package manager: a machine can run +// Homebrew (or apt) AND npm at the same time, so npm is DETECTED INDEPENDENTLY +// here rather than through DetectPackageManager (which returns a single, +// brew-preferred manager and would let a brew machine skip npm entirely). +// PATH-gated for the same reason as HasBrew: detection stays consistent with how +// npm is actually invoked. +func HasNpm() bool { + _, err := exec.LookPath("npm") + return err == nil +} + // BrewPrefix returns the Homebrew installation prefix. It anchors to the // PATH-resolved brew (the resolved path's parent's parent, i.e. /bin/brew // → ) so the prefix matches the brew that would actually run, falling diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 73bfb11..e0952ce 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -45,9 +45,6 @@ type Plugin interface { // Describe renders a one-line human explanation of a block for the adopt UI. Describe(b Block) string - - // Deploy reports which EXISTING dotfile deploy mode the domain uses. - Deploy() DeploySpec } // Reassemble concatenates the blocks' Raw bytes. Because blocks partition their @@ -241,20 +238,6 @@ type Question struct { Default string } -// OverlayKind maps 1:1 onto the EXISTING dotfile deploy modes. -type OverlayKind int - -const ( - Sidecar OverlayKind = iota - WholeFile -) - -// DeploySpec reports the existing dotfile deploy mode the domain uses. -type DeploySpec struct { - DotfileName string // e.g. ".zshrc" - Overlay OverlayKind -} - // Registry maps domain -> Plugin. Duplicate registration panics (programmer // error at init); an unknown domain errors. type Registry struct { diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index 31c8ca8..aea7209 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -18,7 +18,6 @@ func (s stubPlugin) ApplyRepairs(b []Block, a []Finding) ([]Block, error) { func (s stubPlugin) StarterQuestions() []Question { return nil } func (s stubPlugin) Starter(a Answers) ([]byte, error) { return nil, nil } func (s stubPlugin) Describe(b Block) string { return "" } -func (s stubPlugin) Deploy() DeploySpec { return DeploySpec{} } // AC-plugin-registry: the registry resolves a registered domain. func TestRegistryResolve(t *testing.T) { diff --git a/internal/plugin/zsh/zsh.go b/internal/plugin/zsh/zsh.go index e059d72..3d92cfe 100644 --- a/internal/plugin/zsh/zsh.go +++ b/internal/plugin/zsh/zsh.go @@ -89,10 +89,3 @@ func (p *Plugin) Describe(b plugin.Block) string { } return fmt.Sprintf("%s: %s", b.Kind, first) } - -// Deploy: zsh uses the existing include-sidecar deploy (shared dotfiles/zshrc -// deployed whole; per-machine local/zsh/zshrc.local materialised as -// ~/.zshrc.local, sourced last). -func (p *Plugin) Deploy() plugin.DeploySpec { - return plugin.DeploySpec{DotfileName: ".zshrc", Overlay: plugin.Sidecar} -} diff --git a/internal/secret/scan.go b/internal/secret/scan.go index 4191410..728e427 100644 --- a/internal/secret/scan.go +++ b/internal/secret/scan.go @@ -169,6 +169,24 @@ var ( // it must never block on its own (F3, adversarial A-1). hexSecret = regexp.MustCompile(`^[0-9a-fA-F]{32,}$`) + // npmAuthLine matches an npm registry auth assignment whose value is a + // literal credential — the `~/.npmrc` token shapes: a registry-scoped + // `///[path]:_authToken=`, `:_auth=`, or `:_password=` line. These + // bypass secretAssignment (the credential keyword is NOT at line-start — it + // sits after `///:`) and are too short/low-entropy to trip the entropy + // heuristic (a base64 `_auth`, a UUID legacy `_authToken`), so they need their + // own detector or a real npm token would leak to the shared repo. Only the + // captured VALUE (group 1) gates the finding, via the shared + // placeholder/interpolation rule (isNonPlaceholderSecret) and — like the + // URL-userinfo path — WITHOUT a length floor: the `:_authToken=` structure is + // itself the signal that the value is a credential, so a short legacy token + // still blocks. A `${NPM_TOKEN}` env-ref (npm expands it at read time) or a + // ferry placeholder is therefore left un-flagged and carried verbatim. The + // non-secret registry keys (:username, :email, :certfile, :keyfile) and plain + // config lines (registry=, save-exact=true) are NOT matched. Case is + // insensitive so `_authToken`/`_authtoken` both match. + npmAuthLine = regexp.MustCompile(`(?i)^\s*//\S*:_(?:authtoken|auth|password)\s*=\s*(.+)$`) + // urlCredential matches a password embedded in a URL's userinfo, i.e. // `scheme://[user][:pass]@host` (DATABASE_URL=postgres://user:pass@host, // REDIS_URL=redis://:pw@host). These bypass secretAssignment (the key name is @@ -419,6 +437,24 @@ func scanLine(text string, lineNo int) Findings { } } + // npm registry auth line (///:_authToken= / :_auth= / :_password=). The + // credential keyword is not at line-start, so secretAssignment misses it; the + // value is often a short base64/UUID token below the entropy floor, so the + // entropy path misses it too. Only the captured value gates the finding, via + // the shared placeholder/interpolation rule, so a `${NPM_TOKEN}` env-ref is + // left un-flagged and carried verbatim. The detail never echoes the token. + if m := npmAuthLine.FindStringSubmatch(text); m != nil { + if isNonPlaceholderSecret(strings.TrimSpace(m[1])) { + out = append(out, Finding{ + Rule: "npm-auth-token", + Confidence: High, + Line: lineNo, + Detail: "npm registry auth token", + }) + return out + } + } + // Keyword-gated hex (F3): a long pure-hex value is a secret ONLY when the // line's KEY names a credential. Bare 32+ hex is deliberately NOT flagged // (git SHA / MD5 / stripped UUID); the entropy path also skips pure hex, so diff --git a/internal/secret/scan_test.go b/internal/secret/scan_test.go index 014ce53..570bac4 100644 --- a/internal/secret/scan_test.go +++ b/internal/secret/scan_test.go @@ -182,6 +182,62 @@ func TestScanText_URLUserinfoCredential(t *testing.T) { } } +// TestScanText_NpmAuthToken covers the npm `~/.npmrc` auth token shapes: a +// registry-scoped `///:_authToken=`, `:_auth=`, or `:_password=` line +// whose value is a literal credential must block, even when the value is a short +// base64/UUID token below the entropy floor and the credential keyword is not at +// line-start (so secretAssignment and the entropy heuristic both miss it). +func TestScanText_NpmAuthToken(t *testing.T) { + block := []string{ + // Automation token (npm_ prefix). + `//registry.npmjs.org/:_authToken=npm_FAKE0000aaaa1111bbbb2222cccc3333`, + // Low-entropy authToken value — would slip the entropy path; caught by the + // line shape, not the value format. Deliberately not a real npm token + // format (no `npm_` prefix, not a UUID) so provider secret scanners do not + // flag this fixture, yet it carries no placeholder marker so ferry still + // blocks it. + `//registry.npmjs.org/:_authToken=faketoken0000000000000000legacy`, + // Basic-auth base64 blob (_auth) — short, misses the entropy floor. + `//registry.npmjs.org/:_auth=aGVsbG86d29ybGRzZWNyZXQ=`, + // Legacy password (base64). + `//registry.npmjs.org/:_password=c2VjcmV0cGFzc3dvcmQxMjM=`, + // A scoped/private registry with a path prefix. + `//npm.pkg.github.com/:_authToken=ghp_FAKE0000aaaa1111bbbb2222`, + // Mixed case on the key. + `//registry.npmjs.org/:_authtoken=npm_FAKE9999zzzz8888yyyy7777dddd`, + } + for _, c := range block { + fs := ScanText(c) + if !fs.HasHigh() { + t.Errorf("expected high finding for npm auth line %q, got %+v", c, fs) + } + } + // The token must never appear in the finding detail. + for _, f := range ScanText(`//registry.npmjs.org/:_authToken=npm_FAKE0000aaaa1111bbbb2222cccc3333`) { + if strings.Contains(f.Detail, "npm_FAKE") { + t.Errorf("finding detail leaked the npm token: %q", f.Detail) + } + } + // Negatives: an env-ref (npm expands it at read time), a ferry placeholder, + // and ordinary non-secret npmrc lines must NOT be flagged. + pass := []string{ + `//registry.npmjs.org/:_authToken=${NPM_TOKEN}`, // env-ref, carried verbatim + `//registry.npmjs.org/:_authToken={{ferry.secret "npm.tok"}}`, // ferry placeholder + `//registry.npmjs.org/:_authToken=`, // empty value + `//registry.npmjs.org/:username=alex`, // username is not a secret + `//registry.npmjs.org/:email=alex@example.com`, // email is not a secret + `registry=https://registry.npmjs.org/`, // plain registry line + `save-exact=true`, // ordinary config flag + `@myscope:registry=https://npm.pkg.github.com/`, // scoped registry mapping + } + for _, c := range pass { + fs := ScanText(c) + if fs.HasHigh() { + t.Errorf("FALSE POSITIVE: npmrc non-secret line %q flagged: %+v", c, fs) + } + } +} + func TestScanText_LineNumbers(t *testing.T) { content := "line one\nline two\npassword = realSecretValue99\nline four" fs := ScanText(content) diff --git a/internal/secret/spans.go b/internal/secret/spans.go index a4ab407..cdce873 100644 --- a/internal/secret/spans.go +++ b/internal/secret/spans.go @@ -5,19 +5,38 @@ import ( "strings" ) -// SecretSpan is one line-grained flagged secret region of a text, VALUE-BEARING -// (unlike Finding, which never carries the value). StartLine/EndLine are -// 1-based and inclusive; Value is exactly the span's lines joined by \n. +// SecretSpan is one flagged secret region of a text, VALUE-BEARING (unlike +// Finding, which never carries the value). StartLine/EndLine are 1-based and +// inclusive; Value is exactly the span's lines joined by \n. // // FlaggedSpans is the generic, domain-free extraction shape capture's // placeholder-aware blocked path uses (Codex r7-M2): each span's Value is // stored under a positional ref and ONLY that span is patched to a placeholder. +// +// StartCol/EndCol OPTIONALLY narrow the span to a byte range WITHIN a single +// line — a half-open [StartCol, EndCol) byte offset (0-based) into that line — +// for secrets that live inside a substring rather than as a whole line or a +// post-'=' RHS (a token inside a URL, after "Bearer "/"Basic ", or between +// quotes). They are only meaningful when StartLine == EndLine. The zero value +// (both 0, an empty range) is the WHOLE-LINE sentinel: HasColumns reports false +// and every existing line-grained producer/consumer — which leaves these fields +// at their zero value — is byte-for-byte unaffected. A real interior span always +// has EndCol > StartCol, so HasColumns cleanly distinguishes the two. Offsets are +// BYTE offsets, not rune indices (see SwapColumns). type SecretSpan struct { StartLine int EndLine int Value string + StartCol int + EndCol int } +// HasColumns reports whether the span carries a byte-offset column range within +// its line (EndCol > StartCol). When false the span is whole-line-grained and the +// column fields MUST be ignored; this is what keeps every existing SecretSpan +// (which leaves StartCol/EndCol at zero) behaving exactly as before. +func (s SecretSpan) HasColumns() bool { return s.EndCol > s.StartCol } + // pemEndLine matches a PEM END line, closing a BEGIN..END private-key run. var pemEndLine = regexp.MustCompile(`(?i)END(?:\s+[A-Z0-9]+)*\s+PRIVATE\s+KEY`) diff --git a/internal/secret/subvalue.go b/internal/secret/subvalue.go new file mode 100644 index 0000000..73014ca --- /dev/null +++ b/internal/secret/subvalue.go @@ -0,0 +1,52 @@ +package secret + +import "fmt" + +// SwapColumns replaces the byte range [startCol, endCol) of line with +// replacement, preserving every byte OUTSIDE that range — the prefix +// line[:startCol] and the suffix line[endCol:] — exactly. It is the +// arbitrary-interior-offset counterpart of the zsh plugin's assignment-only swap +// (which rewrites only the post-'=' RHS, keeping body[:eq+1] + replacement + +// term): a git recognizer passes the byte range of a token inside a URL or after +// "Bearer "/"Basic "; a tmux recognizer passes the range between the quotes. Every +// surrounding byte — the URL scheme and host, the quotes, the "Bearer " prefix — is +// copied through untouched. +// +// Offsets are BYTE offsets into line, not rune indices, so multibyte/UTF-8 bytes on +// either side are preserved verbatim. Slicing line at byte boundaries is what makes +// the surrounding syntax byte-stable; the caller chooses a range on UTF-8 boundaries +// if it needs the replaced substring itself to be valid UTF-8, but the prefix and +// suffix are preserved regardless. line is expected to be a single line: a newline is +// treated as an ordinary byte and never given special handling. +// +// SwapColumns is its own inverse. For any valid range, +// +// SwapColumns(SwapColumns(line, s, e, r), s, s+len(r), line[s:e]) == line +// +// byte-for-byte. That is the reverse-render path a plugin uses to restore the +// original bytes from a committed placeholder: locate the placeholder's byte range +// in the committed line and SwapColumns it back to the value read from the store. +// +// Out-of-range or inverted offsets return an error and NEVER panic or slice out of +// bounds: the contract is 0 <= startCol <= endCol <= len(line). +func SwapColumns(line string, startCol, endCol int, replacement string) (string, error) { + if startCol < 0 || endCol < startCol || endCol > len(line) { + return "", fmt.Errorf( + "secret: column range [%d,%d) out of bounds for line of %d bytes", + startCol, endCol, len(line)) + } + return line[:startCol] + replacement + line[endCol:], nil +} + +// IsNonPlaceholderSecret reports whether val is a real, literal secret rather than +// an empty / interpolated / placeholder one. It is the EXPORTED form of the shared +// credential-value gate the scanner uses internally (isNonPlaceholderSecret): a +// sub-value recognizer (a git insteadOf/http.extraHeader token, a tmux quoted option +// value) calls it on the EXACT substring it extracted so the SAME +// ${...} / $... / {{...}} / placeholder-word exemption applies — an env-ref value +// like ${NPM_TOKEN} or a ferry placeholder is NOT a secret and must be left in the +// repo verbatim, never stored. Reusing this one predicate here (instead of +// re-deriving the rule inside each plugin) keeps the exemption from forking. +func IsNonPlaceholderSecret(val string) bool { + return isNonPlaceholderSecret(val) +} diff --git a/internal/secret/subvalue_test.go b/internal/secret/subvalue_test.go new file mode 100644 index 0000000..82d6d36 --- /dev/null +++ b/internal/secret/subvalue_test.go @@ -0,0 +1,264 @@ +package secret + +import ( + "strings" + "testing" +) + +// tokenSwap finds tok inside line, swaps ONLY that byte range for the placeholder, +// and returns the result plus the byte offsets it used. It fails the test if tok is +// absent or ambiguous — the realistic fixtures each contain the token exactly once. +func tokenSwap(t *testing.T, line, tok, ref string) (out string, start, end int) { + t.Helper() + start = strings.Index(line, tok) + if start < 0 { + t.Fatalf("token %q not found in line %q", tok, line) + } + if strings.Index(line[start+1:], tok) >= 0 { + t.Fatalf("token %q appears more than once in line %q", tok, line) + } + end = start + len(tok) + got, err := SwapColumns(line, start, end, Placeholder(ref)) + if err != nil { + t.Fatalf("SwapColumns(%q, %d, %d): %v", line, start, end, err) + } + return got, start, end +} + +// assertSurroundingPreserved checks that ONLY the [start,end) range of line changed: +// the prefix and suffix bytes are byte-identical, and out is exactly +// prefix + placeholder + suffix. It also proves the swap is invertible: swapping the +// placeholder's range back to the original substring reproduces line byte-for-byte. +func assertSurroundingPreserved(t *testing.T, line, out string, start, end int, ref string) { + t.Helper() + ph := Placeholder(ref) + want := line[:start] + ph + line[end:] + if out != want { + t.Fatalf("swap result mismatch\n got: %q\nwant: %q", out, want) + } + if out[:start] != line[:start] { + t.Errorf("prefix changed: got %q want %q", out[:start], line[:start]) + } + if out[start+len(ph):] != line[end:] { + t.Errorf("suffix changed: got %q want %q", out[start+len(ph):], line[end:]) + } + // Inverse: swap the placeholder's byte range back to the original substring. + back, err := SwapColumns(out, start, start+len(ph), line[start:end]) + if err != nil { + t.Fatalf("inverse SwapColumns: %v", err) + } + if back != line { + t.Fatalf("round-trip did not restore original\n got: %q\nwant: %q", back, line) + } +} + +// TestSwapColumns_GitInsteadOfURL: a git url..insteadOf section header carries +// the token INSIDE the URL (between "https://" and "@github.com/"). Only the token +// must change; the scheme, host, path and the surrounding [url "..."] syntax stay. +func TestSwapColumns_GitInsteadOfURL(t *testing.T) { + tok := "ghp_16C7e42F292c6912E7710c838347Ae178B4a01" + line := `[url "https://` + tok + `@github.com/"]` + out, start, end := tokenSwap(t, line, tok, "git.url_token") + assertSurroundingPreserved(t, line, out, start, end, "git.url_token") + if !strings.HasPrefix(out, `[url "https://`) || !strings.HasSuffix(out, `@github.com/"]`) { + t.Errorf("URL scaffolding not preserved: %q", out) + } +} + +// TestSwapColumns_HTTPExtraHeaderBearer: an http.extraHeader value carries the token +// after "Authorization: Bearer ". Only the token after "Bearer " must change. +func TestSwapColumns_HTTPExtraHeaderBearer(t *testing.T) { + tok := "ghp_16C7e42F292c6912E7710c838347Ae178B4a01" + line := "\textraHeader = Authorization: Bearer " + tok + out, start, end := tokenSwap(t, line, tok, "git.extra_header") + assertSurroundingPreserved(t, line, out, start, end, "git.extra_header") + if !strings.Contains(out, "Authorization: Bearer ") { + t.Errorf(`"Bearer " prefix not preserved: %q`, out) + } + if !strings.HasPrefix(out, "\textraHeader = ") { + t.Errorf("leading tab / key not preserved: %q", out) + } +} + +// TestSwapColumns_TmuxQuotedOption: a tmux `set -g @token 'SECRET'` carries the value +// BETWEEN single quotes. Only the value inside the quotes must change; the quotes and +// the `set -g @token ` prefix stay. +func TestSwapColumns_TmuxQuotedOption(t *testing.T) { + tok := "sk-AbC123secretVALUE456789xyz" + line := "set -g @token '" + tok + "'" + out, start, end := tokenSwap(t, line, tok, "tmux.token") + assertSurroundingPreserved(t, line, out, start, end, "tmux.token") + if !strings.HasPrefix(out, "set -g @token '") || !strings.HasSuffix(out, "'") { + t.Errorf("quotes/prefix not preserved: %q", out) + } +} + +// TestSwapColumns_UTF8Surroundings: multibyte runes on BOTH sides of the swapped +// range must be preserved byte-for-byte. Offsets are byte offsets, so slicing lands +// between whole runes here and the é / → / ✓ bytes survive intact. +func TestSwapColumns_UTF8Surroundings(t *testing.T) { + tok := "sk-XYZ123opaqueTOKENvalue999" + line := "# café → set @tok '" + tok + "' ✓ done" + out, start, end := tokenSwap(t, line, tok, "tmux.token") + assertSurroundingPreserved(t, line, out, start, end, "tmux.token") + for _, want := range []string{"café", "→", "✓ done"} { + if !strings.Contains(out, want) { + t.Errorf("multibyte context %q lost: %q", want, out) + } + } +} + +// TestSwapColumns_OutOfRange: bad offsets return an error and never panic. +func TestSwapColumns_OutOfRange(t *testing.T) { + const line = "set -g @token 'x'" + cases := []struct { + name string + start, end int + }{ + {"negative start", -1, 3}, + {"end before start", 5, 2}, + {"end past len", 0, len(line) + 1}, + {"start past len", len(line) + 1, len(line) + 2}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out, err := SwapColumns(line, c.start, c.end, "REPL") + if err == nil { + t.Fatalf("want error for [%d,%d), got out=%q", c.start, c.end, out) + } + if out != "" { + t.Errorf("error path should return empty string, got %q", out) + } + }) + } +} + +// TestSwapColumns_EmptyAndFullRange: the boundary ranges are valid, not errors. +func TestSwapColumns_EmptyAndFullRange(t *testing.T) { + const line = "abcdef" + // Empty range at start: pure insertion. + if out, err := SwapColumns(line, 0, 0, "XY"); err != nil || out != "XYabcdef" { + t.Fatalf("insert-at-start: out=%q err=%v", out, err) + } + // Empty range at end: pure append. + if out, err := SwapColumns(line, len(line), len(line), "XY"); err != nil || out != "abcdefXY" { + t.Fatalf("insert-at-end: out=%q err=%v", out, err) + } + // Whole-line range: replace everything. + if out, err := SwapColumns(line, 0, len(line), "XY"); err != nil || out != "XY" { + t.Fatalf("replace-all: out=%q err=%v", out, err) + } +} + +// TestSecretSpan_HasColumns: the zero-value column fields are the whole-line sentinel +// (HasColumns false); a real interior range reports true. This is the backward-compat +// contract in miniature. +func TestSecretSpan_HasColumns(t *testing.T) { + whole := SecretSpan{StartLine: 2, EndLine: 4, Value: "x"} + if whole.HasColumns() { + t.Error("whole-line span (zero cols) must report HasColumns()==false") + } + interior := SecretSpan{StartLine: 1, EndLine: 1, Value: "tok", StartCol: 8, EndCol: 40} + if !interior.HasColumns() { + t.Error("interior span must report HasColumns()==true") + } + // An empty interior range (StartCol==EndCol) is NOT a column span. + if (SecretSpan{StartCol: 5, EndCol: 5}).HasColumns() { + t.Error("empty range must report HasColumns()==false") + } +} + +// TestFlaggedSpans_BackwardCompatNoColumns: every span the existing line-grained +// extractor produces leaves the column fields at zero (HasColumns false), so no +// existing consumer sees interior offsets. This locks the backward-compat guarantee. +func TestFlaggedSpans_BackwardCompatNoColumns(t *testing.T) { + text := "export DB_PASSWORD=supersecretvalue123\n" + + "-----BEGIN OPENSSH PRIVATE KEY-----\n" + + "bodyLine\n" + + "-----END OPENSSH PRIVATE KEY-----\n" + spans := FlaggedSpans(text) + if len(spans) == 0 { + t.Fatal("expected flagged spans") + } + for i, s := range spans { + if s.HasColumns() || s.StartCol != 0 || s.EndCol != 0 { + t.Errorf("span %d carried unexpected columns: %+v", i, s) + } + } +} + +// TestIsNonPlaceholderSecret_Exported proves the exported gate keeps the ${...} +// exemption: an env-ref sub-value is NOT a secret, a literal token IS. This is the +// predicate a sub-value recognizer applies to the substring it extracted. +func TestIsNonPlaceholderSecret_Exported(t *testing.T) { + notSecret := []string{"", "${NPM_TOKEN}", "$TOKEN", `{{ferry.secret "x.y"}}`, "changeme", "your_token"} + for _, v := range notSecret { + if IsNonPlaceholderSecret(v) { + t.Errorf("IsNonPlaceholderSecret(%q) = true, want false (exempt)", v) + } + } + secrets := []string{"ghp_16C7e42F292c6912E7710c838347Ae178B4a01", "sk-liveRealTokenValue123"} + for _, v := range secrets { + if !IsNonPlaceholderSecret(v) { + t.Errorf("IsNonPlaceholderSecret(%q) = false, want true (literal secret)", v) + } + } +} + +// normOffset maps an arbitrary int into a valid byte offset [0,n] for a line of n +// bytes, so the fuzz body always exercises SwapColumns' success path (out-of-range is +// covered separately). It handles negatives and MinInt without overflow. +func normOffset(v, n int) int { + m := n + 1 + v %= m + if v < 0 { + v += m + } + return v +} + +// FuzzSwapColumns: for random lines, random VALID [start,end) ranges and random +// replacements, the swap (a) never panics, (b) preserves the prefix and suffix bytes +// exactly, and (c) is invertible back to the original line. Seeded with the three +// realistic fixtures (git URL, http.extraHeader Bearer, tmux quoted option). +func FuzzSwapColumns(f *testing.F) { + f.Add(`[url "https://ghp_TOKEN@github.com/"]`, 14, 23, `{{ferry.secret "git.url_token"}}`) + f.Add("\textraHeader = Authorization: Bearer ghp_TOKEN", 37, 46, `{{ferry.secret "git.extra_header"}}`) + f.Add("set -g @token 'SECRETvalue'", 15, 26, `{{ferry.secret "tmux.token"}}`) + f.Add("# café → 'sk-XYZ' ✓", 11, 17, "R") + + f.Fuzz(func(t *testing.T, line string, a, b int, repl string) { + n := len(line) + start := normOffset(a, n) + end := normOffset(b, n) + if start > end { + start, end = end, start + } + + out, err := SwapColumns(line, start, end, repl) + if err != nil { + t.Fatalf("valid range [%d,%d) on %d-byte line errored: %v", start, end, n, err) + } + + // (b) prefix and suffix bytes byte-preserved. + if out[:start] != line[:start] { + t.Fatalf("prefix mutated: got %q want %q", out[:start], line[:start]) + } + suffixStart := start + len(repl) + if out[suffixStart:] != line[end:] { + t.Fatalf("suffix mutated: got %q want %q", out[suffixStart:], line[end:]) + } + if out != line[:start]+repl+line[end:] { + t.Fatalf("unexpected splice: got %q", out) + } + + // (c) invertible back to the original line. + back, err := SwapColumns(out, start, suffixStart, line[start:end]) + if err != nil { + t.Fatalf("inverse errored: %v", err) + } + if back != line { + t.Fatalf("inverse did not restore original\n got: %q\nwant: %q", back, line) + } + }) +} diff --git a/internal/termcfg/apply.go b/internal/termcfg/apply.go index d3c6d12..bd151c0 100644 --- a/internal/termcfg/apply.go +++ b/internal/termcfg/apply.go @@ -1,42 +1,15 @@ package termcfg -import ( - "os" - - "github.com/REPPL/ferry/internal/dotfile" -) +import "os" // filePerm / execPerm are the modes for a first-ever write of a terminal config // file: plain 0644, or 0755 when the repo source carries an executable bit (a // terminal config tree may ship a helper script). An existing destination's mode -// is preserved by the shared apply core, matching the dotfile domain. +// is preserved by the shared apply core (dotfile.ApplyContentDeferred), matching +// the dotfile domain. Terminal items reach that core directly through the +// converged kindFile apply arm; these consts name the first-write mode policy the +// mode-clamp regression tests validate. const ( filePerm os.FileMode = 0o644 execPerm os.FileMode = 0o755 ) - -// ApplyItem materialises one planned terminal item onto its $HOME destination -// through the dotfile domain's OWN apply core (dotfile.ApplyContentDeferred): -// the item's overlay-or-shared Content stands in for the repo side, and the -// three-way decision table, first-touch adoption, conflict refusal, the -// empty-over-substantial data-loss guard, and the Backuper-mediated atomic -// write are all the shared machinery — no parallel state machine. Terminal -// config is carried LIKE A DOTFILE: a locally-drifted target is a capture -// candidate (ActionSkipped without force), not repo-authoritative. -// -// Writes never persist last-applied directly: the hash rides on -// Result.PendingHash for the caller to commit AFTER the journal commit -// (dotfile.CommitLastApplied), so a crash can never leave last-applied ahead of -// a rolled-back file. -func ApplyItem(it Item, store *dotfile.Store, b dotfile.Backuper, force bool) (dotfile.Result, error) { - perm := filePerm - if it.Exec { - perm = execPerm - } - // A secret-routed target's Content is already-rendered plaintext credentials. - // Declare the routing to the apply core (it.SecretRouted): it strips group/other - // access from the written mode — even when preserving an existing file's mode — - // so the credential is never world-/group-readable (0644 -> 0600, an executable - // 0755 -> 0700, keeping the exec bit), and records only the content hash. - return dotfile.ApplyContentDeferred(it.Target, it.Content, perm, store, b, force, it.SecretRouted) -} diff --git a/internal/termcfg/apply_secret_test.go b/internal/termcfg/apply_secret_test.go index c44a41e..5247f9b 100644 --- a/internal/termcfg/apply_secret_test.go +++ b/internal/termcfg/apply_secret_test.go @@ -34,8 +34,15 @@ func applyOne(t *testing.T, home string, it Item) os.FileMode { if err != nil { t.Fatalf("open store: %v", err) } - if _, err := ApplyItem(it, store, permBackuper{}, false); err != nil { - t.Fatalf("ApplyItem: %v", err) + // Production reaches dotfile.ApplyContentDeferred directly via the converged + // kindFile apply arm; drive that same core here with the terminal domain's + // first-write mode policy (filePerm/execPerm keyed on Item.Exec). + perm := filePerm + if it.Exec { + perm = execPerm + } + if _, err := dotfile.ApplyContentDeferred(it.Target, it.Content, perm, store, permBackuper{}, false, it.SecretRouted); err != nil { + t.Fatalf("ApplyContentDeferred: %v", err) } fi, err := os.Stat(it.Target.Home) if err != nil { @@ -101,15 +108,21 @@ func TestApplyItemSecretRoutedClampsExistingMode(t *testing.T) { } // No last-applied recorded and live differs from the repo content, so this is a // first-touch adoption: force it through (a default apply would refuse as a - // capture candidate) to exercise the write-over-existing-file branch. - if _, err := ApplyItem(Item{ + // capture candidate) to exercise the write-over-existing-file branch. Drive the + // shared apply core directly, exactly as the converged kindFile arm does. + it := Item{ Key: "terminals/wezterm", Label: "terminals:wezterm", Target: dotfile.Target{Name: "terminals/wezterm", Home: dst}, Content: []byte("token = REAL-RENDERED-SECRET\n"), SecretRouted: true, - }, store, permBackuper{}, true); err != nil { - t.Fatalf("ApplyItem: %v", err) + } + perm := filePerm + if it.Exec { + perm = execPerm + } + if _, err := dotfile.ApplyContentDeferred(it.Target, it.Content, perm, store, permBackuper{}, true, it.SecretRouted); err != nil { + t.Fatalf("ApplyContentDeferred: %v", err) } fi, err := os.Stat(dst) diff --git a/internal/terminal/allowlist.go b/internal/terminal/allowlist.go new file mode 100644 index 0000000..97a9d15 --- /dev/null +++ b/internal/terminal/allowlist.go @@ -0,0 +1,232 @@ +package terminal + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "sort" + "strings" +) + +// ITerm2GlobalAllowlist is the set of GLOBAL (non-profile) iTerm2 preference keys +// ferry carries in the git-tracked com.googlecode.iterm2 plist. It is an ALLOWLIST +// on purpose: capture keeps ONLY these keys and drops everything else, so the +// volatile machine state a whole-plist capture would sweep up — window geometry +// (`NSWindow Frame …`), the `NoSync…` one-shot dialog flags, the retired +// custom-prefs-folder pointer (`PrefsCustomFolder` / `LoadPrefsFromCustomFolder`), +// telemetry and per-run counters — can never reach the repo, because it is not on +// the list rather than because a denylist happened to name it. +// +// Profiles are NOT carried here — they are the separate Dynamic Profiles JSON +// FileDomain (iterm2-profiles). This domain is the app-wide behaviour that lives +// outside any profile. +// +// The set below is a curated STARTING POINT of stable, machine-agnostic global +// behaviour keys; extend it to taste in the repo's own review. A key absent from +// the list is simply not carried (a no-op), never an error. +var ITerm2GlobalAllowlist = map[string]bool{ + // Quit / close confirmations. + "PromptOnQuit": true, + "OnlyWhenMoreTabs": true, + "QuitWhenAllWindowsClosed": true, + // Startup / window restoration behaviour. + "OpenArrangementAtStartup": true, + "OpenNoWindowsAtStartup": true, + "SavePasteHistory": true, + // Tab bar / window chrome (behaviour, not geometry). + "TabViewType": true, + "WindowStyle": true, + "HideTab": true, + "HideTabNumber": true, + "HideTabCloseButton": true, + "HideActivityIndicator": true, + "HideScrollbar": true, + "UseBorder": true, + // Inactive-pane / background dimming. + "DimInactiveSplitPanes": true, + "DimBackgroundWindows": true, + "AnimateDimming": true, + "SplitPaneDimmingAmount": true, + // Clipboard / paste behaviour. + "AllowClipboardAccess": true, + // Software-update behaviour (Sparkle) — a preference, not machine identity. + "SUEnableAutomaticChecks": true, + "SUAutomaticallyUpdate": true, + // Mouse / pointer bindings (a nested dict of behaviour). + "PointerActions": true, + // The default profile pointer (a GUID into the Dynamic Profiles set). + "Default Bookmark Guid": true, +} + +// FilterAllowlist reduces a `defaults export com.googlecode.iterm2 -` XML plist to +// only the keys in ITerm2GlobalAllowlist, returning a canonical, deterministic XML +// plist of that subset. It is the capture-side filter that produces the git-tracked +// representation and the like-for-like comparison basis for status/capture: because +// it is an allowlist, volatile keys (NoSync*, window geometry, the retired +// custom-prefs-folder pointer) are dropped no matter what the live domain holds. +// +// Determinism + idempotency are contractual: keys are emitted in sorted order and +// insignificant whitespace is dropped, so FilterAllowlist(FilterAllowlist(x)) == +// FilterAllowlist(x). That is what lets status compare a filtered live export +// against the filtered repo copy without spurious drift. +// +// Input that does not parse as XML (empty, or an unexpected binary export) is +// returned unchanged — the filter never fabricates content; a caller comparing two +// such blobs still compares like-for-like. +func FilterAllowlist(export []byte) []byte { + if len(export) == 0 { + return export + } + pairs, ok := parsePlistTopLevel(export) + if !ok { + return export + } + kept := pairs[:0] + for _, p := range pairs { + if ITerm2GlobalAllowlist[p.key] { + kept = append(kept, p) + } + } + sort.Slice(kept, func(i, j int) bool { return kept[i].key < kept[j].key }) + + var buf bytes.Buffer + buf.WriteString(xml.Header) // \n + buf.WriteString("\n") + buf.WriteString("\n\n") + for _, p := range kept { + fmt.Fprintf(&buf, "\t%s\n\t%s\n", + encodeTokens([]xml.Token{ + xml.StartElement{Name: xml.Name{Local: "key"}}, + xml.CharData(p.key), + xml.EndElement{Name: xml.Name{Local: "key"}}, + }), + encodeTokens(p.value)) + } + buf.WriteString("\n\n") + return buf.Bytes() +} + +// plistPair is one top-level key/value of the plist root ; value holds the +// value element's full (possibly nested) token subtree, whitespace-stripped. +type plistPair struct { + key string + value []xml.Token +} + +// parsePlistTopLevel streams the plist and returns its root 's immediate +// key/value pairs. ok is false when the bytes are not a parseable XML plist (the +// caller then leaves the input unchanged). +func parsePlistTopLevel(export []byte) (pairs []plistPair, ok bool) { + dec := xml.NewDecoder(bytes.NewReader(export)) + inPlist, inRoot, sawRoot := false, false, false + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, false + } + switch t := tok.(type) { + case xml.StartElement: + switch { + case t.Name.Local == "plist" && !inPlist: + inPlist = true + case inPlist && !inRoot && t.Name.Local == "dict": + inRoot, sawRoot = true, true + case inRoot && t.Name.Local == "key": + key, kerr := readElementText(dec, "key") + if kerr != nil { + return nil, false + } + val, verr := captureValue(dec) + if verr != nil { + return nil, false + } + pairs = append(pairs, plistPair{key: key, value: val}) + } + case xml.EndElement: + if inRoot && t.Name.Local == "dict" { + // Root dict closed — done (ignore anything after). + return pairs, true + } + } + } + // A real plist has a root; without it the bytes were not a plist + // (plain text tokenises as valid XML CharData, so absence of the root — not a + // tokeniser error — is the signal to leave the input unchanged). + return pairs, sawRoot +} + +// readElementText consumes CharData up to the EndElement named name, returning the +// accumulated text (used to read a 's name). +func readElementText(dec *xml.Decoder, name string) (string, error) { + var b strings.Builder + for { + tok, err := dec.Token() + if err != nil { + return "", err + } + switch t := tok.(type) { + case xml.CharData: + b.Write(t) + case xml.EndElement: + if t.Name.Local == name { + return b.String(), nil + } + } + } +} + +// captureValue reads the next value element after a (skipping insignificant +// whitespace) and returns its full token subtree, dropping whitespace-only CharData +// so the re-emitted plist is canonical and idempotent under FilterAllowlist. +func captureValue(dec *xml.Decoder) ([]xml.Token, error) { + var tokens []xml.Token + depth := 0 + for { + tok, err := dec.Token() + if err != nil { + return nil, err + } + switch t := tok.(type) { + case xml.CharData: + if depth == 0 { + if strings.TrimSpace(string(t)) == "" { + continue // whitespace before the value element + } + return nil, fmt.Errorf("terminal: unexpected text before plist value") + } + if strings.TrimSpace(string(t)) == "" { + continue // drop insignificant whitespace inside a container value + } + tokens = append(tokens, xml.CopyToken(t)) + case xml.StartElement: + depth++ + tokens = append(tokens, xml.CopyToken(t)) + case xml.EndElement: + tokens = append(tokens, xml.CopyToken(t)) + depth-- + if depth == 0 { + return tokens, nil + } + default: + if depth > 0 { + tokens = append(tokens, xml.CopyToken(t)) + } + } + } +} + +// encodeTokens serialises a token slice to canonical XML (used for the and +// each captured value subtree). +func encodeTokens(tokens []xml.Token) string { + var b strings.Builder + enc := xml.NewEncoder(&b) + for _, t := range tokens { + _ = enc.EncodeToken(t) + } + _ = enc.Flush() + return b.String() +} diff --git a/internal/terminal/allowlist_test.go b/internal/terminal/allowlist_test.go new file mode 100644 index 0000000..15379d3 --- /dev/null +++ b/internal/terminal/allowlist_test.go @@ -0,0 +1,103 @@ +package terminal + +import ( + "strings" + "testing" +) + +// a representative `defaults export com.googlecode.iterm2 -` body: two allowlisted +// keys (one scalar, one nested dict), plus volatile keys that MUST be dropped — a +// NoSync* one-shot flag, a window-geometry frame, and the retired custom-prefs +// pointer. +const sampleITerm2Export = ` + + + + PromptOnQuit + + NoSyncSuppressAnnoyingBellOffer + + NSWindow Frame iTerm Window 0 + 0 0 800 600 0 0 1440 900 + PrefsCustomFolder + /some/machine/local/path + LoadPrefsFromCustomFolder + + PointerActions + + Button,1,1,, + + Action + kContextMenuPointerAction + + + + +` + +func TestFilterAllowlistKeepsAllowedDropsVolatile(t *testing.T) { + out := string(FilterAllowlist([]byte(sampleITerm2Export))) + + // Allowlisted keys survive. + for _, keep := range []string{"PromptOnQuit", "PointerActions", "kContextMenuPointerAction"} { + if !strings.Contains(out, keep) { + t.Errorf("allowlisted content %q was dropped\n%s", keep, out) + } + } + // Volatile keys are gone. + for _, drop := range []string{ + "NoSyncSuppressAnnoyingBellOffer", + "NSWindow Frame", + "PrefsCustomFolder", + "LoadPrefsFromCustomFolder", + "/some/machine/local/path", + } { + if strings.Contains(out, drop) { + t.Errorf("volatile content %q survived the allowlist filter\n%s", drop, out) + } + } + // Still a plist. + if !strings.Contains(out, "") { + t.Errorf("filtered output is not a plist\n%s", out) + } +} + +// TestFilterAllowlistIdempotent: filtering an already-filtered plist yields the +// SAME bytes — the property status/capture rely on to compare like-for-like +// without spurious drift. +func TestFilterAllowlistIdempotent(t *testing.T) { + once := FilterAllowlist([]byte(sampleITerm2Export)) + twice := FilterAllowlist(once) + if string(once) != string(twice) { + t.Errorf("FilterAllowlist is not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", once, twice) + } +} + +// TestFilterAllowlistDeterministicOrder: two exports with the same keys in a +// different source order filter to identical bytes (keys are emitted sorted). +func TestFilterAllowlistDeterministicOrder(t *testing.T) { + a := ` + + PromptOnQuit + HideScrollbar +` + b := ` + + HideScrollbar + PromptOnQuit +` + if fa, fb := FilterAllowlist([]byte(a)), FilterAllowlist([]byte(b)); string(fa) != string(fb) { + t.Errorf("filter order not deterministic:\n%s\n---\n%s", fa, fb) + } +} + +// TestFilterAllowlistNonXMLUnchanged: bytes that are not XML are returned as-is. +func TestFilterAllowlistNonXMLUnchanged(t *testing.T) { + junk := []byte("not xml at all") + if got := FilterAllowlist(junk); string(got) != string(junk) { + t.Errorf("non-XML input mutated: %q", got) + } + if got := FilterAllowlist(nil); got != nil { + t.Errorf("nil input = %q, want nil", got) + } +} diff --git a/internal/terminal/apply.go b/internal/terminal/apply.go index cf561d7..06f0354 100644 --- a/internal/terminal/apply.go +++ b/internal/terminal/apply.go @@ -1,6 +1,10 @@ package terminal -import "github.com/REPPL/ferry/internal/platform" +import ( + "errors" + + "github.com/REPPL/ferry/internal/platform" +) // ApplyResult reports the outcome of applying a terminal preference domain. // Skipped is true (with ErrNotDarwin in Err) when run on a non-darwin host — @@ -30,6 +34,12 @@ func Apply(d *PreferenceDomain) ApplyResult { return ApplyResult{Domain: d.domain, Skipped: true, Err: ErrNotDarwin} } if err := d.apply(d.runner); err != nil { + // A running iTerm2 is a clean SKIP, not a failure: importing would be + // silently lost on quit. Report it as skipped (Applied=false) so the caller + // leaves live config intact and never rolls back (nothing was imported). + if errors.Is(err, ErrITerm2Running) { + return ApplyResult{Domain: d.domain, Skipped: true, Err: err} + } return ApplyResult{Domain: d.domain, Err: err, Note: d.note} } return ApplyResult{Domain: d.domain, Applied: true, Note: d.note} diff --git a/internal/terminal/doc.go b/internal/terminal/doc.go index 20e99ca..4cd7017 100644 --- a/internal/terminal/doc.go +++ b/internal/terminal/doc.go @@ -10,9 +10,12 @@ // - Backup captures the CURRENT state via `defaults export -` so the // backup engine can snapshot it to the immutable baseline + per-run journal // BEFORE any mutation. -// - Apply mutates the domain (iTerm2: set PrefsCustomFolder + -// LoadPrefsFromCustomFolder via `defaults write`; Apple Terminal: import a -// prepared export via `defaults import`). +// - Apply mutates the domain by importing a prepared export blob via +// `defaults import -` (both iTerm2 and Apple Terminal). iTerm2 +// additionally REFUSES while it is running (a running iTerm2 rewrites the +// domain on quit) and flushes cfprefsd after a successful import. Its +// git-tracked representation is an allowlist-filtered plist (FilterAllowlist), +// so volatile keys (NoSync*, window geometry) are never carried. // - Restore re-applies a previously captured blob via // `defaults import -`, returning the domain to that state — this is // the engine's rollback path. diff --git a/internal/terminal/domain.go b/internal/terminal/domain.go index 94da0f6..dcf6a8a 100644 --- a/internal/terminal/domain.go +++ b/internal/terminal/domain.go @@ -3,7 +3,6 @@ package terminal import ( "errors" "fmt" - "regexp" "strings" "github.com/REPPL/ferry/internal/platform" @@ -15,48 +14,6 @@ const ( AppleTerminalDomain = "com.apple.Terminal" ) -// ITerm2ControlKeys are the MACHINE-LOCAL keys ferry writes to POINT iTerm2 at the -// repo (the custom-prefs-folder pointer), NOT user preferences. apply sets them via -// `defaults write` (see NewITerm2). They are how ferry deploys the repo plist, so -// capture must never ingest them into the repo and status must never report drift -// because of them. capture/status strip these from BOTH the live export and the repo -// plist before comparing/capturing (compare like-for-like). -var ITerm2ControlKeys = []string{"PrefsCustomFolder", "LoadPrefsFromCustomFolder"} - -// iterm2ControlKeyRE matches one `NAME` plus the value element that -// immediately follows it in a `defaults export`/plist XML body, for each control -// key. The value is either a self-closing element (``) or an open/close pair -// (``); both forms are removed together so no orphan key or value -// is left behind. The keys carry scalar string/bool values only, so nested -// containers are not handled — that is acceptable here (and the exact custom-folder -// vs `defaults export` round-trip is Layer-2-deferred per AC-terminal-config). -var iterm2ControlKeyRE = func() *regexp.Regexp { - alt := strings.Join(ITerm2ControlKeys, "|") - // (?s) so `.` spans newlines between the key and its value element. - return regexp.MustCompile(`(?s)[\t ]*(?:` + alt + `)\s*(?:<[a-zA-Z]+/>|<([a-zA-Z]+)>.*?)\s*\n?`) -}() - -// StripITerm2ControlKeys removes the machine-local iTerm2 control keys -// (PrefsCustomFolder / LoadPrefsFromCustomFolder) and their values from a plist / -// `defaults export` XML body, returning the remaining content. It is a TEXT-LEVEL -// strip over the opaque export bytes (the codebase treats these exports as opaque -// byte blobs; there is no plist parser), used by capture/status so the live domain -// is compared LIKE-FOR-LIKE against the repo plist with the pointer keys excluded -// from both sides. Non-iTerm2 / absent inputs are returned unchanged. -// -// NOTE: This is a deliberate consistency fix — never capturing the pointer state and -// comparing both sides without it. The deeper reconciliation of iTerm2's -// custom-prefs-FOLDER plist vs the `defaults export` domain (which can diverge in -// edge cases) is Layer-2-deferred per ACCEPTANCE.md's AC-terminal-config, which -// gates the native-mechanism differential and defers concrete plist key/value -// semantics. Do not grow this into a full custom-folder differ. -func StripITerm2ControlKeys(export []byte) []byte { - if len(export) == 0 { - return export - } - return iterm2ControlKeyRE.ReplaceAll(export, nil) -} - // PreferenceDomain models one macOS terminal preference DOMAIN as a backup // resource. It implements backup.Resource (Domain/Backup/Restore) so the engine // captures pre-mutation state and can roll back, and it carries an Apply @@ -68,43 +25,59 @@ type PreferenceDomain struct { domain string runner Runner // apply performs the domain-specific mutation via the runner. Set by the - // constructors; iTerm2 sets PrefsCustomFolder + LoadPrefsFromCustomFolder, - // Apple Terminal imports a prepared export blob. + // constructors; both iTerm2 and Apple Terminal import a prepared export blob + // via `defaults import` (iTerm2 additionally refuses when it is running and + // flushes cfprefsd afterwards — see NewITerm2). apply func(r Runner) error // note is the human-readable caveat surfaced in the apply result (e.g. the // iTerm2 relaunch / cfprefsd-cache note). note string } -// NewITerm2 builds the iTerm2 preference domain. On Apply it points iTerm2 at -// prefsFolder by setting PrefsCustomFolder to that folder and -// LoadPrefsFromCustomFolder to true via `defaults write`. The returned result -// notes that iTerm2 must be relaunched (and cfprefsd may cache) for the change -// to take effect. +// NewITerm2 builds the iTerm2 GLOBAL preference domain. On Apply it imports the +// prepared, allowlist-filtered export blob into com.googlecode.iterm2 via +// `defaults import -` (mirroring Apple Terminal), then flushes cfprefsd +// so the change is not masked by the daemon's cache. Pass nil to manage the domain +// for backup/restore only (Apply then no-ops the import). +// +// CRITICAL: iTerm2 keeps its preferences in memory and REWRITES the domain on +// quit, and cfprefsd caches it — so importing while iTerm2 is RUNNING is silently +// lost. Apply therefore consults proc.Running() first and, when iTerm2 is up, +// REFUSES with ErrITerm2Running (a clean skip; live config left intact) so the +// user can quit iTerm2 and re-run. After a successful import into a not-running +// iTerm2 it calls proc.FlushPrefsCache() (best-effort `killall cfprefsd`). // -// prefsFolder is the ferry-owned RENDERED STAGING FOLDER (e.g. -// ~/.local/state/ferry/rendered/iterm2/) into which apply has materialised the -// repo plist with its {{ferry.secret ...}} placeholders already substituted — -// NOT the raw repo iterm2/ folder. iTerm2 loads com.googlecode.iterm2.plist from -// whatever folder it is pointed at; pointing it at the staging folder guarantees -// it reads the RENDERED plist and never the raw placeholder one. cmd/apply.go -// stages that plist (refused leaf / missing secret → it skips this domain and -// never calls NewITerm2). -func NewITerm2(prefsFolder string, runner Runner) *PreferenceDomain { +// exportBlob is the RENDERED bytes (any {{ferry.secret ...}} already substituted +// by cmd/apply.go, which skips this domain on a missing secret or a refused repo +// plist so a placeholder is never imported). This retires the previous +// custom-prefs-folder mechanism (v0.7.0 D4): ferry no longer points iTerm2 at a +// folder — it imports the domain like Apple Terminal. +func NewITerm2(exportBlob []byte, runner Runner, proc ProcessController) *PreferenceDomain { d := &PreferenceDomain{ domain: ITerm2Domain, runner: runner, - note: "iTerm2 must be relaunched for the custom prefs folder to take effect (cfprefsd may cache the old value).", + note: "iTerm2 preferences imported; relaunch iTerm2 for them to take effect (cfprefsd was flushed).", } d.apply = func(r Runner) error { - // PrefsCustomFolder -> rendered staging folder (a string default). - if _, err := r.Run(nil, "write", ITerm2Domain, "PrefsCustomFolder", "-string", prefsFolder); err != nil { - return fmt.Errorf("iterm2: set PrefsCustomFolder: %w", err) + if len(exportBlob) == 0 { + return nil // manage backup/restore only; nothing to import. + } + // REFUSE if iTerm2 is running: a running iTerm2 overwrites the domain on + // quit, silently losing the import. Fail closed on a probe error too. + running, err := proc.Running() + if err != nil { + return fmt.Errorf("iterm2: check whether iTerm2 is running: %w", err) + } + if running { + return ErrITerm2Running } - // LoadPrefsFromCustomFolder -> true (a bool default). - if _, err := r.Run(nil, "write", ITerm2Domain, "LoadPrefsFromCustomFolder", "-bool", "true"); err != nil { - return fmt.Errorf("iterm2: set LoadPrefsFromCustomFolder: %w", err) + if _, err := r.Run(exportBlob, "import", ITerm2Domain, "-"); err != nil { + return fmt.Errorf("iterm2: import: %w", err) } + // Flush cfprefsd so the imported values are not masked by its cache. This is + // best-effort: the bytes are already on disk, so a flush hiccup must not fail + // (or roll back) an otherwise successful import. + _ = proc.FlushPrefsCache() return nil } return d @@ -136,6 +109,23 @@ func NewAppleTerminal(exportBlob []byte, runner Runner) *PreferenceDomain { // Domain returns the preference domain identifier (backup.Resource). func (d *PreferenceDomain) Domain() string { return d.domain } +// Name returns the user-facing [manage] scope key for this preference domain +// (iTerm2 -> "iterm2", Apple Terminal -> "terminal") — distinct from Domain(), +// which is the native `defaults` identifier (com.googlecode.iterm2 / +// com.apple.Terminal). It is the key Scope.IsManaged gates on and the converged +// domains.ResourceDomain registry enumerates by, replacing the hardcoded +// {"iterm2","terminal"} literal that drove the preference-domain arm. +func (d *PreferenceDomain) Name() string { + switch d.domain { + case ITerm2Domain: + return "iterm2" + case AppleTerminalDomain: + return "terminal" + default: + return "" + } +} + // Backup captures the domain's CURRENT state via `defaults export -` // (backup.Resource). It is darwin-only: on a non-darwin host it returns // ErrNotDarwin and never shells out. Run BEFORE the domain is mutated. diff --git a/internal/terminal/plan.go b/internal/terminal/plan.go index 14c41bc..e022366 100644 --- a/internal/terminal/plan.go +++ b/internal/terminal/plan.go @@ -59,7 +59,7 @@ func (d *PreferenceDomain) Plan() PlanEntry { var summary string switch d.domain { case ITerm2Domain: - summary = "set custom prefs folder + load-from-custom-folder via defaults write" + summary = "import global preferences via defaults import (quit iTerm2 first)" case AppleTerminalDomain: summary = "import preferences via defaults import" default: diff --git a/internal/terminal/process.go b/internal/terminal/process.go new file mode 100644 index 0000000..1d5085e --- /dev/null +++ b/internal/terminal/process.go @@ -0,0 +1,77 @@ +package terminal + +import ( + "errors" + "fmt" + "os/exec" + "strings" +) + +// ErrITerm2Running is returned by the iTerm2 domain's Apply when iTerm2 is +// currently running. Importing the global preference plist into a running iTerm2 +// is silently lost: iTerm2 holds its preferences in memory and REWRITES +// com.googlecode.iterm2 on quit, and cfprefsd caches the domain, so a +// `defaults import` while it runs is overwritten the moment the app quits. Apply +// therefore REFUSES (a clean skip, not a failure) and asks the user to quit +// iTerm2 first — mirroring ErrNotDarwin's clean-skip contract. +var ErrITerm2Running = errors.New("terminal: iTerm2 is running; quit it before applying its global preferences") + +// ProcessController is the injectable seam over the iTerm2 lifecycle checks the +// global-plist apply needs — is iTerm2 running, and flush cfprefsd's cache after +// an import. It is separate from Runner (which shells `defaults`) because these +// operate on PROCESSES (`pgrep`/`killall`), not the preference tool. Production +// uses ExecProcessController; tests pass a fake that records calls and never +// shells out, so the running-guard and the cache flush are exercised +// deterministically on any host. +type ProcessController interface { + // Running reports whether iTerm2 is currently running. A true result blocks + // the global-plist import (see ErrITerm2Running). + Running() (bool, error) + // FlushPrefsCache restarts cfprefsd (`killall cfprefsd`) so a freshly imported + // preference domain is not masked by the daemon's in-memory cache. It is + // best-effort: "no matching processes" (cfprefsd not running) is a success. + FlushPrefsCache() error +} + +// ExecProcessController is the production ProcessController: it probes iTerm2 via +// `pgrep` and flushes the preferences cache via `killall cfprefsd`. Both are +// darwin tools; callers only construct it on the darwin apply path (the domain +// itself is darwin-guarded via ErrNotDarwin). +type ExecProcessController struct{} + +// Running reports whether an iTerm2 process is alive via `pgrep -x iTerm2`. A +// zero exit (match) is running; exit 1 (no match) is not running; any other +// outcome (e.g. exit 2, or pgrep missing) is surfaced as an error so apply can +// fail closed rather than assume "not running" and import into a live iTerm2. +func (ExecProcessController) Running() (bool, error) { + err := exec.Command("pgrep", "-x", "iTerm2").Run() + if err == nil { + return true, nil + } + var ee *exec.ExitError + if errors.As(err, &ee) && ee.ExitCode() == 1 { + return false, nil + } + return false, fmt.Errorf("pgrep iTerm2: %w", err) +} + +// FlushPrefsCache runs `killall cfprefsd`. cfprefsd is normally running, so this +// usually succeeds; when it is not running `killall` exits 1 with "No matching +// processes", which is treated as success (there is nothing to flush). Any other +// failure is surfaced, but the caller treats a flush failure as non-fatal — the +// import already landed on disk. +func (ExecProcessController) FlushPrefsCache() error { + out, err := exec.Command("killall", "cfprefsd").CombinedOutput() + if err == nil { + return nil + } + if strings.Contains(strings.ToLower(string(out)), "no matching process") { + return nil + } + var ee *exec.ExitError + if errors.As(err, &ee) && ee.ExitCode() == 1 { + // killall exits 1 when it matched nothing to signal — nothing to flush. + return nil + } + return fmt.Errorf("killall cfprefsd: %w", err) +} diff --git a/internal/terminal/terminal_test.go b/internal/terminal/terminal_test.go index 8dcd343..cbbc7bc 100644 --- a/internal/terminal/terminal_test.go +++ b/internal/terminal/terminal_test.go @@ -29,6 +29,25 @@ func (f *fakeRunner) Run(stdin []byte, args ...string) ([]byte, error) { // last returns the most recent recorded call. func (f *fakeRunner) last() call { return f.calls[len(f.calls)-1] } +// fakeProc is a ProcessController stub: it reports a canned running state and +// records whether the cfprefsd flush was invoked, so the iTerm2 running-guard and +// cache flush are exercised without shelling out to pgrep/killall. +type fakeProc struct { + running bool + runErr error + flushErr error + flushed bool +} + +func (f *fakeProc) Running() (bool, error) { return f.running, f.runErr } +func (f *fakeProc) FlushPrefsCache() error { f.flushed = true; return f.flushErr } + +// newITerm2 builds an iTerm2 domain for tests with a not-running process stub, so +// existing backup/restore/plan tests need not care about the running-guard. +func newITerm2(blob []byte, r Runner) *PreferenceDomain { + return NewITerm2(blob, r, &fakeProc{}) +} + func TestBackupExportsDomain(t *testing.T) { if !platform.IsDarwin() { t.Skip("Backup is darwin-only; covered by TestNonDarwinSkips on this host") @@ -61,7 +80,7 @@ func TestBackupAbsentDomainReportsAbsent(t *testing.T) { t.Skip("Backup is darwin-only") } fr := &fakeRunner{err: ErrDomainAbsent} - d := NewITerm2("/repo/iterm2", fr) + d := newITerm2(nil, fr) blob, absent, err := d.Backup() if err != nil { @@ -82,7 +101,7 @@ func TestBackupRealExportErrorIsFatal(t *testing.T) { t.Skip("Backup is darwin-only") } fr := &fakeRunner{err: errors.New("permission denied")} - d := NewITerm2("/repo/iterm2", fr) + d := newITerm2(nil, fr) if _, absent, err := d.Backup(); err == nil || absent { t.Fatalf("Backup = (absent=%v, err=%v), want a real error", absent, err) @@ -94,7 +113,7 @@ func TestRestoreImportsDomain(t *testing.T) { t.Skip("Restore is darwin-only; covered by TestNonDarwinSkips on this host") } fr := &fakeRunner{} - d := NewITerm2("/repo/iterm2", fr) + d := newITerm2(nil, fr) blob := []byte("captured") if err := d.Restore(blob, false); err != nil { @@ -118,7 +137,7 @@ func TestRestoreAbsentBaselineDeletesDomain(t *testing.T) { t.Skip("Restore is darwin-only") } fr := &fakeRunner{} - d := NewITerm2("/repo/iterm2", fr) + d := newITerm2(nil, fr) if err := d.Restore(nil, true); err != nil { t.Fatalf("Restore(absent): %v", err) @@ -137,18 +156,22 @@ func TestRestoreAbsentAlreadyMissingIsNoError(t *testing.T) { t.Skip("Restore is darwin-only") } fr := &fakeRunner{err: ErrDomainAbsent} - d := NewITerm2("/repo/iterm2", fr) + d := newITerm2(nil, fr) if err := d.Restore(nil, true); err != nil { t.Fatalf("Restore(absent) on already-missing domain = %v, want nil", err) } } -func TestITerm2ApplySetsKeys(t *testing.T) { +// TestITerm2ApplyImportsAndFlushes: with iTerm2 NOT running, Apply imports the +// prepared export blob via `defaults import` and then flushes cfprefsd. +func TestITerm2ApplyImportsAndFlushes(t *testing.T) { if !platform.IsDarwin() { t.Skip("Apply mutation is darwin-only; covered by TestNonDarwinSkips on this host") } + blob := []byte("global-iterm2") fr := &fakeRunner{} - d := NewITerm2("/repo/iterm2", fr) + proc := &fakeProc{running: false} + d := NewITerm2(blob, fr, proc) res := Apply(d) if res.Err != nil { @@ -157,21 +180,62 @@ func TestITerm2ApplySetsKeys(t *testing.T) { if !res.Applied || res.Skipped { t.Fatalf("Apply result = %+v, want Applied", res) } - if res.Note == "" || !strings.Contains(res.Note, "relaunch") { - t.Fatalf("Apply note = %q, want a relaunch caveat", res.Note) + if len(fr.calls) != 1 { + t.Fatalf("got %d defaults calls, want 1 (import)", len(fr.calls)) + } + wantArgs := []string{"import", ITerm2Domain, "-"} + if !equalArgs(fr.last().args, wantArgs) { + t.Fatalf("Apply args = %v, want %v", fr.last().args, wantArgs) + } + if string(fr.last().stdin) != string(blob) { + t.Fatalf("Apply stdin = %q, want the rendered export blob", fr.last().stdin) + } + if !proc.flushed { + t.Fatalf("cfprefsd was not flushed after a successful import") + } +} + +// TestITerm2ApplyRefusesWhenRunning: a running iTerm2 must SKIP (ErrITerm2Running), +// never import — a `defaults import` would be silently lost on quit. Nothing is +// imported and cfprefsd is not flushed. +func TestITerm2ApplyRefusesWhenRunning(t *testing.T) { + if !platform.IsDarwin() { + t.Skip("Apply mutation is darwin-only") + } + fr := &fakeRunner{} + proc := &fakeProc{running: true} + d := NewITerm2([]byte("x"), fr, proc) + + res := Apply(d) + if !res.Skipped || !errors.Is(res.Err, ErrITerm2Running) { + t.Fatalf("Apply while running = %+v, want Skipped with ErrITerm2Running", res) + } + if res.Applied { + t.Fatalf("Apply reported Applied while iTerm2 was running") } - if len(fr.calls) != 2 { - t.Fatalf("got %d defaults calls, want 2 (PrefsCustomFolder + LoadPrefsFromCustomFolder)", len(fr.calls)) + if len(fr.calls) != 0 { + t.Fatalf("imported into a running iTerm2 (%d defaults calls, want 0)", len(fr.calls)) + } + if proc.flushed { + t.Fatalf("flushed cfprefsd despite skipping the import") } - // First call: PrefsCustomFolder -> repo path. - want1 := []string{"write", ITerm2Domain, "PrefsCustomFolder", "-string", "/repo/iterm2"} - if !equalArgs(fr.calls[0].args, want1) { - t.Fatalf("call 0 args = %v, want %v", fr.calls[0].args, want1) +} + +// TestITerm2ApplyNilBlobNoImport: a nil blob manages backup/restore only and never +// imports (nor probes running / flushes). +func TestITerm2ApplyNilBlobNoImport(t *testing.T) { + if !platform.IsDarwin() { + t.Skip("Apply mutation is darwin-only") + } + fr := &fakeRunner{} + proc := &fakeProc{running: true} // even "running" is irrelevant with no blob + d := NewITerm2(nil, fr, proc) + res := Apply(d) + if res.Err != nil || !res.Applied { + t.Fatalf("Apply result = %+v, want Applied (no-op import)", res) } - // Second call: LoadPrefsFromCustomFolder -> true. - want2 := []string{"write", ITerm2Domain, "LoadPrefsFromCustomFolder", "-bool", "true"} - if !equalArgs(fr.calls[1].args, want2) { - t.Fatalf("call 1 args = %v, want %v", fr.calls[1].args, want2) + if len(fr.calls) != 0 || proc.flushed { + t.Fatalf("nil-blob iTerm2 Apply shelled out (calls=%d flushed=%v), want none", len(fr.calls), proc.flushed) } } @@ -217,7 +281,7 @@ func TestApplyPropagatesRunnerError(t *testing.T) { t.Skip("Apply mutation is darwin-only") } fr := &fakeRunner{err: errors.New("defaults boom")} - d := NewITerm2("/repo/iterm2", fr) + d := NewITerm2([]byte("x"), fr, &fakeProc{}) res := Apply(d) if res.Err == nil || res.Applied { t.Fatalf("Apply result = %+v, want an error", res) @@ -226,7 +290,7 @@ func TestApplyPropagatesRunnerError(t *testing.T) { func TestPlanMarksPreferenceDomain(t *testing.T) { for _, d := range []*PreferenceDomain{ - NewITerm2("/repo/iterm2", &fakeRunner{}), + newITerm2(nil, &fakeRunner{}), NewAppleTerminal(nil, &fakeRunner{}), } { p := d.Plan() @@ -247,7 +311,7 @@ func TestPlanMarksPreferenceDomain(t *testing.T) { } func TestPlanDomainIDs(t *testing.T) { - if got := NewITerm2("/x", &fakeRunner{}).Plan().Domain; got != ITerm2Domain { + if got := newITerm2(nil, &fakeRunner{}).Plan().Domain; got != ITerm2Domain { t.Fatalf("iTerm2 plan domain = %q, want %q", got, ITerm2Domain) } if got := NewAppleTerminal(nil, &fakeRunner{}).Plan().Domain; got != AppleTerminalDomain { @@ -261,7 +325,7 @@ func TestPlanDomainIDs(t *testing.T) { // runner-untouched assertion is gated on the platform. func TestNonDarwinSkips(t *testing.T) { fr := &fakeRunner{} - d := NewITerm2("/repo/iterm2", fr) + d := newITerm2(nil, fr) if platform.IsDarwin() { // On darwin there is no skip; just confirm Apply runs the runner. diff --git a/internal/tmux/secret.go b/internal/tmux/secret.go new file mode 100644 index 0000000..1773a59 --- /dev/null +++ b/internal/tmux/secret.go @@ -0,0 +1,80 @@ +// Package tmux holds ferry's tmux-format knowledge that must NOT live in the +// generic secret scanner: the recogniser that finds a secret sitting inside a +// quoted `set -g @token ''` option value. It is the tmux analogue of the +// zsh plugin's assignment recogniser (internal/plugin/zsh) — format-specific +// span extraction that CALLS the generic secret primitives (secret.ScanText, +// secret.IsNonPlaceholderSecret, and, at the capture site, secret.SwapColumns) +// but never teaches internal/secret any tmux syntax. +package tmux + +import ( + "regexp" + "strings" + + "github.com/REPPL/ferry/internal/secret" +) + +// setOptionRe matches a tmux `set` / `set-option` line that assigns a QUOTED +// value to a user option (`@name`). The value carries the potential secret, +// where zsh's whole-line fallback would clobber the `set -g @name` prefix and +// the quotes. Groups: 1 = everything up to and including the opening quote, +// 2 = the opening quote, 3 = the value, 4 = the closing quote. Group 3's byte +// offsets (FindStringSubmatchIndex) become the SecretSpan column range so only +// the value — never the surrounding `set -g @name '…'` syntax — is replaced. +// +// The value group is greedy (`.*`) up to the LAST quote before optional trailing +// whitespace: a tmux option takes a single value, so a greedy match cleanly spans +// a value that itself contains a quote of the OTHER kind. A mismatched +// open/close quote pair is rejected by the caller. +var setOptionRe = regexp.MustCompile( + `^(\s*set(?:-option)?(?:\s+-[A-Za-z]+)*\s+@[A-Za-z0-9_-]+\s+(["']))(.*)(["'])\s*$`) + +// SecretSpans returns the column-grained secret spans for tmux +// `set -g @token ''` option lines whose quoted VALUE is a real, +// high-confidence, literal secret. A span carries the byte range of the VALUE +// INSIDE the quotes (StartCol/EndCol), so the capture site can replace ONLY that +// range via secret.SwapColumns and leave the `set -g @token '…'` syntax and the +// quotes byte-identical. +// +// A value is a secret only when BOTH hold, exactly as the git/zsh sub-value +// recognisers gate: +// - secret.IsNonPlaceholderSecret(value) — an env-ref like ${TMUX_TOKEN}, a +// ferry {{…}} placeholder, or an empty value is NOT a secret and is left in +// the repo verbatim; and +// - secret.ScanText(value).HasHigh() — the value is a real high-confidence +// secret, not an ordinary short option string. +// +// A line without a quoted `@name` value, or one whose open/close quotes differ, +// contributes nothing. +func SecretSpans(text string) []secret.SecretSpan { + var spans []secret.SecretSpan + for i, line := range strings.Split(text, "\n") { + m := setOptionRe.FindStringSubmatchIndex(line) + if m == nil { + continue + } + // m indexes: [0:2] whole, [2:4] group1, [4:6] group2 (open quote), + // [6:8] group3 (value), [8:10] group4 (close quote). + openQuote := line[m[4]:m[5]] + closeQuote := line[m[8]:m[9]] + if openQuote != closeQuote { + continue // an unbalanced quote pair is not a clean value + } + valStart, valEnd := m[6], m[7] + value := line[valStart:valEnd] + if !secret.IsNonPlaceholderSecret(value) { + continue // ${ENV} / {{placeholder}} / empty: left verbatim, never stored + } + if !secret.ScanText(value).HasHigh() { + continue // not a real high-confidence secret + } + spans = append(spans, secret.SecretSpan{ + StartLine: i + 1, + EndLine: i + 1, + Value: value, + StartCol: valStart, + EndCol: valEnd, + }) + } + return spans +} diff --git a/internal/tmux/secret_test.go b/internal/tmux/secret_test.go new file mode 100644 index 0000000..f51b456 --- /dev/null +++ b/internal/tmux/secret_test.go @@ -0,0 +1,70 @@ +package tmux + +import "testing" + +// TestSecretSpansOffsets pins that the recogniser isolates EXACTLY the quoted +// value's byte range, leaving the `set -g @token '…'` prefix and the quotes +// outside the span — the precondition for a byte-stable secret.SwapColumns swap. +func TestSecretSpansOffsets(t *testing.T) { + const ghToken = "ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8" // GitHub token: a named-token High secret + line := "set -g @token '" + ghToken + "'" + spans := SecretSpans(line + "\n") + if len(spans) != 1 { + t.Fatalf("SecretSpans found %d spans, want 1: %+v", len(spans), spans) + } + sp := spans[0] + if !sp.HasColumns() { + t.Fatalf("span is not column-grained: %+v", sp) + } + if sp.Value != ghToken { + t.Errorf("span value = %q, want %q", sp.Value, ghToken) + } + // The span must cover ONLY the value, so the byte before StartCol is the + // opening quote and the byte at EndCol is the closing quote. + if line[sp.StartCol-1] != '\'' || line[sp.EndCol] != '\'' { + t.Errorf("span [%d,%d) does not sit inside the quotes: %q", sp.StartCol, sp.EndCol, line) + } + if line[sp.StartCol:sp.EndCol] != ghToken { + t.Errorf("span byte range = %q, want %q", line[sp.StartCol:sp.EndCol], ghToken) + } +} + +// TestSecretSpansDoubleQuotesAndSetOption confirms the recogniser accepts the +// `set-option` spelling and double quotes, and preserves the value byte range. +func TestSecretSpansDoubleQuotesAndSetOption(t *testing.T) { + line := `set-option -g @api "ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"` + spans := SecretSpans(line) + if len(spans) != 1 { + t.Fatalf("SecretSpans found %d spans, want 1", len(spans)) + } + if line[spans[0].StartCol:spans[0].EndCol] != "ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8" { + t.Errorf("value range = %q", line[spans[0].StartCol:spans[0].EndCol]) + } +} + +// TestSecretSpansSkipsNonSecrets is the load-bearing negative: an env-ref, a +// ferry placeholder, an empty value, and an ordinary short option string are +// NEVER flagged — they must reach the repo verbatim. +func TestSecretSpansSkipsNonSecrets(t *testing.T) { + for _, line := range []string{ + `set -g @token '${TMUX_TOKEN}'`, // env-ref: expanded at read time + `set -g @token "$TMUX_TOKEN"`, // bare env-ref + `set -g @token '{{ferry.secret "t.k"}}'`, // already a ferry placeholder + `set -g @token ''`, // empty + `set -g @plugin 'tmux-plugins/tmux-sensible'`, // ordinary option string, not a secret + `set -g status-bg black`, // unquoted, not a @user option + `# set -g @token 'ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8'`, // recognises the line shape but… + } { + if spans := SecretSpans(line); len(spans) != 0 { + t.Errorf("SecretSpans(%q) flagged %d spans, want 0: %+v", line, len(spans), spans) + } + } +} + +// TestSecretSpansMismatchedQuotes rejects an unbalanced quote pair (no clean +// value boundary): nothing is flagged rather than a mis-sliced span. +func TestSecretSpansMismatchedQuotes(t *testing.T) { + if spans := SecretSpans(`set -g @token 'AKIAIOSFODNN7EXAMPLE"`); len(spans) != 0 { + t.Errorf("mismatched quotes flagged %d spans, want 0", len(spans)) + } +} From 9a7ece2842e0eb91c33bdce1cc22e70bb5101607 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:04:44 +0100 Subject: [PATCH 2/2] chore: allowlist v0.7.0 config-plugin secret-test fixtures in gitleaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The git/tmux/npm secret-recogniser tests embed fake, credential-shaped tokens to exercise the scanners and prove a literal token never reaches the shared repo — the same reason internal/secret's tests are already allowlisted. Add the five new fixture-bearing test files by path; every non-test file stays fully scanned. --- .gitleaks.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitleaks.toml b/.gitleaks.toml index d71db7a..f6f0151 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -20,4 +20,12 @@ paths = [ '''internal/plugin/zsh/analyze_test\.go$''', # A fake AWS key fixture in the untrusted-git-transport tests. '''cmd/sync_round3_test\.go$''', + # v0.7.0 config-plugin secret tests: credential-shaped fixtures that exercise + # the git/tmux/npm token recognisers and prove a literal token never reaches + # the shared repo. Fake tokens only; every non-test file stays fully scanned. + '''internal/gitconfig/secret_test\.go$''', + '''internal/tmux/secret_test\.go$''', + '''evals/git_test\.go$''', + '''evals/tmux_test\.go$''', + '''evals/npmrc_test\.go$''', ]