Compare commits
2 Commits
feat/791-p
...
feat/791-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d12c5f7808 | ||
|
|
8bee1b65ea |
@@ -48,15 +48,19 @@ steps:
|
|||||||
# keep mode is a single cp-based path that must not depend on rsync), and that a
|
# 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
|
# corrupt/empty/missing manifest aborts fail-closed leaving operator files
|
||||||
# untouched (B2/B3). The rollback gate proves a mid-sync failure is rolled back
|
# 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
|
# from the pre-update snapshot (B1). The durable-snapshot gate (#791 PR2) proves
|
||||||
# contract-file semantics. Pure bash, no node_modules — runs early alongside
|
# the retained, operator-scoped pre-update backup is taken before any mutation
|
||||||
# sanitization.
|
# (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:
|
upgrade-guard:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- apk add --no-cache bash rsync
|
- 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-manifest-guard.sh
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.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
|
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
|
|||||||
@@ -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 ·
|
- 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.
|
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).
|
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-<UTC-ts>/`
|
||||||
|
— 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 <ts>`
|
||||||
|
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 <ts>` 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.
|
||||||
|
|||||||
@@ -109,6 +109,225 @@ restore_snapshot() {
|
|||||||
}
|
}
|
||||||
cleanup_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] && rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""; }
|
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-<UTC-ts> 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),
|
# Reconcile contract files after sync: framework-owned overwrite (backup-once),
|
||||||
# user-seeded seed-if-absent.
|
# user-seeded seed-if-absent.
|
||||||
reconcile_framework_files() {
|
reconcile_framework_files() {
|
||||||
@@ -326,6 +545,10 @@ run_migrations() {
|
|||||||
# Remove bin/ directory — all executables now live in the npm CLI.
|
# Remove bin/ directory — all executables now live in the npm CLI.
|
||||||
# Scripts that were in bin/ are now in tools/_scripts/.
|
# Scripts that were in bin/ are now in tools/_scripts/.
|
||||||
if [[ "$from_version" -lt 2 ]]; then
|
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
|
if [[ -d "$TARGET_DIR/bin" ]]; then
|
||||||
ok "Removing legacy bin/ directory (executables now in npm CLI)"
|
ok "Removing legacy bin/ directory (executables now in npm CLI)"
|
||||||
rm -rf "$TARGET_DIR/bin"
|
rm -rf "$TARGET_DIR/bin"
|
||||||
@@ -383,6 +606,9 @@ fi
|
|||||||
# not for a validation failure that has touched nothing yet (#791 blocker-1).
|
# not for a validation failure that has touched nothing yet (#791 blocker-1).
|
||||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||||
manifest_load
|
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
|
fi
|
||||||
|
|
||||||
# Snapshot before any destructive file operation; restore on interrupt/failure.
|
# 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.
|
# File-system phase complete and consistent — clear the restore trap.
|
||||||
trap - ERR INT TERM
|
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
|
cleanup_snapshot
|
||||||
|
|
||||||
# Testability / minimal-install hook: stop after the file-system phase, before any
|
# Testability / minimal-install hook: stop after the file-system phase, before any
|
||||||
|
|||||||
@@ -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-<ts>/ —
|
||||||
|
# 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 `# <MARKER>` 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 <marker-regex> <name> — echo a control installer path ($FW-local) that
|
||||||
|
# is $INSTALL with every line matching /<marker-regex>/ 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" <<SHIM
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
dest="\${@: -1}"
|
||||||
|
src="\${@:(-2):1}"
|
||||||
|
case "\$dest" in
|
||||||
|
*/$POISON_REL)
|
||||||
|
rm -f "$home/tools/_lib/credentials.json"
|
||||||
|
ln -s "$EXFIL_TARGET" "$home/tools/_lib/credentials.json"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
# Coreutils-agnostic emulation of GNU cp's follow-through-dest-symlink behavior.
|
||||||
|
if [[ -L "\$dest" && -f "\$src" ]]; then
|
||||||
|
cat "\$src" > "\$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" <<SHIM
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
dest="\${@: -1}"
|
||||||
|
case "\$dest" in
|
||||||
|
*/$POISON_REL) printf 'CORRUPTION-mid-sync\n' >> "$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: "<exit>\t<out>\t<state-dir>\t<home>".
|
||||||
|
# $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 ]
|
||||||
@@ -29,6 +29,13 @@ INSTALL="$FW/install.sh"
|
|||||||
pass=0; fail=0
|
pass=0; fail=0
|
||||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
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'
|
SECRET='SUPER-SECRET-TOKEN-do-not-log-3f9a'
|
||||||
|
|
||||||
# Seed a throwaway MOSAIC_HOME with an operator sentinel per ownership class.
|
# 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
|
# does not silently depend on rsync being installed. (Provide the coreutils the
|
||||||
# installer needs on the stripped PATH.)
|
# installer needs on the stripped PATH.)
|
||||||
FBIN=$(mktemp -d)
|
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"
|
p=$(command -v "$t" 2>/dev/null) && ln -s "$p" "$FBIN/$t"
|
||||||
done
|
done
|
||||||
run_matrix "rsync-absent" env "PATH=$FBIN"
|
run_matrix "rsync-absent" env "PATH=$FBIN"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { registerConfigCommand } from './commands/config.js';
|
|||||||
import { registerFleetCommand } from './commands/fleet.js';
|
import { registerFleetCommand } from './commands/fleet.js';
|
||||||
import { registerMissionCommand } from './commands/mission.js';
|
import { registerMissionCommand } from './commands/mission.js';
|
||||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||||
|
import { registerRestoreCommand } from './commands/restore.js';
|
||||||
// prdy is registered via launch.ts
|
// prdy is registered via launch.ts
|
||||||
import { registerLaunchCommands } from './commands/launch.js';
|
import { registerLaunchCommands } from './commands/launch.js';
|
||||||
import { registerAuthCommand } from './commands/auth.js';
|
import { registerAuthCommand } from './commands/auth.js';
|
||||||
@@ -406,6 +407,10 @@ registerStorageCommand(program);
|
|||||||
|
|
||||||
registerUninstallCommand(program);
|
registerUninstallCommand(program);
|
||||||
|
|
||||||
|
// ─── restore ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerRestoreCommand(program);
|
||||||
|
|
||||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerTelemetryCommand(program);
|
registerTelemetryCommand(program);
|
||||||
|
|||||||
466
packages/mosaic/src/commands/restore.spec.ts
Normal file
466
packages/mosaic/src/commands/restore.spec.ts
Normal file
@@ -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-<ts>/ with
|
||||||
|
* 0700 dirs / 0600 files. `mosaic restore` is the recovery counterpart:
|
||||||
|
* • --list (default) enumerate snapshots by timestamp — dry-run, never mutates.
|
||||||
|
* • --from <ts> 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, string>): 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-<ts> 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
313
packages/mosaic/src/commands/restore.ts
Normal file
313
packages/mosaic/src/commands/restore.ts
Normal file
@@ -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-<UTC-ts>/ (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 <ts> # restore that snapshot over MOSAIC_HOME
|
||||||
|
* mosaic restore --from <ts> --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<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SNAPSHOT_PREFIX = 'pre-update-';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact shape install.sh `make_durable_snapshot()` stamps: `<8>T<6>Z` UTC,
|
||||||
|
* with an optional `-<n>` 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-<ts>` 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 `<ts>` or the full `pre-update-<ts>` 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<boolean> {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
try {
|
||||||
|
return await new Promise<boolean>((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<number> {
|
||||||
|
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 <timestamp>`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --from <ts>: 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 <timestamp>', '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 <path>',
|
||||||
|
'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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user