/** * Config-Audit Rollback Engine * Restores configuration from backup with checksum verification. * Zero external dependencies. */ import { readFile, writeFile, readdir, stat, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { getBackupDir, getLegacyBackupDir, parseManifest, checksum } from './lib/backup.mjs'; import { evaluateWriteTargets } from './lib/write-scope.mjs'; /** * Resolve a backup id to its directory, canonical root first, then the * pre-v2.2.0 root. Returns null when the id exists in neither. * @param {string} backupId * @returns {Promise<{ path: string, legacy: boolean } | null>} */ async function resolveBackupPath(backupId) { for (const [root, legacy] of [[getBackupDir(), false], [getLegacyBackupDir(), true]]) { const candidate = join(root, backupId); try { await stat(join(candidate, 'manifest.yaml')); return { path: candidate, legacy }; } catch { // try the next root } } return null; } /** * List all available backups. * @returns {Promise<{ backups: object[] }>} */ export async function listBackups() { const backups = []; const seen = new Set(); // Canonical root first; a legacy backup with the same id must not shadow it. for (const [backupRoot, legacy] of [[getBackupDir(), false], [getLegacyBackupDir(), true]]) { let entries; try { entries = await readdir(backupRoot, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (!entry.isDirectory() || seen.has(entry.name)) continue; const backupPath = join(backupRoot, entry.name); const manifestPath = join(backupPath, 'manifest.yaml'); try { const manifestContent = await readFile(manifestPath, 'utf-8'); const manifest = parseManifest(manifestContent); seen.add(entry.name); backups.push({ id: entry.name, createdAt: manifest.created_at, legacy, files: manifest.files.map(f => ({ originalPath: f.originalPath, backupPath: f.backupPath, checksum: f.checksum, sizeBytes: f.sizeBytes, })), created: manifest.created, }); } catch { // Skip backups without valid manifest continue; } } } // Sort newest first backups.sort((a, b) => b.id.localeCompare(a.id)); return { backups }; } /** * Restore files from a backup. * @param {string} backupId * @param {object} [opts] * @param {boolean} [opts.dryRun=false] * @param {boolean} [opts.verify=true] * @returns {Promise<{ restored: object[], failed: object[] }>} */ export async function restoreBackup(backupId, opts = {}) { const verify = opts.verify !== false; const resolved = await resolveBackupPath(backupId); if (!resolved) throw new Error(`Backup not found: ${backupId}`); const backupPath = resolved.path; const manifestContent = await readFile(join(backupPath, 'manifest.yaml'), 'utf-8'); const manifest = parseManifest(manifestContent); const restored = []; const failed = []; // Q1 — the scope gate, in code. `rollback` is one of the five arms M-BUG-41 // measured: it renders repo-relative-looking paths while writing to the // ABSOLUTE originals recorded in the manifest, so what the operator reads and // what the run touches are not the same set. A backup taken under `--global` // restores `~/.claude/…`, which is `user-scope` / `require-ok`. const scope = evaluateWriteTargets( manifest.files.map((f) => f.originalPath), opts.repoRoot ?? null, opts.home ? { home: opts.home } : {}, ); // The gate guards a WRITE; a dry run is not one (#63). `requiresApproval` is // returned either way, so a caller previewing a restore still learns that // approval will be owed. if (scope.requiresApproval && !opts.approveScope && !opts.dryRun) { return { restored: [], failed: [], gate: scope.gate, requiresApproval: true, disclosures: scope.disclosures, refused: manifest.files.map((f) => ({ originalPath: f.originalPath, status: 'refused', reason: 'scope-gate', })), }; } // A manifest with entries that parsed to nothing would restore nothing while // reporting success. Fail loudly instead. if (manifest.files.length === 0 && /^\s+-\s/m.test(manifestContent)) { throw new Error(`Unreadable manifest for backup ${backupId}: entries present but none parsed`); } for (const fileEntry of manifest.files) { const backupFilePath = join(backupPath, fileEntry.backupPath); if (opts.dryRun) { restored.push({ originalPath: fileEntry.originalPath, status: 'dry-run', }); continue; } try { // Read backup file const content = await readFile(backupFilePath); // Verify checksum before restoring if (verify) { const hash = checksum(content); if (hash !== fileEntry.checksum) { failed.push({ originalPath: fileEntry.originalPath, status: 'checksum-mismatch', error: `Expected ${fileEntry.checksum}, got ${hash}`, }); continue; } } // Write to original path await writeFile(fileEntry.originalPath, content); // Verify after write if (verify) { const written = await readFile(fileEntry.originalPath); const writtenHash = checksum(written); if (writtenHash !== fileEntry.checksum) { failed.push({ originalPath: fileEntry.originalPath, status: 'checksum-mismatch', error: 'Checksum mismatch after write', }); continue; } } restored.push({ originalPath: fileEntry.originalPath, status: 'restored', }); } catch (err) { failed.push({ originalPath: fileEntry.originalPath, status: 'failed', error: err.message, }); } } // Files implement CREATED are absent from the backup by definition, so they // survive the restore. Report them — a half-restored target is only dangerous // when it is also silent. return { restored, failed, createdNotRemoved: manifest.created, legacy: resolved.legacy, gate: scope.gate, requiresApproval: scope.requiresApproval, disclosures: scope.disclosures, refused: [], }; } /** * Delete a backup directory. * @param {string} backupId * @returns {Promise<{ deleted: boolean, error?: string }>} */ export async function deleteBackup(backupId) { const resolved = await resolveBackupPath(backupId); if (!resolved) return { deleted: false, error: `Backup not found: ${backupId}` }; try { await rm(resolved.path, { recursive: true, force: true }); return { deleted: true }; } catch (err) { return { deleted: false, error: err.message }; } }