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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,12 @@ on the internal packages, and the full eval suite against a real Linux binary.
`docs/`) stays host-agnostic. The one sanctioned place to name the AI
assistant is attribution: the "Built with Claude Code" badge in `README.md`.
Private, unpublished tool names never appear in any committed file.
- **Commits stay clean.** No `Co-Authored-By:` or `Assisted-by:` trailers for
AI, no "Generated with" lines, no session links. The human is the author of
record, responsible for all AI-assisted output — its correctness, licensing,
and fit for the project.
- **AI-assisted commits carry an `Assisted-by:` trailer**, kernel format
(`Assisted-by: Claude:claude-fable-5`) — disclosure, not authorship. Never
`Co-Authored-By:` for AI (it asserts an authorship the tool does not hold and
inflates the contributor graph). No "Generated with" lines or session links.
The human is the author of record, responsible for all AI-assisted output —
its correctness, licensing, and fit for the project.

## Working memory

Expand Down
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,60 @@ called out in a **Breaking** section. See

## [Unreleased]

### Added

- **`ferry restore --undo <snapshot-id>`** re-applies the pre-restore snapshot
ferry takes before every restore, making the "undo is possible" the restore
output has always advertised actually reachable. The post-restore message now
prints the exact command to run. It takes the apply lock and registers the
terminal preference domains, exactly as a restore does.

### Security

- **The secret scanner closes several credential-in-repo bypasses.** The scanner
is ferry's automated gate keeping plaintext credentials out of the shared,
pushed repo; these gaps let real secrets through it:
- Quoted multi-word values (`password = "correct horse battery"`) were only
checked up to the first space — the rest slipped through. The whole quoted
value is now scanned.
- Literal secrets beginning with `$` (a leetspeak password, not a variable
reference) or containing an interior `<` / `...` were exempted as
placeholders. Only genuine `${VAR}` / `$(cmd)` / whole-value `$VAR`
references and `<...>` templates are now exempt.
- `Authorization: Bearer <token>` / `Basic <token>` header values are now
detected in generic dotfiles (`~/.curlrc`, custom tool configs).
- Credential assignments beyond the first line of an opaque, multi-line value
(e.g. a `defaults export` plist blob) are now scanned.
- More credential key names are recognised (`pass`, `passphrase`, `pwd`,
`credentials`, `*_key`), while path-valued keys (`*_file = /path`) are
excluded to preserve the no-false-positive discipline.
- Bundle members that are binary are now scanned for provider tokens
(GitHub/AWS/GCP/Slack/OpenAI/Stripe) in addition to private-key headers,
symmetric on export and import.
- **The apply lock is published atomically** (temp file + fsync + hardlink), so a
concurrent acquirer can no longer reclaim a live owner's half-written lockfile
and run a second `apply` in parallel.
- **The init-time GitHub push runs under the same untrusted-transport hardening**
as every other ferry git invocation (`protocol.ext.allow=never`, hooks /
fsmonitor / ssh neutralised), so an ambient `pushInsteadOf` rewrite can no
longer redirect it.

### Fixed

- **Capturing a file with no trailing newline no longer wedges it** in a
permanent, unresolvable conflict — the captured composition now preserves the
live file's final-newline shape.
- **Crash-safety of ferry's own state.** Journal/snapshot backup blobs and the
dotfile and agents state files are now fsync'd before the metadata that
references them, so a power loss can no longer roll a truncated backup over an
intact file or wedge every command behind a zero-length state file; a
zero-length state file now degrades to first-touch instead of hard-failing.
- **`ferry sync` no longer silently skips remote integration** when the
ahead/behind computation fails — it rolls back with a clear error instead of
reporting "up to date".
- **Custom `ferry bundle import --out` targets are symlink-hardened** against a
redirected parent chain, matching the default target.

## [0.8.0] - 2026-07-08

### Breaking
Expand Down
27 changes: 23 additions & 4 deletions cmd/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ func captureOne(cc captureCtx) (bool, error) {
}

// Compose the captured content = repo content with accepted hunks applied.
captured := applyHunks(string(cc.repoBytes), hunks, accepted)
captured := applyHunks(string(cc.repoBytes), hunks, accepted, endsWithNewline(cc.liveBytes))
spanPatched := false
var storedRefs []string // refs Put by the span consent (for honest refusal notices)

Expand Down Expand Up @@ -893,7 +893,7 @@ func captureZshSidecar(cc captureCtx, home string) (wrote bool, offered bool, er
}
}

