/** * 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); }, ); }