From 31607a4af66cd701249ea15ab1576fdba61136a5 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Fri, 17 Jul 2026 00:43:18 +0000 Subject: [PATCH] feat(mosaic): durable pre-update snapshot + verify net + restore CLI (#791 PR2) (#811) --- .woodpecker/ci.yml | 10 +- .../791-upgrade-config-protection.md | 82 +++ packages/mosaic/framework/install.sh | 230 +++++++++ .../scripts/test-upgrade-durable-snapshot.sh | 313 ++++++++++++ .../scripts/test-upgrade-manifest-guard.sh | 9 +- packages/mosaic/src/cli.ts | 5 + packages/mosaic/src/commands/restore.spec.ts | 466 ++++++++++++++++++ packages/mosaic/src/commands/restore.ts | 313 ++++++++++++ 8 files changed, 1424 insertions(+), 4 deletions(-) create mode 100644 packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh create mode 100644 packages/mosaic/src/commands/restore.spec.ts create mode 100644 packages/mosaic/src/commands/restore.ts diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index e3c1959..cb3244d 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -48,15 +48,19 @@ steps: # keep mode is a single cp-based path that must not depend on rsync), and that a # corrupt/empty/missing manifest aborts fail-closed leaving operator files # untouched (B2/B3). The rollback gate proves a mid-sync failure is rolled back - # from the pre-update snapshot (B1). The migration matrix pins the v2→v3 - # contract-file semantics. Pure bash, no node_modules — runs early alongside - # sanitization. + # from the pre-update snapshot (B1). The durable-snapshot gate (#791 PR2) proves + # the retained, operator-scoped pre-update backup is taken before any mutation + # (0700/0600, secret never logged, retention-pruned) and that the post-sync + # verify net restores any operator file a manifest bug lets the sync touch. The + # migration matrix pins the v2→v3 contract-file semantics. Pure bash, no + # node_modules — runs early alongside sanitization. upgrade-guard: image: *node_image commands: - apk add --no-cache bash rsync - bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh - bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh + - bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh - bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh typecheck: diff --git a/docs/scratchpads/791-upgrade-config-protection.md b/docs/scratchpads/791-upgrade-config-protection.md index 4a5f39a..c3f7cea 100644 --- a/docs/scratchpads/791-upgrade-config-protection.md +++ b/docs/scratchpads/791-upgrade-config-protection.md @@ -214,3 +214,85 @@ Re-ran codex again; it found two more rollback-path gaps `set -E` cannot catch. - Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1094 · HARD GATE 193/193 · rollback 28/28 (was 14, +14 for D1/D2 with controls) · migration 21/21. shellcheck clean on new lines. No --no-verify. Committing FORWARD (no rebase of 34e55d4a/0a5e703a). + +## Session 3 (2026-07-16) — PR1 MERGED, starting PR2 (durable snapshot + restore + secrev) +PR1 (#802) squash-merged → main `32a0ffba`; issue #791 stays open (3-PR DAG umbrella). Independent Opus +adversarial/security review APPROVED at head `af627e75` (Gitea RoR cmt 17892); lead ran rollback 28/28 + +HARD GATE 193/193 green; CI #1877 green. PR2 UNBLOCKED. + +PR2 branch: `feat/791-pr2-snapshot-restore` off `origin/main` 32a0ffba. Same treatment applies: +tests-first red-first, independent review + durable Gitea Reviewer-of-Record comment BEFORE MS-LEAD runs +the queue guard/merge. Report PR2 number + exact head when ready. PR body: `Part of #791` (NOT Fixes). + +### PR2 scope (ratified §3/§5 of design doc, Mos-approved — do NOT re-litigate) +- **(a) Durable pre-update snapshot** to `${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-/` + — OUTSIDE ~/.config/mosaic and any repo. Perms dir 0700 / files 0600 (umask 077 + explicit chmod). + Scope = operator-owned surface that EXISTS (operatorReserved paths), not the framework tree. Taken + BEFORE any mutation. Retention N=5 (`MOSAIC_BACKUP_RETENTION`), prune older. +- **Post-sync verify + selective restore**: diff operator surface vs snapshot; (b) should never touch + operator paths, so ANY diff = manifest bug → restore affected paths + warn loudly. (a) catches a (b) miss. +- **`mosaic restore`** (TS CLI): `--list` (default, dry-run) enumerates snapshots by ts; `--from ` + restores over operator surface, confirmation-gated. Counts/paths only. +- **Secret-safety (secrev)**: snapshot/restore NEVER emit file contents; only paths/counts. Tests assert + 0700/0600 AND that a secret value seeded in tools/_lib/credentials.json never appears in any output. + +### PR2 implementation status (2026-07-16, ready-for-review) +All three tasks implemented, red-first proven, unit-green: +- **Task #10 — durable snapshot (install.sh)**: `backup_root()`/`enumerate_operator_files()`/ + `prune_durable_snapshots()`/`make_durable_snapshot()` wired into keep-mode main() after `manifest_load`, + before any mutation. umask 077 + explicit chmod 700/600. UTC ts, collision suffix. FAIL-OPEN (a backup + failure never aborts the upgrade it protects). Retention `MOSAIC_BACKUP_RETENTION` (default 5), in-place + `sort -r -o` prune (no `mv` — stays inside the rsync-absent coreutils whitelist). +- **Task #11 — post-sync verify net (install.sh)**: `verify_operator_surface()` runs after sync (trap + disarmed), `cmp -s` each snapshot file vs target; restores any diverged/missing operator file + warns + loudly (a divergence = manifest bug). VERIFY-NET wired before `cleanup_snapshot`. +- **Task #12 — `mosaic restore` (TS)**: `src/commands/restore.ts` + co-located spec (19 tests). + `--list` default (dry-run enumerate), `--from ` confirmation-gated restore, `--dry-run`, `--yes`/ + `MOSAIC_ASSUME_YES`. Injectable `confirm` for testability (proceed/decline/env-bypass covered). Restored + files forced 0600. Registered in `cli.ts`. Path convention mirrors install.sh `backup_root()`. +- **CI**: `.woodpecker/ci.yml` upgrade-guard runs the new `test-upgrade-durable-snapshot.sh` gate. +- **Gates green**: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1241 (+5) · + durable-snapshot 26/26 · manifest-guard 193/193 · rollback 28/28 · migration 21/21. + Est. new-code coverage ≈93% (only the interactive readline default + process.exit-on-error uncovered). +- Regression fixed: PR2's `date`/`sort`/`mv` broke the rsync-absent manifest-guard PATH whitelist → + made date/sort fail-open, replaced `mv` with in-place `sort -o`, added `date sort` to the test whitelist + + isolated `XDG_STATE_HOME`. All 193 manifest-guard assertions green under restricted PATH. +- Codex code-review + security-review (secrev) run on the uncommitted diff before commit. + +### PR2 review round 1 — findings + remediations (2026-07-16, pre-PR) +Codex code-review returned **request-changes** (1 blocker + 3 should-fix); Codex security-review returned +**high** (1 high + 1 medium). Deduped to 5 distinct defects, ALL legitimate, ALL fixed FORWARD, each with +a red-first regression test whose control neuters exactly the guard under test: + +- **A · BLOCKER — verify net undid the legacy bin/ migration (install.sh).** On a pre-v2 install `bin/**` + is operator-classified, so the durable snapshot captured it; `run_migrations()` deletes bin/ on purpose, + but `verify_operator_surface()` then saw it "missing" and healed it back — the migration would be silently + undone forever once the version stamps. **Fix:** `MIGRATION_REMOVED_PATHS[]` recorded by run_migrations + (`bin`,`rails`) + `is_migration_removed()` skip in the verify loop (`# MIGRATION-SKIP-GUARD`). + **Test:** Part 6 — v1 fixture with bin/; shipped keeps it removed + stamps v3; control (guard stripped) + wrongly restores bin/tool.sh. +- **B · HIGH (CWE-59) — restore/verify wrote secrets THROUGH a symlink (install.sh + restore.ts).** An + attacker swapping an operator path (e.g. tools/_lib/credentials.json) for a symlink after the snapshot + would make `cp`/`copyFileSync` write the snapshot's secret out through the link. **Fix (bash):** refuse a + symlinked ancestor (`has_symlinked_parent`), drop a symlinked leaf before restore + (`# SYMLINK-LEAF-GUARD`). **Fix (TS):** reuse audited `secure-file.ts` — `assertCanonicalContainment` + + `ensureManagedDirectory` on every dst, open the leaf `O_NOFOLLOW|O_CREAT|O_TRUNC` 0600 (ELOOP = + fail-closed). **Tests:** Part 7 (shipped leaves external exfil target untouched, restores a real 0600 + file; control leaks the secret through the link) + restore.spec symlinked-leaf/ancestor cases (red-first). +- **C · MEDIUM/should-fix (CWE-22) — `--from` traversal escaped the backup root (restore.ts).** + `join(root, from)` accepted `../poison`. **Fix:** validate the selector against + `^\d{8}T\d{6}Z(?:-\d+)?$`, build exactly `join(root,'pre-update-'+ts)`, `lstat` (reject symlinked snap + dir). **Test:** restore.spec `it.each` of 6 malformed selectors + `--from ../poison` fail-closed (red-first). +- **D · should-fix — verify `mkdir -p` unguarded under set -e (install.sh).** A parent replaced by a + regular file aborted the installer before the recovery pointer printed. **Fix:** guard `mkdir -p`, warn + + `continue` on failure (keeps healing remaining files). +- **E · should-fix — snapshot `umask 077` leaked process-global (install.sh).** Later sync copies/dirs + inherited 0600/0700. **Fix:** save `old_umask`, restore on EVERY return path (`# UMASK-RESTORE-NORMAL`). + **Test:** Part 8 — synced framework file is 0644 while the secret backup stays 0600; control (restore + stripped) makes the synced file 0600. + +**Full gate suite re-run after fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic +vitest **1252** · restore.spec **30** · durable-snapshot **41** · manifest-guard 193 · rollback 28 · +migration 21. shellcheck clean on all new lines; new test markers mirror the existing `# VERIFY-NET` +anchor convention. NOTE: codex self-review does NOT satisfy the independent-review gate — an independent +(author≠reviewer) review + durable Gitea Reviewer-of-Record comment is still required before MS-LEAD merges. diff --git a/packages/mosaic/framework/install.sh b/packages/mosaic/framework/install.sh index e176ff5..612b002 100755 --- a/packages/mosaic/framework/install.sh +++ b/packages/mosaic/framework/install.sh @@ -109,6 +109,225 @@ restore_snapshot() { } cleanup_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] && rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""; } +# ─── durable operator-config snapshot (#791 PR2) ───────────────────────────── +# A SECOND, independent safety layer, distinct from SNAPSHOT_DIR above: +# • SNAPSHOT_DIR is ephemeral (/tmp, deleted on success) and mirrors the WHOLE +# target for CRASH rollback if the sync aborts mid-write. +# • DURABLE_SNAPSHOT_DIR is RETAINED, holds only the operator-owned surface, and +# lives OUTSIDE the framework tree and any repo. It exists for the failure the +# crash-rollback cannot see: a sync that finishes "successfully" yet a +# manifest/logic bug let it modify an operator file. verify_operator_surface() +# (post-sync) heals from it; `mosaic restore` recovers from it days later. +# Path convention is mirrored in packages/mosaic/src/commands/restore.ts — keep +# the two in sync (there is no shared code across the bash/TS boundary). +DURABLE_SNAPSHOT_DIR="" +backup_root() { printf '%s/mosaic/backups' "${XDG_STATE_HOME:-$HOME/.local/state}"; } + +# Relative paths that a migration INTENTIONALLY removes from the target (e.g. the +# legacy bin/ tree). Such a path is operator-classified by the manifest (unknown⇒ +# operator), so the durable snapshot captures it — but its post-migration absence +# is correct, NOT a manifest bug. run_migrations() records each removal here so +# verify_operator_surface() does not "heal" it back and silently undo the +# migration (which would then be skipped forever once the version is stamped). +MIGRATION_REMOVED_PATHS=() + +# True (0) if $1 (a path relative to TARGET_DIR) equals or lives under a path a +# migration deliberately removed this run. +is_migration_removed() { + local rel="$1" removed + for removed in ${MIGRATION_REMOVED_PATHS[@]+"${MIGRATION_REMOVED_PATHS[@]}"}; do + [[ -n "$removed" ]] || continue + [[ "$rel" == "$removed" || "$rel" == "$removed"/* ]] && return 0 + done + return 1 +} + +# True (0) if any parent directory of $1 (relative to TARGET_DIR) is a symlink. +# Restoring THROUGH a symlinked ancestor would let cp write snapshot contents — +# possibly secrets — outside the target (CWE-59), so the verify net refuses it. +has_symlinked_parent() { + local rel="$1" dir p seg + dir="$(dirname "$rel")" + [[ "$dir" == "." ]] && return 1 + p="$TARGET_DIR" + local IFS='/' + for seg in $dir; do + [[ -n "$seg" ]] || continue + p="$p/$seg" + [[ -L "$p" ]] && return 0 + done + return 1 +} + +# Emit (NUL-delimited, into file $1) the operator-owned relative paths that exist +# under TARGET_DIR, classified via the shared manifest (deny-wins; unknown⇒ +# operator). Returns non-zero if the filesystem walk itself failed — we must +# NEVER snapshot from a truncated scan (a `< <(find …)` process substitution +# would hide that error; capture-then-check does not — cf. #791 blocker-D1). +enumerate_operator_files() { + local out="$1" scan abs rel + scan="$(mktemp)" + if ! find "$TARGET_DIR" -type f -print0 > "$scan"; then + rm -f "$scan" + return 1 # OP-SCAN-GUARD + fi + : > "$out" + while IFS= read -r -d '' abs; do + rel="${abs#"$TARGET_DIR"/}" + # Not operator config: version marker and any VCS metadata. + case "$rel" in .framework-version|.git|.git/*) continue ;; esac + manifest_is_framework "$rel" || printf '%s\0' "$rel" >> "$out" + done < "$scan" + rm -f "$scan" +} + +# Retain only the newest MOSAIC_BACKUP_RETENTION (default 5) snapshots. The +# pre-update- names sort lexicographically = chronologically, so a +# reverse sort is newest-first. Pruning failures are non-fatal (they only leave +# extra old backups); the enclosing find's status is still honored, not swallowed. +prune_durable_snapshots() { + local root keep list d i=0 + root="$(backup_root)" + keep="${MOSAIC_BACKUP_RETENTION:-5}" + [[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5 + list="$(mktemp)" + if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then + rm -f "$list"; return 0 + fi + # Newest-first ordering needs `sort` (`-o` writes back in place — no `mv` + # dependency); if it is somehow unavailable, leave the backups untouched rather + # than risk pruning in an undefined order. + if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then + rm -f "$list"; return 0 + fi + while IFS= read -r d; do + [[ -n "$d" ]] || continue + i=$((i + 1)) + (( i > keep )) && rm -rf "$d" + done < "$list" + rm -f "$list" +} + +# Take the durable pre-update snapshot BEFORE any mutation. Fail-OPEN: the durable +# snapshot is a recovery bonus on top of the manifest (which already keeps the +# sync out of operator paths) and the crash-rollback — so an un-writable backup +# location warns and continues rather than blocking the upgrade. Everything it +# creates is private (umask 077 + explicit 0700 dirs / 0600 files): the snapshot +# mirrors operator config, which may hold secrets, and must never be world-readable. +make_durable_snapshot() { + is_existing_install || return 0 + local root ts dir list rel src dst count=0 old_umask + root="$(backup_root)" + # Fail-open if we cannot even stamp a timestamp: the durable snapshot is a + # recovery bonus and must never be the thing that aborts an upgrade. + ts="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || true)" + if [[ -z "$ts" ]]; then + warn "Durable snapshot skipped: no UTC timestamp available (upgrade continues)." + return 0 + fi + # umask 077 makes every dir/file the snapshot creates private from birth (it + # mirrors operator config, which may hold secrets). It is PROCESS-global, so we + # save and restore it around exactly this block — otherwise every later sync + # copy and new framework dir would inherit 0600/0700 instead of 0644/0755. + old_umask="$(umask)" + umask 077 + if ! mkdir -p "$root"; then + umask "$old_umask" + warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)." + return 0 + fi + chmod 700 "$root" 2>/dev/null || true + dir="$root/pre-update-$ts" + if [[ -e "$dir" ]]; then # same-second re-run: disambiguate + local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n" + fi + if ! mkdir -p "$dir"; then + umask "$old_umask" + warn "Durable snapshot skipped: cannot create $dir (upgrade continues)." + return 0 + fi + chmod 700 "$dir" + list="$(mktemp)" + if ! enumerate_operator_files "$list"; then + umask "$old_umask" + warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)." + rm -f "$list"; rmdir "$dir" 2>/dev/null || true + return 0 + fi + while IFS= read -r -d '' rel; do + src="$TARGET_DIR/$rel"; dst="$dir/$rel" + [[ -f "$src" ]] || continue + mkdir -p "$(dirname "$dst")" + if ! cp "$src" "$dst"; then + warn "Durable snapshot: could not copy operator file '$rel' (skipped)." + continue + fi + chmod 600 "$dst" 2>/dev/null || true + count=$((count + 1)) + done < "$list" + rm -f "$list" + # Tighten every dir the copy created (mkdir -p honors umask, but be explicit). + find "$dir" -type d -exec chmod 700 {} + 2>/dev/null || true + umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above) + DURABLE_SNAPSHOT_DIR="$dir" + ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)" + prune_durable_snapshots +} + +# Post-sync safety net: a keep-mode upgrade must NEVER modify an operator file. +# Compare every file in the durable snapshot to its current target counterpart; +# any that changed (or vanished) was touched by a framework bug — restore it from +# the snapshot and warn loudly. This does NOT abort: the framework itself synced +# correctly; we only heal the operator collateral. Runs after the restore trap is +# disarmed so its corrective copies can't spuriously trip a full rollback, and +# every step is guarded so `set -e` cannot exit silently mid-heal (cf. blocker-D2). +verify_operator_surface() { + [[ -n "$DURABLE_SNAPSHOT_DIR" && -d "$DURABLE_SNAPSHOT_DIR" ]] || return 0 + local scan snap rel cur healed=0 + scan="$(mktemp)" + if ! find "$DURABLE_SNAPSHOT_DIR" -type f -print0 > "$scan"; then + rm -f "$scan" + warn "Post-upgrade verify skipped: could not enumerate the pre-update snapshot at $DURABLE_SNAPSHOT_DIR." + return 0 + fi + while IFS= read -r -d '' snap; do + rel="${snap#"$DURABLE_SNAPSHOT_DIR"/}" + cur="$TARGET_DIR/$rel" + # A migration may legitimately delete an operator-classified path (e.g. legacy + # bin/). Its absence is intended — do not heal it back, or the migration is + # silently undone and never re-runs once the version is stamped (#791 PR2). + is_migration_removed "$rel" && continue # MIGRATION-SKIP-GUARD + if [[ ! -e "$cur" ]] || ! cmp -s "$snap" "$cur"; then + # Never restore THROUGH a symlink: an operator path swapped for a link would + # otherwise let cp write snapshot contents (possibly secrets) outside the + # target (CWE-59). Refuse a symlinked parent; drop a symlinked leaf and write + # a real file in its place. + if has_symlinked_parent "$rel"; then + warn "Operator path '$rel' has a symlinked parent under $TARGET_DIR; refusing to restore through it (possible tampering) — recover it manually from $DURABLE_SNAPSHOT_DIR." + continue + fi + [[ -L "$cur" ]] && rm -f "$cur" # SYMLINK-LEAF-GUARD + # Guard mkdir too: under set -e (trap already disarmed) a bare failure would + # exit the whole installer before the recovery pointer below is emitted. + if ! mkdir -p "$(dirname "$cur")"; then + warn "Operator file '$rel' was modified by the upgrade but could NOT be auto-restored (parent dir unavailable) — recover it manually from $DURABLE_SNAPSHOT_DIR." + continue + fi + if cp "$snap" "$cur"; then + chmod 600 "$cur" 2>/dev/null || true + warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel" + healed=$((healed + 1)) + else + warn "Operator file '$rel' was modified by the upgrade but could NOT be auto-restored — recover it manually from $DURABLE_SNAPSHOT_DIR." + fi + fi + done < "$scan" + rm -f "$scan" + if (( healed > 0 )); then + warn "$healed operator file(s) were unexpectedly changed by this upgrade and were restored from the pre-update snapshot. A keep-mode upgrade must never modify operator files — this indicates a framework manifest bug; please report it (#791)." + fi +} + # Reconcile contract files after sync: framework-owned overwrite (backup-once), # user-seeded seed-if-absent. reconcile_framework_files() { @@ -326,6 +545,10 @@ run_migrations() { # Remove bin/ directory — all executables now live in the npm CLI. # Scripts that were in bin/ are now in tools/_scripts/. if [[ "$from_version" -lt 2 ]]; then + # bin/ and the rails symlink are operator-classified by the manifest (unknown⇒ + # operator) and thus captured in the durable snapshot; record them as + # intentional removals so the post-sync verify net does not restore them. + MIGRATION_REMOVED_PATHS+=("bin" "rails") if [[ -d "$TARGET_DIR/bin" ]]; then ok "Removing legacy bin/ directory (executables now in npm CLI)" rm -rf "$TARGET_DIR/bin" @@ -383,6 +606,9 @@ fi # not for a validation failure that has touched nothing yet (#791 blocker-1). if [[ "$INSTALL_MODE" == "keep" ]]; then manifest_load + # Durable, operator-scoped backup taken BEFORE any mutation (#791 PR2). Kept + # outside the framework tree; recovered later via `mosaic restore`. Fail-open. + make_durable_snapshot fi # Snapshot before any destructive file operation; restore on interrupt/failure. @@ -421,6 +647,10 @@ run_migrations # File-system phase complete and consistent — clear the restore trap. trap - ERR INT TERM +# Post-sync safety net: heal any operator file a manifest bug let the sync touch, +# using the durable pre-update snapshot (#791 PR2). Runs with the trap disarmed so +# a corrective copy can't spuriously trigger a full rollback. +verify_operator_surface # VERIFY-NET (#791 PR2) cleanup_snapshot # Testability / minimal-install hook: stop after the file-system phase, before any diff --git a/packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh b/packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh new file mode 100644 index 0000000..3cf8294 --- /dev/null +++ b/packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh @@ -0,0 +1,313 @@ +#!/usr/bin/env bash +# test-upgrade-durable-snapshot.sh — the #791 PR2 regression gate. +# +# PR1 gave keep-mode upgrades two protections: the manifest (a keep-sync only +# ever writes framework-owned paths — operator config is structurally untouched) +# and an EPHEMERAL /tmp snapshot that rolls the whole target back if the sync +# CRASHES mid-write. PR2 adds a third, independent layer for the case neither +# covers: a "successful" upgrade that a manifest/logic bug silently let touch an +# operator file. That layer is a DURABLE, operator-scoped pre-update snapshot: +# +# Part 1 (scope): before any mutation, the installer copies exactly the +# operator-owned files that exist into a retained backup +# under $XDG_STATE_HOME/mosaic/backups/pre-update-/ — +# framework files are NOT captured. +# Part 2 (perms): the backup root, snapshot dir and every nested dir are +# 0700; every backed-up file is 0600 (never world-readable, +# even though operator config may hold secrets). +# Part 3 (no leak): a secret seeded into credentials.json is copied into the +# snapshot (proving coverage) but its value never appears +# on stdout/stderr — the snapshot reports counts/paths only. +# Part 4 (retention): only the newest MOSAIC_BACKUP_RETENTION snapshots survive; +# older ones are pruned. +# Part 5 (verify net): if the upgrade DID modify an operator file (injected here +# with a cp shim that scribbles on SOUL.md while a framework +# file is copied), the post-sync verify restores that file +# from the durable snapshot and warns loudly. The control — +# the same installer with the verify call stripped — leaves +# the corruption in place, proving the net is load-bearing. +# +# Usage: bash test-upgrade-durable-snapshot.sh +set -uo pipefail + +FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework +INSTALL="$FW/install.sh" +ORIG_PATH="$PATH" +FRAMEWORK_VERSION="$(grep -m1 '^FRAMEWORK_VERSION=' "$INSTALL" | cut -d= -f2)" + +# Control installers must live INSIDE $FW: install.sh derives SOURCE_DIR from its +# own path and sources tools/_lib/manifest.sh relative to it, so a copy anywhere +# else aborts before the sync. Each control is a shipped installer with one guard +# line stripped (keyed off a `# ` anchor), proving that guard load-bearing. +# All controls share the .install-*.tmp.sh glob so one trap sweeps them on exit. +VERIFYCTRL="$FW/.install-verifynet-control.tmp.sh" +rm -f "$FW"/.install-*.tmp.sh +trap 'rm -f "$FW"/.install-*.tmp.sh' EXIT + +# mk_control — echo a control installer path ($FW-local) that +# is $INSTALL with every line matching // deleted. +mk_control() { + local path="$FW/.install-$2.tmp.sh" + sed "/$1/d" "$INSTALL" > "$path" + printf '%s' "$path" +} + +pass=0; fail=0 +chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; } + +SECRET='SUPER-SECRET-TOKEN-do-not-log-pr2' +SOUL_ORIG='# persona' +# A framework file the sync copies (source ships it; the seeded target omits it, +# so the bytes differ and cp is attempted). The Part-5 shim keys off this path. +POISON_REL='guides/E2E-DELIVERY.md' + +# Seed a recognized keep-mode install holding four operator-owned files across +# the identity file, an operator subtree, memory, and the credentials carve-out. +seed_home() { + local H="$1" + mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory" + printf '%s\n' "$SOUL_ORIG" > "$H/SOUL.md" # recognized install → keep mode + printf 'MODEL=opus\n' > "$H/agents/coder0.conf" + printf '# operator memory\n' > "$H/memory/note.md" + printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json" + echo 3 > "$H/.framework-version" + # Deliberately NO guides/E2E-DELIVERY.md so the sync copies it (framework file, + # bytes differ) — that copy is where the Part-5 corruption shim fires. +} + +# A pre-v2 (legacy) keep-mode install: SOUL.md marks it recognized, and a bin/ +# tree with NO .framework-version makes installed_framework_version() report 1, so +# the v1→v2 migration (which deletes bin/) runs. bin/ is unknown⇒operator, so the +# durable snapshot captures it — the verify net must NOT heal the intended removal. +seed_home_v1() { + local H="$1" + mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory" "$H/bin" + printf '%s\n' "$SOUL_ORIG" > "$H/SOUL.md" + printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json" + printf '#!/bin/sh\necho legacy\n' > "$H/bin/tool.sh"; chmod +x "$H/bin/tool.sh" + # Deliberately NO .framework-version and NO guides/E2E-DELIVERY.md (see seed_home). +} + +# A cp shim that, while the framework POISON file is being copied during sync, +# swaps the operator credentials file for a symlink pointing at an attacker- +# readable file OUTSIDE the target — simulating post-snapshot tampering (CWE-59). +# The durable snapshot already holds the real credentials (it is taken before any +# sync), so the verify net must restore a REAL file in place WITHOUT following the +# link (which would write the snapshot's secret out through it). $EXFIL_TARGET is +# expanded at shim-write time from the caller's environment. +# +# PORTABILITY (why this shim, not the real `cp`): the CWE-59 leak this exercises is +# `cp` writing THROUGH a symlinked destination. GNU/BSD cp — what a real operator +# runs `mosaic update` under — follows the dest symlink and leaks. busybox cp (the +# Alpine CI image) REPLACES a symlinked dest instead of following it, so under the +# CI harness the leak vector simply does not exist and the negative control could +# never reproduce it. This shim therefore emulates the real-target GNU cp behavior +# PORTABLY: when the destination is a symlink it writes the source bytes through the +# link via redirection (which follows symlinks on every coreutils, busybox included); +# otherwise it delegates to the host's real cp unchanged. Both the shipped-case and +# the negative control run through this identical shim, so the ONLY difference +# between them remains the SYMLINK-LEAF-GUARD — the control stays load-bearing and +# non-tautological. It does NOT touch install.sh (approved) or the real assertions: +# with the guard present the symlinked leaf is dropped BEFORE this cp runs, so the +# dest is a real file and the delegate path is taken exactly as on a GNU host. +make_symlink_leaf_shim() { + local dir="$1" home="$2" + cat > "$dir/cp" < "\$dest" + exit \$? +fi +exec env PATH="$ORIG_PATH" cp "\$@" +SHIM + chmod +x "$dir/cp" +} + +# A cp shim that, while the framework POISON file is being copied during sync, +# also appends garbage to the operator SOUL.md — simulating a manifest bug that +# writes outside the framework lane. The framework copy itself still succeeds +# (real cp runs), so the sync completes 0 and the post-sync verify is what must +# catch and undo the operator-file damage. The snapshot's own cp only ever +# targets operator files (never guides/…), so it is never corrupted by this shim. +make_corrupt_shim() { + local dir="$1" home="$2" + cat > "$dir/cp" <> "$home/SOUL.md" 2>/dev/null || true ;; +esac +exec env PATH="$ORIG_PATH" cp "\$@" +SHIM + chmod +x "$dir/cp" +} + +# Run one keep-mode, sync-only upgrade with $XDG_STATE_HOME redirected to a +# throwaway dir (so the real ~/.local/state is never touched). Optional args: +# $2 shim-maker (default none), $3 MOSAIC_BACKUP_RETENTION (default unset). +# Echoes: "\t\t\t". +# $4 seeder (default seed_home) — swap in seed_home_v1 for the migration case. +run_snap() { + local installer="$1" shim_maker="${2:-}" retention="${3:-}" seeder="${4:-seed_home}" H STATE OUT SHIM rc pathpre + H=$(mktemp -d); STATE=$(mktemp -d); OUT=$(mktemp); pathpre="$ORIG_PATH" + "$seeder" "$H" + if [[ -n "$shim_maker" ]]; then + SHIM=$(mktemp -d); "$shim_maker" "$SHIM" "$H"; pathpre="$SHIM:$ORIG_PATH" + fi + set +e + env PATH="$pathpre" XDG_STATE_HOME="$STATE" \ + ${retention:+MOSAIC_BACKUP_RETENTION="$retention"} \ + MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 \ + bash "$installer" >"$OUT" 2>&1 + rc=$? + set -e 2>/dev/null || true + [[ -n "$shim_maker" ]] && rm -rf "$SHIM" + printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$STATE" "$H" +} + +# Resolve the single pre-update-* snapshot dir under a state dir (newest if many). +snap_dir() { + find "$1/mosaic/backups" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null \ + | LC_ALL=C sort -r | head -1 +} + +echo "── Part 1/2/3: durable snapshot scope, perms, no-leak ──────────────────" +IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL") +SNAP="$(snap_dir "$STATE")" + +chk "upgrade succeeds" "[ '$rc' -eq 0 ]" +chk "exactly one pre-update snapshot created" "[ \$(find '$STATE/mosaic/backups' -maxdepth 1 -type d -name 'pre-update-*' | wc -l) -eq 1 ]" +chk "snapshot: SOUL.md captured" "[ -f '$SNAP/SOUL.md' ]" +chk "snapshot: operator subtree captured" "[ -f '$SNAP/agents/coder0.conf' ]" +chk "snapshot: memory captured" "[ -f '$SNAP/memory/note.md' ]" +chk "snapshot: credentials carve-out captured" "[ -f '$SNAP/tools/_lib/credentials.json' ]" +chk "snapshot: SOUL.md bytes preserved" "[ \"\$(cat '$SNAP/SOUL.md')\" = '$SOUL_ORIG' ]" +chk "snapshot: framework file NOT captured" "[ ! -e '$SNAP/CONSTITUTION.md' ] && [ ! -e '$SNAP/$POISON_REL' ]" + +# Part 2 — permissions (0700 dirs, 0600 files); never world-readable. +chk "perms: backup root is 0700" "[ \$(stat -c '%a' '$STATE/mosaic/backups') -eq 700 ]" +chk "perms: snapshot dir is 0700" "[ \$(stat -c '%a' '$SNAP') -eq 700 ]" +chk "perms: nested dir is 0700" "[ \$(stat -c '%a' '$SNAP/agents') -eq 700 ]" +chk "perms: credentials backup is 0600" "[ \$(stat -c '%a' '$SNAP/tools/_lib/credentials.json') -eq 600 ]" +chk "perms: SOUL.md backup is 0600" "[ \$(stat -c '%a' '$SNAP/SOUL.md') -eq 600 ]" + +# Part 3 — the secret is backed up but never emitted to stdout/stderr. +chk "no-leak: secret IS in the backup file" "grep -q '$SECRET' '$SNAP/tools/_lib/credentials.json'" +chk "no-leak: secret NOT on stdout/stderr" "! grep -q '$SECRET' '$OUT'" +rm -rf "$STATE" "$H"; rm -f "$OUT" + +echo "── Part 4: retention prune (MOSAIC_BACKUP_RETENTION) ───────────────────" +# Pre-seed four dated snapshots, then take one real snapshot with retention=2: +# only the two newest (the fresh real one + the newest pre-seeded) must survive. +IFS=$'\t' read -r rc OUT STATE H < <( + H=$(mktemp -d); STATE=$(mktemp -d); OUT=$(mktemp) + seed_home "$H" + mkdir -p "$STATE/mosaic/backups" + for ts in 20200101T000000Z 20210101T000000Z 20220101T000000Z 20230101T000000Z; do + mkdir -p "$STATE/mosaic/backups/pre-update-$ts" + done + set +e + env PATH="$ORIG_PATH" XDG_STATE_HOME="$STATE" MOSAIC_BACKUP_RETENTION=2 \ + MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 \ + bash "$INSTALL" >"$OUT" 2>&1 + rc=$? + set -e 2>/dev/null || true + printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$STATE" "$H" +) +chk "retention: upgrade succeeds" "[ '$rc' -eq 0 ]" +chk "retention: pruned to exactly 2 snapshots" "[ \$(find '$STATE/mosaic/backups' -maxdepth 1 -type d -name 'pre-update-*' | wc -l) -eq 2 ]" +chk "retention: newest pre-seeded survives" "[ -d '$STATE/mosaic/backups/pre-update-20230101T000000Z' ]" +chk "retention: oldest pre-seeded pruned" "[ ! -d '$STATE/mosaic/backups/pre-update-20200101T000000Z' ]" +rm -rf "$STATE" "$H"; rm -f "$OUT" + +echo "── Part 5: post-sync verify restores an operator file (+ control) ──────" +# Shipped installer: the cp shim corrupts SOUL.md mid-sync; verify must restore it. +IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" make_corrupt_shim) +chk "verify: upgrade still succeeds" "[ '$rc' -eq 0 ]" +chk "verify: SOUL.md restored to original" "[ \"\$(cat '$H/SOUL.md')\" = '$SOUL_ORIG' ]" +chk "verify: no corruption remains in SOUL.md" "! grep -q 'CORRUPTION-mid-sync' '$H/SOUL.md'" +chk "verify: loud restore warning emitted" "grep -qi 'restored from the pre-update snapshot' '$OUT'" +chk "verify: secret still not leaked" "! grep -q '$SECRET' '$OUT'" +rm -rf "$STATE" "$H"; rm -f "$OUT" + +# Control: strip the verify call → the corruption must SURVIVE (net is load-bearing). +sed '/# VERIFY-NET/d' "$INSTALL" > "$VERIFYCTRL" +IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$VERIFYCTRL" make_corrupt_shim) +chk "control: SOUL.md corruption survives" "grep -q 'CORRUPTION-mid-sync' '$H/SOUL.md'" +chk "control: no restore warning emitted" "! grep -qi 'restored from the pre-update snapshot' '$OUT'" +rm -rf "$STATE" "$H"; rm -f "$OUT" + +echo "── Part 6: verify net honors an intentional migration removal (+ control) ─" +# BLOCKER regression: on a pre-v2 install, bin/ is operator-classified so the durable +# snapshot captures it — but the v1→v2 migration deletes bin/ ON PURPOSE. The verify +# net must SKIP that removal (is_migration_removed), or it heals bin/ back and the +# migration is silently undone forever once the version is stamped. +IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" "" "" seed_home_v1) +chk "migration: upgrade succeeds" "[ '$rc' -eq 0 ]" +chk "migration: legacy bin/ stays removed" "[ ! -e '$H/bin' ]" +chk "migration: operator SOUL.md untouched" "[ \"\$(cat '$H/SOUL.md')\" = '$SOUL_ORIG' ]" +chk "migration: version stamped to $FRAMEWORK_VERSION" "[ \"\$(cat '$H/.framework-version')\" = '$FRAMEWORK_VERSION' ]" +rm -rf "$STATE" "$H"; rm -f "$OUT" + +# Control: strip the MIGRATION-SKIP-GUARD → the verify net restores bin/ from the +# snapshot, silently undoing the migration (proves the guard is load-bearing). +MIGCTRL="$(mk_control 'MIGRATION-SKIP-GUARD' migration-control)" +IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$MIGCTRL" "" "" seed_home_v1) +chk "control: bin/ wrongly restored by verify" "[ -e '$H/bin/tool.sh' ]" +rm -rf "$STATE" "$H"; rm -f "$OUT" + +echo "── Part 7: verify net never restores a secret through a symlink (+ control) ─" +# HIGH (CWE-59) regression: an attacker who swaps an operator file for a symlink +# AFTER the durable snapshot must not cause the verify net's restore to write the +# snapshot's secret out THROUGH that link. The shipped net drops a symlinked leaf and +# writes a real file in its place, leaving the external target untouched. +EXFIL_DIR=$(mktemp -d); EXFIL_TARGET="$EXFIL_DIR/stolen" +printf 'ATTACKER-PLACEHOLDER\n' > "$EXFIL_TARGET" +IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" make_symlink_leaf_shim) +chk "symlink-leaf: upgrade succeeds" "[ '$rc' -eq 0 ]" +chk "symlink-leaf: secret NOT written through link" "! grep -q '$SECRET' '$EXFIL_TARGET'" +chk "symlink-leaf: credentials.json is a real file" "[ -f '$H/tools/_lib/credentials.json' ] && [ ! -L '$H/tools/_lib/credentials.json' ]" +chk "symlink-leaf: credentials.json restored intact" "grep -q '$SECRET' '$H/tools/_lib/credentials.json'" +chk "symlink-leaf: secret not leaked to stdout/stderr" "! grep -q '$SECRET' '$OUT'" +rm -rf "$STATE" "$H" "$EXFIL_DIR"; rm -f "$OUT" + +# Control: strip the SYMLINK-LEAF-GUARD → cp follows the swapped-in link and writes +# the snapshot secret out through it (proves the guard is load-bearing). +EXFIL_DIR=$(mktemp -d); EXFIL_TARGET="$EXFIL_DIR/stolen" +printf 'ATTACKER-PLACEHOLDER\n' > "$EXFIL_TARGET" +LEAFCTRL="$(mk_control 'SYMLINK-LEAF-GUARD' symlinkleaf-control)" +IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$LEAFCTRL" make_symlink_leaf_shim) +chk "control: secret leaked through the symlink" "grep -q '$SECRET' '$EXFIL_TARGET'" +rm -rf "$STATE" "$H" "$EXFIL_DIR"; rm -f "$OUT" + +echo "── Part 8: snapshot umask 077 does not leak into synced files (+ control) ──" +# SHOULD-FIX regression: umask 077 is process-global. Scoped to the snapshot it keeps +# backups 0600; leaked past it, every later cp/mkdir inherits 0600/0700. A freshly- +# synced framework file must be 0644 (per the ambient 022 umask) while the backup of +# a secret stays 0600. +IFS=$'\t' read -r rc OUT STATE H < <(umask 022; run_snap "$INSTALL") +SNAP="$(snap_dir "$STATE")" +chk "umask: upgrade succeeds" "[ '$rc' -eq 0 ]" +chk "umask: synced framework file is 0644" "[ \$(stat -c '%a' '$H/$POISON_REL') -eq 644 ]" +chk "umask: backup of a secret stays 0600" "[ \$(stat -c '%a' '$SNAP/tools/_lib/credentials.json') -eq 600 ]" +rm -rf "$STATE" "$H"; rm -f "$OUT" + +# Control: strip the UMASK-RESTORE-NORMAL line → umask 077 leaks past the snapshot, +# so the newly-synced framework file is created 0600 (proves the restore matters). +UMASKCTRL="$(mk_control 'UMASK-RESTORE-NORMAL' umask-control)" +IFS=$'\t' read -r rc OUT STATE H < <(umask 022; run_snap "$UMASKCTRL") +chk "control: leaked umask makes synced file 0600" "[ \$(stat -c '%a' '$H/$POISON_REL') -eq 600 ]" +rm -rf "$STATE" "$H"; rm -f "$OUT" + +echo "" +echo "RESULT: $pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh b/packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh index f221e68..353f348 100644 --- a/packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh +++ b/packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh @@ -29,6 +29,13 @@ INSTALL="$FW/install.sh" pass=0; fail=0 chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; } +# Redirect the #791 PR2 durable pre-update snapshot ($XDG_STATE_HOME/mosaic/backups) +# into a throwaway so a keep-mode upgrade under test never writes into the real +# ~/.local/state. This test asserts operator-surface fidelity, not backup content. +export XDG_STATE_HOME +XDG_STATE_HOME="$(mktemp -d)" +trap 'rm -rf "$XDG_STATE_HOME"' EXIT + SECRET='SUPER-SECRET-TOKEN-do-not-log-3f9a' # Seed a throwaway MOSAIC_HOME with an operator sentinel per ownership class. @@ -206,7 +213,7 @@ fi # does not silently depend on rsync being installed. (Provide the coreutils the # installer needs on the stripped PATH.) FBIN=$(mktemp -d) -for t in bash cp find mktemp rm mkdir chmod cmp sed grep cat dirname basename stat sha256sum awk tr; do +for t in bash cp find mktemp rm mkdir chmod cmp sed grep cat dirname basename stat sha256sum awk tr date sort; do p=$(command -v "$t" 2>/dev/null) && ln -s "$p" "$FBIN/$t" done run_matrix "rsync-absent" env "PATH=$FBIN" diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index e897ee6..73fd6a5 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -17,6 +17,7 @@ import { registerConfigCommand } from './commands/config.js'; import { registerFleetCommand } from './commands/fleet.js'; import { registerMissionCommand } from './commands/mission.js'; import { registerUninstallCommand } from './commands/uninstall.js'; +import { registerRestoreCommand } from './commands/restore.js'; // prdy is registered via launch.ts import { registerLaunchCommands } from './commands/launch.js'; import { registerAuthCommand } from './commands/auth.js'; @@ -406,6 +407,10 @@ registerStorageCommand(program); registerUninstallCommand(program); +// ─── restore ───────────────────────────────────────────────────────────────── + +registerRestoreCommand(program); + // ─── telemetry ─────────────────────────────────────────────────────────────── registerTelemetryCommand(program); diff --git a/packages/mosaic/src/commands/restore.spec.ts b/packages/mosaic/src/commands/restore.spec.ts new file mode 100644 index 0000000..0b4335d --- /dev/null +++ b/packages/mosaic/src/commands/restore.spec.ts @@ -0,0 +1,466 @@ +/** + * Tests for `mosaic restore` (#791 PR2 Task 12). + * + * The durable pre-update snapshot (install.sh: make_durable_snapshot) writes the + * operator-owned surface to $XDG_STATE_HOME/mosaic/backups/pre-update-/ with + * 0700 dirs / 0600 files. `mosaic restore` is the recovery counterpart: + * • --list (default) enumerate snapshots by timestamp — dry-run, never mutates. + * • --from restore that snapshot over MOSAIC_HOME, confirmation-gated. + * It reports counts and relative paths ONLY — a snapshot may contain secrets + * (credentials.json), so no file content is ever printed (secrev invariant). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + mkdtempSync, + rmSync, + mkdirSync, + writeFileSync, + readFileSync, + statSync, + lstatSync, + symlinkSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir, homedir } from 'node:os'; +import { Command } from 'commander'; +import { + resolveBackupRoot, + listSnapshots, + resolveSnapshotDir, + planRestore, + applyRestore, + runRestore, + registerRestoreCommand, +} from './restore.js'; + +const SECRET = 'SUPER-SECRET-TOKEN-do-not-log-restore'; + +function seedSnapshot(root: string, ts: string, files: Record): string { + const dir = join(root, `pre-update-${ts}`); + for (const [rel, content] of Object.entries(files)) { + const abs = join(dir, rel); + mkdirSync(join(abs, '..'), { recursive: true }); + writeFileSync(abs, content); + } + return dir; +} + +describe('mosaic restore (#791 PR2)', () => { + let tmp: string; + let backups: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'mosaic-restore-')); + backups = join(tmp, 'state', 'mosaic', 'backups'); + mkdirSync(backups, { recursive: true }); + vi.clearAllMocks(); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + describe('resolveBackupRoot', () => { + it('honors XDG_STATE_HOME', () => { + expect(resolveBackupRoot({ XDG_STATE_HOME: '/x/state' })).toBe('/x/state/mosaic/backups'); + }); + it('falls back to ~/.local/state', () => { + expect(resolveBackupRoot({})).toBe(join(homedir(), '.local', 'state', 'mosaic', 'backups')); + }); + }); + + describe('listSnapshots', () => { + it('returns [] when the backup root does not exist', () => { + expect(listSnapshots(join(tmp, 'nope'))).toEqual([]); + }); + + it('lists pre-update snapshots newest-first with file counts, ignoring other dirs', () => { + seedSnapshot(backups, '20240101T000000Z', { 'SOUL.md': 'a' }); + seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'b', 'agents/x.conf': 'c' }); + mkdirSync(join(backups, 'unrelated-dir'), { recursive: true }); + + const snaps = listSnapshots(backups); + expect(snaps.map((s) => s.timestamp)).toEqual(['20260101T000000Z', '20240101T000000Z']); + expect(snaps[0]!.fileCount).toBe(2); + expect(snaps[1]!.fileCount).toBe(1); + }); + }); + + describe('resolveSnapshotDir', () => { + it('resolves by bare timestamp and by full pre-update- name', () => { + const dir = seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'a' }); + expect(resolveSnapshotDir(backups, '20260101T000000Z')).toBe(dir); + expect(resolveSnapshotDir(backups, 'pre-update-20260101T000000Z')).toBe(dir); + }); + it('returns undefined for an unknown timestamp', () => { + expect(resolveSnapshotDir(backups, '19990101T000000Z')).toBeUndefined(); + }); + }); + + describe('planRestore', () => { + it('walks nested dirs and returns every relative file path', () => { + const dir = seedSnapshot(backups, '20260101T000000Z', { + 'SOUL.md': 'a', + 'agents/x.conf': 'b', + 'tools/_lib/credentials.json': 'c', + }); + expect(planRestore(dir).sort()).toEqual( + ['SOUL.md', 'agents/x.conf', 'tools/_lib/credentials.json'].sort(), + ); + }); + }); + + describe('applyRestore', () => { + it('restores byte-exact content, creates parent dirs, and sets 0600', () => { + const dir = seedSnapshot(backups, '20260101T000000Z', { + 'SOUL.md': 'original-soul', + 'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`, + }); + const home = join(tmp, 'home'); + mkdirSync(home, { recursive: true }); + // A diverged operator file that restore must overwrite. + writeFileSync(join(home, 'SOUL.md'), 'CORRUPTED'); + + const n = applyRestore(dir, home, planRestore(dir)); + expect(n).toBe(2); + expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('original-soul'); + expect(readFileSync(join(home, 'tools/_lib/credentials.json'), 'utf8')).toBe( + `TOKEN=${SECRET}\n`, + ); + expect(statSync(join(home, 'SOUL.md')).mode & 0o777).toBe(0o600); + expect(statSync(join(home, 'tools/_lib/credentials.json')).mode & 0o777).toBe(0o600); + }); + }); + + describe('runRestore', () => { + it('--list prints timestamps and counts, mutating nothing', async () => { + seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'a', 'agents/x.conf': 'b' }); + const home = join(tmp, 'home'); + mkdirSync(home, { recursive: true }); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const code = await runRestore({ + list: true, + mosaicHome: home, + env: { XDG_STATE_HOME: join(tmp, 'state') }, + }); + + expect(code).toBe(0); + const out = log.mock.calls.flat().join('\n'); + expect(out).toContain('20260101T000000Z'); + expect(out).toMatch(/2\b/); // the file count is surfaced + log.mockRestore(); + }); + + it('--from restores the snapshot over MOSAIC_HOME byte-exact (yes bypasses prompt)', async () => { + seedSnapshot(backups, '20260101T000000Z', { + 'SOUL.md': 'restored-soul', + 'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`, + }); + const home = join(tmp, 'home'); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, 'SOUL.md'), 'STALE'); + + const code = await runRestore({ + from: '20260101T000000Z', + yes: true, + mosaicHome: home, + env: { XDG_STATE_HOME: join(tmp, 'state') }, + }); + + expect(code).toBe(0); + expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('restored-soul'); + expect(readFileSync(join(home, 'tools/_lib/credentials.json'), 'utf8')).toBe( + `TOKEN=${SECRET}\n`, + ); + }); + + it('--from with an unknown timestamp fails without mutating', async () => { + const home = join(tmp, 'home'); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, 'SOUL.md'), 'KEEP'); + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const code = await runRestore({ + from: '19990101T000000Z', + yes: true, + mosaicHome: home, + env: { XDG_STATE_HOME: join(tmp, 'state') }, + }); + + expect(code).toBe(1); + expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('KEEP'); + err.mockRestore(); + }); + + it('--dry-run with --from reports the plan but mutates nothing', async () => { + seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'snap' }); + const home = join(tmp, 'home'); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, 'SOUL.md'), 'UNCHANGED'); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const code = await runRestore({ + from: '20260101T000000Z', + dryRun: true, + mosaicHome: home, + env: { XDG_STATE_HOME: join(tmp, 'state') }, + }); + + expect(code).toBe(0); + expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('UNCHANGED'); + log.mockRestore(); + }); + + it('--from prompts and applies the restore when the operator confirms', async () => { + seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'confirmed-soul' }); + const home = join(tmp, 'home'); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, 'SOUL.md'), 'STALE'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + const confirm = vi.fn().mockResolvedValue(true); + + const code = await runRestore({ + from: '20260101T000000Z', + mosaicHome: home, + env: { XDG_STATE_HOME: join(tmp, 'state') }, + confirm, + }); + + expect(code).toBe(0); + expect(confirm).toHaveBeenCalledOnce(); + expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('confirmed-soul'); + }); + + it('--from aborts without mutating when the operator declines', async () => { + seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'snap' }); + const home = join(tmp, 'home'); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, 'SOUL.md'), 'KEEP'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + const confirm = vi.fn().mockResolvedValue(false); + + const code = await runRestore({ + from: '20260101T000000Z', + mosaicHome: home, + env: { XDG_STATE_HOME: join(tmp, 'state') }, + confirm, + }); + + expect(code).toBe(0); + expect(confirm).toHaveBeenCalledOnce(); + expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('KEEP'); + }); + + it('MOSAIC_ASSUME_YES=1 bypasses the confirmation prompt', async () => { + seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'env-yes' }); + const home = join(tmp, 'home'); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, 'SOUL.md'), 'STALE'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + const confirm = vi.fn().mockResolvedValue(false); + + const code = await runRestore({ + from: '20260101T000000Z', + mosaicHome: home, + env: { XDG_STATE_HOME: join(tmp, 'state'), MOSAIC_ASSUME_YES: '1' }, + confirm, + }); + + expect(code).toBe(0); + expect(confirm).not.toHaveBeenCalled(); + expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('env-yes'); + }); + + it('--list reports gracefully when no snapshots exist', async () => { + const home = join(tmp, 'home'); + mkdirSync(home, { recursive: true }); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const code = await runRestore({ + list: true, + mosaicHome: home, + env: { XDG_STATE_HOME: join(tmp, 'empty-state') }, + }); + + expect(code).toBe(0); + expect(log.mock.calls.flat().join('\n')).toMatch(/No pre-update snapshots/); + }); + + it('never prints a secret value found inside a backed-up file', async () => { + seedSnapshot(backups, '20260101T000000Z', { + 'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`, + }); + const home = join(tmp, 'home'); + mkdirSync(home, { recursive: true }); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await runRestore({ + list: true, + mosaicHome: home, + env: { XDG_STATE_HOME: join(tmp, 'state') }, + }); + await runRestore({ + from: '20260101T000000Z', + yes: true, + mosaicHome: home, + env: { XDG_STATE_HOME: join(tmp, 'state') }, + }); + + const all = [...log.mock.calls, ...err.mock.calls].flat().join('\n'); + expect(all).not.toContain(SECRET); + log.mockRestore(); + err.mockRestore(); + }); + }); + + // Regression coverage for the codex code+security review of PR2 (#791): + // CWE-22 traversal via --from, and CWE-59 symlink write-through in applyRestore. + describe('security hardening', () => { + it.each([ + '../../etc', + 'pre-update-/../../tmp/poison', + 'pre-update-../evil', + '20260101T000000Z/../../../tmp', + 'not-a-timestamp', + '2026-01-01', + ])('resolveSnapshotDir rejects traversal / malformed selector %j', (bad) => { + // Even if a matching directory exists on disk, a non-timestamp selector + // must not resolve — the only accepted shape is <8>T<6>Z[-n]. + expect(resolveSnapshotDir(backups, bad)).toBeUndefined(); + }); + + it('runRestore --from a traversal selector fails closed without copying', async () => { + // Plant a real dir one level ABOVE the backup root. The naive resolver + // `join(root, from)` with `from='../poison'` would reach it (backups is + // .../mosaic/backups, so `../poison` == .../mosaic/poison) and import it. + const outside = join(tmp, 'state', 'mosaic', 'poison'); + mkdirSync(outside, { recursive: true }); + writeFileSync(join(outside, 'x'), 'attacker'); + const home = join(tmp, 'home'); + mkdirSync(home, { recursive: true }); + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const code = await runRestore({ + from: '../poison', + yes: true, + mosaicHome: home, + env: { XDG_STATE_HOME: join(tmp, 'state') }, + }); + + expect(code).toBe(1); + expect(statSync(home).isDirectory()).toBe(true); + // Nothing from `outside` was imported. + expect(() => statSync(join(home, 'x'))).toThrow(); + err.mockRestore(); + }); + + it('applyRestore refuses to write a secret through a symlinked leaf (CWE-59)', () => { + const dir = seedSnapshot(backups, '20260101T000000Z', { + 'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`, + }); + const home = join(tmp, 'home'); + mkdirSync(join(home, 'tools', '_lib'), { recursive: true }); + // Attacker points the operator credentials file at a file they can read. + const exfil = join(tmp, 'exfil-target'); + writeFileSync(exfil, 'original-attacker-content'); + symlinkSync(exfil, join(home, 'tools', '_lib', 'credentials.json')); + + expect(() => applyRestore(dir, home, planRestore(dir))).toThrow(); + // The secret was NOT written through the link into the attacker's file. + expect(readFileSync(exfil, 'utf8')).toBe('original-attacker-content'); + }); + + it('applyRestore refuses to write through a symlinked ancestor (CWE-59)', () => { + const dir = seedSnapshot(backups, '20260101T000000Z', { + 'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`, + }); + const home = join(tmp, 'home'); + mkdirSync(join(home, 'tools'), { recursive: true }); + // Attacker replaces the `tools/_lib` ancestor with a symlink out of the root. + const exfilDir = join(tmp, 'exfil-dir'); + mkdirSync(exfilDir, { recursive: true }); + symlinkSync(exfilDir, join(home, 'tools', '_lib')); + + expect(() => applyRestore(dir, home, planRestore(dir))).toThrow(); + // Nothing was written into the attacker-controlled directory. + expect(() => statSync(join(exfilDir, 'credentials.json'))).toThrow(); + }); + + it('runRestore surfaces a symlink violation as exit 1 without leaking the secret', async () => { + seedSnapshot(backups, '20260101T000000Z', { + 'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`, + }); + const home = join(tmp, 'home'); + mkdirSync(join(home, 'tools', '_lib'), { recursive: true }); + const exfil = join(tmp, 'exfil-target'); + writeFileSync(exfil, 'attacker'); + symlinkSync(exfil, join(home, 'tools', '_lib', 'credentials.json')); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const code = await runRestore({ + from: '20260101T000000Z', + yes: true, + mosaicHome: home, + env: { XDG_STATE_HOME: join(tmp, 'state') }, + }); + + expect(code).toBe(1); + expect(readFileSync(exfil, 'utf8')).toBe('attacker'); + const all = [...log.mock.calls, ...err.mock.calls].flat().join('\n'); + expect(all).not.toContain(SECRET); + log.mockRestore(); + err.mockRestore(); + }); + + it('applyRestore replaces a diverged regular file in place with 0600 (not a symlink)', () => { + const dir = seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'restored' }); + const home = join(tmp, 'home'); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, 'SOUL.md'), 'stale'); + + const n = applyRestore(dir, home, planRestore(dir)); + expect(n).toBe(1); + expect(lstatSync(join(home, 'SOUL.md')).isSymbolicLink()).toBe(false); + expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('restored'); + expect(statSync(join(home, 'SOUL.md')).mode & 0o777).toBe(0o600); + }); + }); + + describe('registerRestoreCommand', () => { + it('registers `restore` with the expected flags', () => { + const program = new Command(); + program.exitOverride(); + registerRestoreCommand(program); + const cmd = program.commands.find((c) => c.name() === 'restore'); + expect(cmd).toBeDefined(); + const longs = cmd!.options.map((o) => o.long); + expect(longs).toEqual( + expect.arrayContaining(['--list', '--from', '--dry-run', '--yes', '--mosaic-home']), + ); + }); + + it('runs the list action end-to-end via the parsed command', async () => { + seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'a' }); + const prevXdg = process.env['XDG_STATE_HOME']; + process.env['XDG_STATE_HOME'] = join(tmp, 'state'); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + const program = new Command(); + program.exitOverride(); + registerRestoreCommand(program); + await program.parseAsync(['restore', '--list', '--mosaic-home', join(tmp, 'home')], { + from: 'user', + }); + expect(log.mock.calls.flat().join('\n')).toContain('20260101T000000Z'); + } finally { + log.mockRestore(); + if (prevXdg === undefined) delete process.env['XDG_STATE_HOME']; + else process.env['XDG_STATE_HOME'] = prevXdg; + } + }); + }); +}); diff --git a/packages/mosaic/src/commands/restore.ts b/packages/mosaic/src/commands/restore.ts new file mode 100644 index 0000000..d687180 --- /dev/null +++ b/packages/mosaic/src/commands/restore.ts @@ -0,0 +1,313 @@ +/** + * restore.ts — top-level `mosaic restore` command (#791 PR2 Task 12) + * + * Recovery counterpart to the durable pre-update snapshot taken by install.sh + * (make_durable_snapshot). Before a keep-mode upgrade mutates anything, the + * installer copies the operator-owned surface to + * $XDG_STATE_HOME/mosaic/backups/pre-update-/ (0700 dirs / 0600 files) + * This command lets the operator inspect and roll back to those snapshots: + * + * mosaic restore # == --list: enumerate snapshots (dry-run) + * mosaic restore --list + * mosaic restore --from # restore that snapshot over MOSAIC_HOME + * mosaic restore --from --dry-run + * + * SECREV INVARIANT: a snapshot may contain secrets (e.g. tools/_lib/credentials.json). + * This command reports counts and RELATIVE PATHS only — it never reads a backed-up + * file into any logged string. Restored files are written back 0600 (owner-only), + * matching the snapshot's own private posture. The path convention here mirrors + * install.sh `backup_root()`; keep the two in sync (no shared code across the boundary). + */ + +import { + existsSync, + readdirSync, + statSync, + lstatSync, + readFileSync, + openSync, + writeSync, + fchmodSync, + closeSync, + constants, +} from 'node:fs'; +import { createInterface } from 'node:readline'; +import { homedir } from 'node:os'; +import { join, dirname, relative } from 'node:path'; +import type { Command } from 'commander'; +import { DEFAULT_MOSAIC_HOME } from '../constants.js'; +import { assertCanonicalContainment, ensureManagedDirectory } from '../fleet/secure-file.js'; + +// ─── types ─────────────────────────────────────────────────────────────────── + +export interface SnapshotInfo { + /** The UTC stamp after the `pre-update-` prefix, e.g. "20260716T232225Z". */ + readonly timestamp: string; + /** Absolute path to the snapshot directory. */ + readonly dir: string; + /** Number of files captured in the snapshot. */ + readonly fileCount: number; +} + +export interface RestoreOptions { + list?: boolean; + from?: string; + dryRun?: boolean; + yes?: boolean; + mosaicHome: string; + /** Environment source (injectable for tests); defaults to process.env. */ + env?: NodeJS.ProcessEnv; + /** + * Confirmation gate (injectable for tests); defaults to an interactive + * readline prompt. Returns true to proceed with the overwrite. + */ + confirm?: (question: string) => Promise; +} + +const SNAPSHOT_PREFIX = 'pre-update-'; + +/** + * The exact shape install.sh `make_durable_snapshot()` stamps: `<8>T<6>Z` UTC, + * with an optional `-` same-second collision suffix. `--from` is matched + * against this — nothing containing a path separator or `..` can pass, so a + * selector can never escape the backup root (CWE-22). + */ +const SNAPSHOT_TS_RE = /^\d{8}T\d{6}Z(?:-\d+)?$/; + +// ─── pure helpers ───────────────────────────────────────────────────────────── + +/** Resolve the durable-snapshot root, mirroring install.sh `backup_root()`. */ +export function resolveBackupRoot(env: NodeJS.ProcessEnv = process.env): string { + const stateHome = env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state'); + return join(stateHome, 'mosaic', 'backups'); +} + +/** Recursively collect every file under `dir` as a path relative to `dir`. */ +export function planRestore(dir: string): string[] { + const out: string[] = []; + const walk = (cur: string): void => { + for (const entry of readdirSync(cur, { withFileTypes: true })) { + const abs = join(cur, entry.name); + if (entry.isDirectory()) { + walk(abs); + } else if (entry.isFile()) { + out.push(relative(dir, abs)); + } + } + }; + if (existsSync(dir)) walk(dir); + return out; +} + +/** Enumerate snapshots newest-first (the `pre-update-` names sort chronologically). */ +export function listSnapshots(root: string): SnapshotInfo[] { + if (!existsSync(root)) return []; + let entries: string[]; + try { + entries = readdirSync(root); + } catch { + return []; + } + return entries + .filter((name) => name.startsWith(SNAPSHOT_PREFIX)) + .map((name) => join(root, name)) + .filter((dir) => { + try { + return statSync(dir).isDirectory(); + } catch { + return false; + } + }) + .sort() + .reverse() + .map((dir) => ({ + timestamp: dir.split('/').at(-1)!.slice(SNAPSHOT_PREFIX.length), + dir, + fileCount: planRestore(dir).length, + })); +} + +/** + * Resolve a snapshot dir from a `--from` selector. Accepts ONLY a strict + * generated identifier — a bare `` or the full `pre-update-` name — and + * builds exactly `join(root, 'pre-update-' + ts)`. A selector containing `/`, + * `..`, or anything but the timestamp shape is rejected (returns undefined), so + * `--from` can never traverse outside the backup root (CWE-22). The resolved dir + * must be a real, non-symlink directory (lstat, not stat), so a symlinked + * snapshot entry can't redirect the restore either. + */ +export function resolveSnapshotDir(root: string, from: string): string | undefined { + const ts = from.startsWith(SNAPSHOT_PREFIX) ? from.slice(SNAPSHOT_PREFIX.length) : from; + if (!SNAPSHOT_TS_RE.test(ts)) return undefined; + const dir = join(root, `${SNAPSHOT_PREFIX}${ts}`); + try { + if (lstatSync(dir).isDirectory()) return dir; + } catch { + /* absent or inaccessible */ + } + return undefined; +} + +/** + * Copy each `relPaths` entry from the snapshot back into `mosaicHome`, forcing + * 0600 on the restored file (owner-only — the operator surface may hold secrets). + * Returns the number of files restored. Never reads a file's content into a + * logged string. + * + * SYMLINK-SAFE (CWE-59): a snapshot may hold secrets, so we must never let a + * tampered destination redirect the write. Every destination path is contained + * within `mosaicHome` (assertCanonicalContainment) and every ancestor is proven + * to be a real, non-symlink directory (ensureManagedDirectory) before we write. + * The leaf itself is opened O_NOFOLLOW, so if it was swapped for a symlink the + * open fails closed (ELOOP) rather than writing the secret through the link. + */ +export function applyRestore( + snapDir: string, + mosaicHome: string, + relPaths: readonly string[], +): number { + let restored = 0; + for (const rel of relPaths) { + const src = join(snapDir, rel); + const dst = join(mosaicHome, rel); + // Fail closed if the target path escapes the managed root or any ancestor is + // a symlink; create missing ancestors as private (0700) real directories. + assertCanonicalContainment(mosaicHome, dst); + ensureManagedDirectory(mosaicHome, dirname(dst)); + // O_NOFOLLOW: refuse to follow a symlink at the leaf (secret exfil guard). + // O_CREAT|O_TRUNC: create a fresh 0600 file, or overwrite a diverged real one. + const fd = openSync( + dst, + constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, + 0o600, + ); + try { + fchmodSync(fd, 0o600); // enforce 0600 even when the file pre-existed + writeSync(fd, readFileSync(src)); + } finally { + closeSync(fd); + } + restored += 1; + } + return restored; +} + +// ─── orchestration ──────────────────────────────────────────────────────────── + +async function promptConfirm(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + return await new Promise((resolve) => { + rl.question(`${question} [y/N] `, (ans) => resolve(ans.trim().toLowerCase() === 'y')); + }); + } finally { + rl.close(); + } +} + +/** + * Run `mosaic restore`. Returns a process exit code (0 ok, 1 error) rather than + * calling process.exit, so it stays unit-testable. + */ +export async function runRestore(opts: RestoreOptions): Promise { + const env = opts.env ?? process.env; + const root = resolveBackupRoot(env); + + // Default action (and explicit --list): enumerate, never mutate. + if (opts.list || !opts.from) { + const snaps = listSnapshots(root); + if (snaps.length === 0) { + console.log(`No pre-update snapshots found under ${root}.`); + return 0; + } + console.log(`Pre-update snapshots under ${root} (newest first):\n`); + for (const s of snaps) { + console.log(` ${s.timestamp} — ${s.fileCount} file(s)`); + } + console.log(`\nRestore one with: mosaic restore --from `); + return 0; + } + + // --from : restore over the operator surface. + const snapDir = resolveSnapshotDir(root, opts.from); + if (!snapDir) { + console.error(`No snapshot matching '${opts.from}' under ${root}.`); + console.error(`Run 'mosaic restore --list' to see available timestamps.`); + return 1; + } + + const relPaths = planRestore(snapDir); + const ts = snapDir.split('/').at(-1)!.slice(SNAPSHOT_PREFIX.length); + + if (opts.dryRun) { + console.log( + `[dry-run] Would restore ${relPaths.length} file(s) from snapshot ${ts} into ${opts.mosaicHome}:`, + ); + for (const rel of relPaths) console.log(` ${rel}`); + console.log('[dry-run] No changes made.'); + return 0; + } + + const assumeYes = opts.yes || env['MOSAIC_ASSUME_YES'] === '1'; + if (!assumeYes) { + console.log( + `About to restore ${relPaths.length} operator file(s) from snapshot ${ts} into ${opts.mosaicHome}.`, + ); + console.log('This OVERWRITES those files with their pre-update contents.'); + const ok = await (opts.confirm ?? promptConfirm)('Proceed?'); + if (!ok) { + console.log('Restore cancelled. No changes made.'); + return 0; + } + } + + let n: number; + try { + n = applyRestore(snapDir, opts.mosaicHome, relPaths); + } catch (err) { + // A containment/symlink violation is a fail-closed security stop, not a + // routine error — surface it without leaking file contents and abort. + console.error( + `Restore aborted: a destination path under ${opts.mosaicHome} is unsafe to write ` + + `(symlink or escapes the managed root). No files were restored. (${(err as Error).message})`, + ); + return 1; + } + console.log(`Restored ${n} operator file(s) from snapshot ${ts} into ${opts.mosaicHome}.`); + return 0; +} + +// ─── commander registration ─────────────────────────────────────────────────── + +export function registerRestoreCommand(program: Command): void { + program + .command('restore') + .description('List or restore durable pre-update snapshots of your operator config (#791)') + .option('--list', 'List available snapshots by timestamp (default action)') + .option('--from ', 'Restore the snapshot with this timestamp over MOSAIC_HOME') + .option('--dry-run', 'With --from: show what would be restored without changing anything') + .option('--yes, -y', 'Skip the confirmation prompt (also: MOSAIC_ASSUME_YES=1)') + .option( + '--mosaic-home ', + 'Override MOSAIC_HOME directory', + process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME, + ) + .action( + async (opts: { + list?: boolean; + from?: string; + dryRun?: boolean; + yes?: boolean; + mosaicHome: string; + }) => { + const code = await runRestore({ + list: opts.list, + from: opts.from, + dryRun: opts.dryRun, + yes: opts.yes, + mosaicHome: opts.mosaicHome, + }); + if (code !== 0) process.exit(code); + }, + ); +}