composed := applyHunks(string(repoBytes), hunks, accepted)
composed := applyHunks(string(repoBytes), hunks, accepted, endsWithNewline(reviewLive))
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) {
Expand Down Expand Up @@ -1291,7 +1291,16 @@ func diffHunks(oldText, newText string) []hunk {
// applyHunks reconstructs the captured text from repo's lines, replacing each
// ACCEPTED hunk's old region with its new lines and leaving rejected hunks as
// repo's original lines.
func applyHunks(oldText string, hunks []hunk, accepted []bool) string {
//
// trailingNewline carries the NEW (live) side's final-newline shape. The line
// model here terminates every emitted line with '\n', so without this the
// composed result ALWAYS ends in '\n'; when the live file has no trailing newline
// the all-accepted composition would then be live+"\n" != live, and the shared
// route would write that, leaving the target wedged in a permanent StateConflict
// that capture can never clear (the only difference is the trailing byte, which
// yields zero reviewable hunks). Stamping the live side's shape keeps composed ==
// live when every hunk is accepted.
func applyHunks(oldText string, hunks []hunk, accepted []bool, trailingNewline bool) string {
o := splitLines(oldText)
var b strings.Builder
pos := 0
Expand All @@ -1317,7 +1326,17 @@ func applyHunks(oldText string, hunks []hunk, accepted []bool) string {
b.WriteString(o[pos])
b.WriteByte('\n')
}
return b.String()
out := b.String()
if !trailingNewline && strings.HasSuffix(out, "\n") {
out = out[:len(out)-1]
}
return out
}

// endsWithNewline reports whether content's final byte is a newline — the shape
// applyHunks must preserve so a captured composition matches the live file.
func endsWithNewline(content []byte) bool {
return len(content) > 0 && content[len(content)-1] == '\n'
}

// localHunkDelta builds the zsh `.local` sidecar body: ONLY the added/changed
Expand Down
2 changes: 1 addition & 1 deletion cmd/capture_agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ func captureAgentsOne(cc agentsCaptureCtx, ct agents.CaptureTarget) (bool, error
}
}

composed := applyHunks(string(ct.Content), hunks, accepted)
composed := applyHunks(string(ct.Content), hunks, accepted, endsWithNewline(liveBytes))
// Re-scan the composed content: never write secret material to the repo.
if secret.IsBlockedFromRepo(composed) {
reportAgentsSecretBlock(cc.out, ct.Label)
Expand Down
31 changes: 31 additions & 0 deletions cmd/capture_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -511,3 +511,34 @@ func TestCaptureOne_PromptLabelsVisible(t *testing.T) {
}
}
}

// TestApplyHunks_PreservesTrailingNewlineShape is the F02 regression gate: an
// all-accepted composition must equal the live file byte-for-byte, including its
// trailing-newline shape. Otherwise a live file with no final newline composes to
// live+"\n", which the shared route writes and then wedges the target in a
// permanent, unclearable StateConflict.
func TestApplyHunks_PreservesTrailingNewlineShape(t *testing.T) {
repo := "a\nb\nc\n"

// Live WITHOUT a trailing newline: composition must not append one.
live := "a\nX\nc"
hunks := diffHunks(repo, live)
accepted := make([]bool, len(hunks))
for i := range accepted {
accepted[i] = true
}
if got := applyHunks(repo, hunks, accepted, endsWithNewline([]byte(live))); got != live {
t.Errorf("no-trailing-newline: composition = %q, want live %q", got, live)
}

// Live WITH a trailing newline: composition must keep it.
live2 := "a\nX\nc\n"
hunks2 := diffHunks(repo, live2)
accepted2 := make([]bool, len(hunks2))
for i := range accepted2 {
accepted2[i] = true
}
if got := applyHunks(repo, hunks2, accepted2, endsWithNewline([]byte(live2))); got != live2 {
t.Errorf("trailing-newline: composition = %q, want live %q", got, live2)
}
}
19 changes: 10 additions & 9 deletions cmd/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,16 +214,17 @@ func classifyExportEntry(repoRoot, rel string, includeLocal bool) ([]byte, strin
return nil, "unreadable", false
}
// Binary (non-text) content: the line-based text secret gate can't scan it, but a
// binary can still carry embedded private-key material. Run the BINARY-SAFE key
// marker scan (secret.HasKeyMarker) over the raw bytes; if it hits, WITHHOLD it
// (never bundle key material). This scan is symmetric with import/validate, which
// applies the SAME HasKeyMarker check to binary payloads, so an export always
// re-imports and a binary carrying key bytes is refused on both sides. A clean
// binary is included (already tracked/user-vetted, past every path/symlink/~.ssh
// gate).
// binary can still carry embedded private-key material OR a constant-prefix
// provider token (a GitHub PAT, AWS key, …) as an ASCII substring. Run the
// BINARY-SAFE scan (secret.HasBinarySecret) over the raw bytes; if it hits,
// WITHHOLD it (never bundle secret material). This scan is symmetric with
// import/validate, which applies the SAME check to binary payloads, so an export
// always re-imports and a binary carrying key/token bytes is refused on both
// sides. A clean binary is included (already tracked/user-vetted, past every
// path/symlink/~.ssh gate).
if !isProbablyText(data) {
if secret.HasKeyMarker(data) {
return nil, "contains key material", false
if secret.HasBinarySecret(data) {
return nil, "contains key material or a provider token", false
}
return data, "", true
}
Expand Down
22 changes: 10 additions & 12 deletions cmd/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,19 @@ func runImport(c *cobra.Command, args []string) error {
if err != nil {
return err
}
// Guard the target against ~/.ssh and harden its config-dir chain (the default
// lives under ~/.config/ferry): bundle content is only ever written here.
// Guard the target against ~/.ssh and harden its parent chain against symlink
// redirection: bundle content is only ever written here. HardenStoreDir walks
// from $HOME down to the target refusing any symlink component, and is a no-op
// for a target that is not under $HOME — so this applies to BOTH the default
// (~/.config/ferry) and an explicit --out under $HOME, closing the gap where a
// custom target's symlinked parent could redirect the extracted tree while the
// default target was hardened. An --out deliberately placed outside $HOME stays
// unrestricted (HardenStoreDir returns nil there), preserving --out's freedom.
if _, err := guardRepoPath("import target", target); err != nil {
return err
}
if isDefaultImportTarget(outDir) {
if err := hardenConfigDirForRepo(target); err != nil {
return err
}
if err := hardenConfigDirForRepo(target); err != nil {
return err
}

// No-clobber (N3): refuse a target that exists and is non-empty. Checked before
Expand Down Expand Up @@ -242,12 +246,6 @@ func resolveImportTarget(outDir string) (string, error) {
return defaultRepoDir()
}

// isDefaultImportTarget reports whether the import target is ferry's default under
// ~/.config/ferry (so its config-dir chain is hardened, mirroring init).
func isDefaultImportTarget(outDir string) bool {
return strings.TrimSpace(outDir) == ""
}

func init() {
importCmd.Flags().String("out", "", "target repo directory (default ~/.config/ferry/repo; must be empty/absent)")
importCmd.Flags().String("expect-sha256", "", "verify the bundle's overall SHA256 before importing (out-of-band tamper check)")
Expand Down
56 changes: 54 additions & 2 deletions cmd/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func init() {
// when stdin is not a terminal, so evals never hang on empty stdin).
restoreCmd.Flags().Bool("packages", false, "also uninstall packages ferry recorded as self-installed")
restoreCmd.Flags().Bool("yes", false, "skip the confirmation prompt")
restoreCmd.Flags().String("undo", "", "re-apply a pre-restore snapshot by ID (printed after a restore), undoing that restore")
restoreCmd.Flags().Bool("purge-without-recovery", false, "remove ferry's own config AND the backup store after restore — DESTROYS the ability to undo this restore or re-restore later (irreversible; the default keeps the backup store)")
}

Expand Down Expand Up @@ -62,6 +63,17 @@ func runRestore(c *cobra.Command, args []string) (retErr error) {

out := c.OutOrStdout()

// --undo <id> re-applies a pre-restore snapshot, reversing an earlier restore.
// It is its own self-contained, mutating path (takes the apply lock, registers
// terminal domains, replays the snapshot) and ignores the baseline/scoped/purge
// flow, so guard against combining it with those.
if undoID, _ := c.Flags().GetString("undo"); undoID != "" {
if len(args) > 0 || purge || doPackages {
return errors.New("restore --undo cannot be combined with domain arguments, --packages, or --purge-without-recovery")
}
return runUndoRestore(ctx, undoID, out)
}

// A full restore reverts the machine, so confirm — but stay non-interactive
// safe: proceed without prompting when --yes is set or stdin is not a TTY.
if !confirmRestore(c, yes, args, out) {
Expand Down Expand Up @@ -191,6 +203,46 @@ func confirmPurge(c *cobra.Command, yes bool, out io.Writer) bool {
return strings.TrimSpace(line) == "yes"
}

// runUndoRestore re-applies a previously taken pre-restore snapshot, reversing an
// earlier `ferry restore`. It mutates managed paths, so it takes the SAME apply
// lock as apply/restore and registers the terminal preference domains first (so a
// snapshotted resource entry restores through its Resource hook), then replays the
// snapshot via the engine. This is the user-facing entry point the post-restore
// "undo with ..." message names — without it the snapshot was written but never
// reachable.
func runUndoRestore(ctx *cmdContext, snapID string, out io.Writer) (retErr error) {
eng, err := ctx.Engine()
if err != nil {
return err
}
lock, lockErr := eng.Lock()
if lockErr != nil {
var held *backup.ErrLockHeld
if errors.As(lockErr, &held) {
return fmt.Errorf("another ferry apply is in progress (pid %d); try again later", held.OwnerPID)
}
return fmt.Errorf("acquire apply lock: %w", lockErr)
}
defer func() {
if uErr := lock.Unlock(); uErr != nil {
if retErr == nil {
retErr = fmt.Errorf("release apply lock: %w (the lock may be stale; remove it before the next apply)", uErr)
} else {
fmt.Fprintf(out, "warning: failed to release apply lock: %v; the lock may be stale and block the next apply\n", uErr)
}
}
}()

if err := registerTerminalDomains(ctx); err != nil {
return err
}
if err := eng.RestoreSnapshot(snapID); err != nil {
return fmt.Errorf("undo restore from snapshot %q: %w (run `ferry restore` first to produce a snapshot, and pass the ID it printed)", snapID, err)
}
fmt.Fprintf(out, "restore: undid the previous restore from snapshot %s\n", snapID)
return nil
}

// fullRestore reverts every managed path to its baseline. The engine snapshots
// current state first (built into Restore), so the revert is itself reversible.
func fullRestore(ctx *cmdContext, out io.Writer) error {
Expand All @@ -204,7 +256,7 @@ func fullRestore(ctx *cmdContext, out io.Writer) error {
}
fmt.Fprintln(out, "restore: reverted all managed paths to their pre-ferry baseline")
if snapID != "" {
fmt.Fprintf(out, "restore: pre-restore state snapshotted (%s); undo is possible if needed\n", snapID)
fmt.Fprintf(out, "restore: pre-restore state snapshotted (%s); undo with `ferry restore --undo %s`\n", snapID, snapID)
}
return nil
}
Expand Down Expand Up @@ -276,7 +328,7 @@ func scopedRestore(ctx *cmdContext, domains []string, out io.Writer) error {
}
fmt.Fprintf(out, "restore: reverted %s to baseline\n", strings.Join(restored, ", "))
if snapID != "" {
fmt.Fprintf(out, "restore: pre-restore state snapshotted (%s)\n", snapID)
fmt.Fprintf(out, "restore: pre-restore state snapshotted (%s); undo with `ferry restore --undo %s`\n", snapID, snapID)
}
return nil
}
Expand Down
20 changes: 14 additions & 6 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,10 @@ func runSync(c *cobra.Command, _ []string) error {
return rollback(snap, repo, err)
}

ahead, behind := aheadBehind(repo, "HEAD", upstream)
ahead, behind, aberr := aheadBehind(repo, "HEAD", upstream)
if aberr != nil {
return rollback(snap, repo, aberr)
}
switch {
case behind > 0 && ahead == 0:
// Pure fast-forward: reset the worktree to the remote tip. --hard is safe
Expand Down Expand Up @@ -839,19 +842,24 @@ func untrackedClobberErr(rel string) error {
}

// aheadBehind returns how many commits `ref` is ahead of and behind `base`
// (git rev-list --count --left-right base...ref). Zero/zero on error.
func aheadBehind(repo, ref, base string) (ahead, behind int) {
// (git rev-list --count --left-right base...ref). It returns an error rather than
// masking a git failure as 0/0: the only caller runs it against an upstream it has
// already verified exists, so a rev-list failure (or unparseable output) is a real
// fault. Reporting 0/0 there would silently skip remote integration and let a
// divergent branch push without ever pulling — data-loss-shaped. The caller rolls
// back on the error instead.
func aheadBehind(repo, ref, base string) (ahead, behind int, err error) {
o, ok := gitSyncOK(repo, "rev-list", "--left-right", "--count", base+"..."+ref)
if !ok {
return 0, 0
return 0, 0, fmt.Errorf("sync: could not compute ahead/behind for %s...%s (git rev-list failed)", base, ref)
}
f := strings.Fields(o)
if len(f) != 2 {
return 0, 0
return 0, 0, fmt.Errorf("sync: unexpected rev-list output %q computing ahead/behind", strings.TrimSpace(o))
}
behind = atoiSafe(f[0]) // commits in base not in ref
ahead = atoiSafe(f[1]) // commits in ref not in base
return ahead, behind
return ahead, behind, nil
}

// pushRangeCommits computes the EXACT list of commit oids that a push would make
Expand Down
1 change: 1 addition & 0 deletions docs/reference/cli/ferry_restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ ferry restore [flags]
-h, --help help for restore
--packages also uninstall packages ferry recorded as self-installed
--purge-without-recovery remove ferry's own config AND the backup store after restore — DESTROYS the ability to undo this restore or re-restore later (irreversible; the default keeps the backup store)
--undo string re-apply a pre-restore snapshot by ID (printed after a restore), undoing that restore
--yes skip the confirmation prompt
```

Expand Down
Loading