/** * 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'; /** * 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 = []; // 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 }; } /** * 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 }; } }