feat(mosaic): durable pre-update snapshot + verify net + restore CLI (#791 PR2) (#811)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

This commit was merged in pull request #811.
This commit is contained in:
2026-07-17 00:43:18 +00:00
parent 32a0ffba13
commit 31607a4af6
8 changed files with 1424 additions and 4 deletions

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