config-audit/scanners/rollback-engine.mjs
Kjell Tore Guttormsen 8f149891c9 fix(rollback): restore the backup path contract the engine and the commands disagreed on
Pipeline step 4 dogfood. `/config-audit rollback` could not see a single one of
the four real backups on this machine, and reported "Backup not found" for one
that was sitting right there.

Four defects, one root: nothing agreed on where a backup lives or what its
manifest looks like.

- M-BUG-22 `lib/backup.mjs` resolved `~/.config-audit/backups` (pre-v2.2.0)
  while every command, agent and doc uses `~/.claude/config-audit/backups`.
  The auto-backup hook and fix-cli wrote to the first, implement to the second,
  rollback read only the first. Canonical root now, with the legacy root kept
  readable so older backups stay listable and restorable (`legacy: true`).
- M-BUG-25 `parseManifest` understood only the engine's quoted `original_path:`
  spelling, but implement hand-builds its manifest with `- backup:`/`original:`/
  `sha256:`. Every implement-made backup parsed to zero files and restoreBackup
  returned `{restored: [], failed: []}` — a success-shaped no-op. Both formats
  parse now, and a manifest with unparseable entries throws instead of
  pretending to succeed.
- M-BUG-23 both session hooks watched `~/.config-audit/sessions`, which does not
  exist; sessions live under `~/.claude/`. "Check for active sessions" had never
  fired once. It fires now.
- M-BUG-24 the suite called createBackup() against the developer's real home —
  it had left nine stray backups there, and cleanupOldBackups() deletes past ten.
  Root is overridable via CONFIG_AUDIT_BACKUP_ROOT; both test files use it.

Rollback still cannot delete files implement CREATED — no backup can hold a file
that never existed. It no longer does so silently: manifests carry a `created:`
list, restoreBackup returns `createdNotRemoved`, and rollback.md requires the
report. Automatic deletion is a destructive action and needs its own design.

Verified against backup 20260717_032636 on a throwaway copy: all three files
restore byte-exact (sha256 match), zero writes outside the copy, backup dir
unmodified. Suite 1382 -> 1398/0; frozen v5.0.0 snapshots untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SejM9RQAa1Hfuq7Ek2WfFr
2026-07-31 17:23:30 +02:00

188 lines
5.5 KiB
JavaScript

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