ktg-plugin-marketplace/plugins/config-audit/scanners/rollback-engine.mjs

166 lines
4.2 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, parseManifest, checksum } from './lib/backup.mjs';
/**
* List all available backups.
* @returns {Promise<{ backups: object[] }>}
*/
export async function listBackups() {
const backupRoot = getBackupDir();
const backups = [];
let entries;
try {
entries = await readdir(backupRoot, { withFileTypes: true });
} catch {
return { backups: [] };
}
for (const entry of entries) {
if (!entry.isDirectory()) 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);
backups.push({
id: entry.name,
createdAt: manifest.created_at,
files: manifest.files.map(f => ({
originalPath: f.originalPath,
backupPath: f.backupPath,
checksum: f.checksum,
sizeBytes: f.sizeBytes,
})),
});
} 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 backupRoot = getBackupDir();
const backupPath = join(backupRoot, backupId);
const manifestPath = join(backupPath, 'manifest.yaml');
// Read manifest
let manifestContent;
try {
manifestContent = await readFile(manifestPath, 'utf-8');
} catch {
throw new Error(`Backup not found: ${backupId}`);
}
const manifest = parseManifest(manifestContent);
const restored = [];
const failed = [];
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,
});
}
}
return { restored, failed };
}
/**
* Delete a backup directory.
* @param {string} backupId
* @returns {Promise<{ deleted: boolean, error?: string }>}
*/
export async function deleteBackup(backupId) {
const backupRoot = getBackupDir();
const backupPath = join(backupRoot, backupId);
try {
await stat(backupPath);
} catch {
return { deleted: false, error: `Backup not found: ${backupId}` };
}
try {
await rm(backupPath, { recursive: true, force: true });
return { deleted: true };
} catch (err) {
return { deleted: false, error: err.message };
}
}