UNPKG

claude-flow-novice

Version:

Claude Flow Novice - Advanced orchestration platform for multi-agent AI workflows with CFN Loop architecture Includes Local RuVector Accelerator and all CFN skills for complete functionality.

779 lines (778 loc) 31.4 kB
/** * Unified Backup & Restore Manager * * Centralized backup and restore system for all critical file operations. * Part of Task 4.3: Unified Backup & Restore System * * Features: * - Multiple backup types (pre-edit, checkpoint, manual) * - SQLite metadata storage with queryability * - Restore operations (latest, by timestamp, by hash) * - Restore verification with hash comparison * - Dry-run mode for restore preview * - Automatic rollback on verification failure * - Rate limiting for restore operations * - Comprehensive audit trail * - Disk usage monitoring * - Automatic cleanup based on TTL * - Integration with FileLockManager * * Usage: * const manager = new BackupManager(); * const backup = await manager.createBackup('/path/to/file.txt', { * agentId: 'backend-dev-001', * backupType: 'pre-edit' * }); * * // Restore latest backup * await manager.restoreLatest('/path/to/file.txt', { * agentId: 'backend-dev-001', * verify: true * }); */ import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; import { promisify } from 'util'; import { randomUUID } from 'crypto'; import Database from 'better-sqlite3'; import { createLogger } from './logging.js'; import { createError, ErrorCode, StandardError } from './errors.js'; import { getFileLockManager } from './file-lock-manager.js'; import { withFileSystemRetry } from './retry-manager.js'; import { getEncryptionManager } from './encryption-manager.js'; const logger = createLogger('backup-manager'); const fsReadFile = promisify(fs.readFile); const fsWriteFile = promisify(fs.writeFile); const fsCopyFile = promisify(fs.copyFile); const fsStat = promisify(fs.stat); const fsMkdir = promisify(fs.mkdir); const fsAccess = promisify(fs.access); const fsReaddir = promisify(fs.readdir); const fsUnlink = promisify(fs.unlink); /** * Backup type classification */ export var BackupType = /*#__PURE__*/ function(BackupType) { BackupType["PRE_EDIT"] = "pre-edit"; BackupType["CHECKPOINT"] = "checkpoint"; BackupType["MANUAL"] = "manual"; return BackupType; }({}); /** * Unified Backup & Restore Manager */ export class BackupManager { db; lockManager; encryptionManager; backupDir; defaultTtlMs; rateLimitConfig; projectRoot; constructor(config = {}){ this.projectRoot = config.projectRoot || process.cwd(); this.backupDir = config.backupDir || path.join(this.projectRoot, '.backups'); this.defaultTtlMs = config.defaultTtlMs || 24 * 60 * 60 * 1000; // 24 hours this.rateLimitConfig = config.rateLimit || { maxRestoresPerHour: 100 }; const dbPath = config.dbPath || path.join(this.projectRoot, 'claude-assets/skills/cfn-redis-coordination/data/backups.db'); // Initialize database this.db = this.initializeDatabase(dbPath); // Initialize file lock manager this.lockManager = getFileLockManager(); // Initialize encryption manager (CVSS 7.2 mitigation) this.encryptionManager = getEncryptionManager(); // Ensure backup directory exists this.ensureBackupDirectory(); logger.info('Backup manager initialized', { backupDir: this.backupDir, dbPath, defaultTtlMs: this.defaultTtlMs, encryptionEnabled: this.encryptionManager.isEnabled() }); } /** * Create a backup of a file * * @param filePath - Path to file to backup * @param options - Backup options * @returns Backup instance */ async createBackup(filePath, options) { const startTime = Date.now(); const absolutePath = path.resolve(filePath); logger.info('Creating backup', { filePath: absolutePath, agentId: options.agentId, backupType: options.backupType }); // Acquire file lock const lock = await this.lockManager.acquireLock(absolutePath, { agentId: options.agentId, timeout: 30000 }); try { // Check if file exists const exists = await this.fileExists(absolutePath); if (!exists) { throw createError(ErrorCode.FILE_NOT_FOUND, `File does not exist: ${absolutePath}`, { filePath: absolutePath }); } // Read file and calculate hash const fileContent = await fsReadFile(absolutePath); const originalHash = this.calculateHash(fileContent); const fileSize = fileContent.length; // Create backup directory structure const timestamp = Date.now(); const backupPath = this.getBackupPath(options.agentId, timestamp, originalHash); await this.ensureDirectory(path.dirname(backupPath)); // Copy file to backup location with retry logic for transient failures await withFileSystemRetry(async ()=>{ await fsCopyFile(absolutePath, backupPath); }); // Verify backup with retry logic const backupContent = await withFileSystemRetry(async ()=>{ return await fsReadFile(backupPath); }); const backupHash = this.calculateHash(backupContent); if (!this.constantTimeHashCompare(originalHash, backupHash)) { // Cleanup failed backup await this.safeUnlink(backupPath); throw createError(ErrorCode.VALIDATION_FAILED, 'Backup verification failed: hash mismatch', { originalHash, backupHash, filePath: absolutePath }); } // Calculate expiration const ttlMs = options.ttlMs || this.defaultTtlMs; const expiresAt = new Date(Date.now() + ttlMs); // Store metadata in database const backupId = randomUUID(); const backup = { id: backupId, filePath: absolutePath, backupPath, agentId: options.agentId, backupType: options.backupType, originalHash, backupHash, fileSize, createdAt: new Date(), expiresAt, metadata: options.metadata }; // ENCRYPTION SUPPORT (CVSS 7.2 mitigation) let encryptionMetadata = null; if (this.encryptionManager.isEnabled()) { try { const encrypted = await this.encryptionManager.encrypt(backupContent, backupId); encryptionMetadata = encrypted.metadata; // Write encrypted backup await withFileSystemRetry(async ()=>{ await fsWriteFile(backupPath, encrypted.data); }); logger.info('Backup encrypted successfully', { backupId, algorithm: encrypted.metadata.algorithm, originalSize: backupContent.length, encryptedSize: encrypted.data.length }); } catch (encryptError) { logger.error('Backup encryption failed', encryptError instanceof Error ? encryptError : undefined, { backupId, filePath: absolutePath }); // Cleanup failed backup await this.safeUnlink(backupPath); throw encryptError; } } this.insertBackup(backup, encryptionMetadata); const duration = Date.now() - startTime; logger.info('Backup created successfully', { backupId, filePath: absolutePath, backupPath, fileSize, durationMs: duration }); return backup; } catch (error) { const duration = Date.now() - startTime; logger.error('Backup creation failed', error instanceof Error ? error : undefined, { filePath: absolutePath, durationMs: duration }); // Log failed backup to audit trail this.logAuditEntry({ backupId: null, operation: 'create', agentId: options.agentId, status: 'failure', filePath: absolutePath, errorMessage: error instanceof Error ? error.message : String(error), errorCode: error instanceof StandardError ? error.code : ErrorCode.UNKNOWN_ERROR, durationMs: duration }); throw error; } finally{ await this.lockManager.releaseLock(lock.id); } } /** * Restore the latest backup for a file * * @param filePath - Path to file to restore * @param options - Restore options * @returns Restore result */ async restoreLatest(filePath, options) { const absolutePath = path.resolve(filePath); const backup = this.getLatestBackup(absolutePath); if (!backup) { throw createError(ErrorCode.FILE_NOT_FOUND, `No backup found for: ${absolutePath}`, { filePath: absolutePath }); } return this.restoreBackup(backup.id, options); } /** * Restore backup by timestamp * * @param filePath - Path to file * @param timestamp - Backup timestamp * @param options - Restore options * @returns Restore result */ async restoreByTimestamp(filePath, timestamp, options) { const absolutePath = path.resolve(filePath); const backup = this.getBackupByTimestamp(absolutePath, timestamp); if (!backup) { throw createError(ErrorCode.FILE_NOT_FOUND, `No backup found for: ${absolutePath} at timestamp ${timestamp.toISOString()}`, { filePath: absolutePath, timestamp: timestamp.toISOString() }); } return this.restoreBackup(backup.id, options); } /** * Restore backup by hash * * @param filePath - Path to file * @param hash - File hash * @param options - Restore options * @returns Restore result */ async restoreByHash(filePath, hash, options) { const absolutePath = path.resolve(filePath); const backup = this.getBackupByHash(absolutePath, hash); if (!backup) { throw createError(ErrorCode.FILE_NOT_FOUND, `No backup found for: ${absolutePath} with hash ${hash}`, { filePath: absolutePath, hash }); } return this.restoreBackup(backup.id, options); } /** * Restore a specific backup by ID * * @param backupId - Backup ID * @param options - Restore options * @returns Restore result */ async restoreBackup(backupId, options) { const startTime = Date.now(); const verify = options.verify !== false; const dryRun = options.dryRun || false; const force = options.force || false; const createBackupBeforeRestore = options.createBackupBeforeRestore !== false; logger.info('Restoring backup', { backupId, agentId: options.agentId, verify, dryRun }); // Get backup metadata const metadata = this.getBackupMetadata(backupId); if (!metadata) { throw createError(ErrorCode.FILE_NOT_FOUND, `Backup not found: ${backupId}`, { backupId }); } // Check rate limit if (!force && !dryRun) { const rateLimitOk = this.checkRateLimit(options.agentId); if (!rateLimitOk) { throw createError(ErrorCode.LOCK_TIMEOUT, 'Restore rate limit exceeded', { agentId: options.agentId, maxRestoresPerHour: this.rateLimitConfig.maxRestoresPerHour }); } } // Verify backup file exists const backupExists = await this.fileExists(metadata.backupPath); if (!backupExists) { throw createError(ErrorCode.FILE_NOT_FOUND, `Backup file not found: ${metadata.backupPath}`, { backupId, backupPath: metadata.backupPath }); } // Dry-run mode: just verify and return if (dryRun) { const backupContent = await fsReadFile(metadata.backupPath); const verificationHash = this.calculateHash(backupContent); return { success: true, backupId, filePath: metadata.filePath, backupPath: metadata.backupPath, verified: this.constantTimeHashCompare(verificationHash, metadata.backupHash), dryRun: true, restoredAt: new Date(), verificationHash, expectedHash: metadata.backupHash }; } // Acquire file lock const lock = await this.lockManager.acquireLock(metadata.filePath, { agentId: options.agentId, timeout: 30000 }); let rollbackBackupId; try { // Create backup of current file before restore if (createBackupBeforeRestore && await this.fileExists(metadata.filePath)) { const rollbackBackup = await this.createBackup(metadata.filePath, { agentId: options.agentId, backupType: "pre-edit", metadata: { reason: 'pre-restore-backup', restoringBackupId: backupId } }); rollbackBackupId = rollbackBackup.id; } // DECRYPTION SUPPORT (CVSS 7.2 mitigation) let backupDataToRestore; if (metadata.isEncrypted && this.encryptionManager.isEnabled()) { try { const encryptedBackupContent = await withFileSystemRetry(async ()=>{ return await fsReadFile(metadata.backupPath); }); const encryptedPayload = { data: encryptedBackupContent, metadata: { algorithm: 'AES-256-GCM', iv: metadata.encryptionIv, authTag: metadata.encryptionAuthTag, hmac: metadata.encryptionHmac, encryptedAt: metadata.encryptedAt, keyVersion: metadata.encryptionKeyVersion } }; const decryptionResult = await this.encryptionManager.decrypt(encryptedPayload, backupId); if (!decryptionResult.integrityVerified) { logger.warn('Backup integrity check failed during restore', { backupId, integrityVerified: false }); } backupDataToRestore = decryptionResult.data; logger.info('Backup decrypted successfully', { backupId, integrityVerified: decryptionResult.integrityVerified }); } catch (decryptError) { logger.error('Backup decryption failed', decryptError instanceof Error ? decryptError : undefined, { backupId, filePath: metadata.filePath }); throw decryptError; } } else { // Load unencrypted backup backupDataToRestore = await withFileSystemRetry(async ()=>{ return await fsReadFile(metadata.backupPath); }); } // Perform restore with retry logic for transient failures await withFileSystemRetry(async ()=>{ await fsWriteFile(metadata.filePath, backupDataToRestore); }); // Verify restore if requested let verified = false; let verificationHash; if (verify) { const restoredContent = await withFileSystemRetry(async ()=>{ return await fsReadFile(metadata.filePath); }); verificationHash = this.calculateHash(restoredContent); verified = this.constantTimeHashCompare(verificationHash, metadata.originalHash); if (!verified) { // Verification failed - rollback if we created a backup let rollbackPerformed = false; if (rollbackBackupId) { try { await this.restoreBackup(rollbackBackupId, { agentId: options.agentId, verify: false, dryRun: false, force: true, createBackupBeforeRestore: false }); rollbackPerformed = true; } catch (rollbackError) { logger.error('Rollback failed after verification failure', rollbackError instanceof Error ? rollbackError : undefined, { backupId, rollbackBackupId }); } } throw createError(ErrorCode.VALIDATION_FAILED, 'Restore verification failed: hash mismatch', { backupId, filePath: metadata.filePath, verificationHash, expectedHash: metadata.originalHash, rollbackPerformed }); } } // Record restore in rate limit table this.recordRestore(backupId, options.agentId, metadata.filePath); // Log successful restore to audit trail const duration = Date.now() - startTime; this.logAuditEntry({ backupId, operation: 'restore', agentId: options.agentId, status: 'success', filePath: metadata.filePath, backupPath: metadata.backupPath, durationMs: duration, metadata: { verified, rollbackBackupId } }); logger.info('Restore completed successfully', { backupId, filePath: metadata.filePath, verified, durationMs: duration }); return { success: true, backupId, filePath: metadata.filePath, backupPath: metadata.backupPath, verified, dryRun: false, restoredAt: new Date(), verificationHash, expectedHash: metadata.originalHash }; } catch (error) { const duration = Date.now() - startTime; logger.error('Restore failed', error instanceof Error ? error : undefined, { backupId, filePath: metadata.filePath, durationMs: duration }); // Log failed restore to audit trail this.logAuditEntry({ backupId, operation: 'restore', agentId: options.agentId, status: 'failure', filePath: metadata.filePath, backupPath: metadata.backupPath, errorMessage: error instanceof Error ? error.message : String(error), errorCode: error instanceof StandardError ? error.code : ErrorCode.UNKNOWN_ERROR, durationMs: duration }); throw error; } finally{ await this.lockManager.releaseLock(lock.id); } } /** * Get disk usage statistics */ getDiskUsage() { const stmt = this.db.prepare(` SELECT COUNT(*) as total_backups, SUM(CASE WHEN deleted_at IS NULL AND expires_at > datetime('now') THEN 1 ELSE 0 END) as active_backups, SUM(CASE WHEN deleted_at IS NULL AND expires_at <= datetime('now') THEN 1 ELSE 0 END) as expired_backups, SUM(file_size) as total_size_bytes, SUM(CASE WHEN is_compressed = 1 THEN file_size ELSE 0 END) as compressed_size_bytes, AVG(CASE WHEN is_compressed = 1 THEN compression_ratio ELSE NULL END) as avg_compression_ratio, MIN(created_at) as oldest_backup, MAX(created_at) as newest_backup FROM backups WHERE deleted_at IS NULL `); const result = stmt.get(); const byTypeStmt = this.db.prepare(` SELECT backup_type, COUNT(*) as count FROM backups WHERE deleted_at IS NULL GROUP BY backup_type `); const byType = byTypeStmt.all(); const byAgentStmt = this.db.prepare(` SELECT agent_id, COUNT(*) as count FROM backups WHERE deleted_at IS NULL GROUP BY agent_id `); const byAgent = byAgentStmt.all(); return { totalBackups: result.total_backups || 0, activeBackups: result.active_backups || 0, expiredBackups: result.expired_backups || 0, totalSizeBytes: result.total_size_bytes || 0, compressedSizeBytes: result.compressed_size_bytes || 0, averageCompressionRatio: result.avg_compression_ratio || 0, oldestBackupDate: result.oldest_backup ? new Date(result.oldest_backup) : null, newestBackupDate: result.newest_backup ? new Date(result.newest_backup) : null, backupsByType: byType.reduce((acc, row)=>{ acc[row.backup_type] = row.count; return acc; }, {}), backupsByAgent: byAgent.reduce((acc, row)=>{ acc[row.agent_id] = row.count; return acc; }, {}) }; } /** * List backups for a file */ listBackups(filePath) { const absolutePath = path.resolve(filePath); const stmt = this.db.prepare(` SELECT * FROM backups WHERE file_path = ? AND deleted_at IS NULL ORDER BY created_at DESC `); return stmt.all(absolutePath); } /** * Delete expired backups */ deleteExpiredBackups() { const expiredBackups = this.db.prepare(` SELECT id, backup_path FROM backups WHERE deleted_at IS NULL AND expires_at <= datetime('now') `).all(); let deletedCount = 0; for (const backup of expiredBackups){ try { // Delete backup file fs.unlinkSync(backup.backup_path); // Mark as deleted in database this.db.prepare(` UPDATE backups SET deleted_at = datetime('now') WHERE id = ? `).run(backup.id); deletedCount++; } catch (error) { logger.error('Failed to delete expired backup', error instanceof Error ? error : undefined, { backupId: backup.id, backupPath: backup.backup_path }); } } logger.info('Expired backups deleted', { count: deletedCount }); return deletedCount; } /** * Close database connection */ close() { this.db.close(); logger.info('Backup manager closed'); } // ============================================================================ // Private Helper Methods // ============================================================================ initializeDatabase(dbPath) { const dbDir = path.dirname(dbPath); if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }); } const db = new Database(dbPath); // Run migration const migrationPath = path.join(this.projectRoot, 'src/db/migrations/up/004-backup-metadata-schema.sql'); if (fs.existsSync(migrationPath)) { const migration = fs.readFileSync(migrationPath, 'utf8'); db.exec(migration); logger.info('Database migration applied', { migrationPath }); } return db; } ensureBackupDirectory() { if (!fs.existsSync(this.backupDir)) { fs.mkdirSync(this.backupDir, { recursive: true, mode: 0o755 }); logger.info('Created backup directory', { directory: this.backupDir }); } } async ensureDirectory(dirPath) { try { await fsMkdir(dirPath, { recursive: true, mode: 0o755 }); } catch (error) { // Ignore if directory already exists if (error.code !== 'EEXIST') { throw error; } } } getBackupPath(agentId, timestamp, hash) { return path.join(this.backupDir, agentId, `${timestamp}_${hash}`, 'original'); } calculateHash(content) { return crypto.createHash('sha256').update(content).digest('hex'); } /** * Constant-time hash comparison to prevent timing attacks * @param hash1 First hash (hex string) * @param hash2 Second hash (hex string) * @returns true if hashes match, false otherwise */ constantTimeHashCompare(hash1, hash2) { try { // Convert hex strings to buffers const buffer1 = Buffer.from(hash1, 'hex'); const buffer2 = Buffer.from(hash2, 'hex'); // Length check (not timing-sensitive as length is not secret) if (buffer1.length !== buffer2.length) { return false; } // Constant-time comparison return crypto.timingSafeEqual(buffer1, buffer2); } catch (error) { // Handle Buffer conversion failures logger.error('Hash comparison failed', error instanceof Error ? error : undefined, { hash1Length: hash1?.length, hash2Length: hash2?.length }); return false; } } async fileExists(filePath) { try { await fsAccess(filePath, fs.constants.F_OK); return true; } catch { return false; } } async safeUnlink(filePath) { try { await fsUnlink(filePath); } catch { // Ignore errors } } insertBackup(backup, encryptionMetadata) { const stmt = this.db.prepare(` INSERT INTO backups ( id, agent_id, file_path, backup_path, original_hash, backup_hash, file_size, backup_type, created_at, expires_at, metadata, is_encrypted, encrypted_at, encryption_algorithm, encryption_iv, encryption_auth_tag, encryption_hmac, encryption_key_version ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run(backup.id, backup.agentId, backup.filePath, backup.backupPath, backup.originalHash, backup.backupHash, backup.fileSize, backup.backupType, backup.createdAt.toISOString(), backup.expiresAt.toISOString(), backup.metadata ? JSON.stringify(backup.metadata) : null, // ENCRYPTION SUPPORT (CVSS 7.2 mitigation) encryptionMetadata ? 1 : 0, encryptionMetadata ? encryptionMetadata.encryptedAt : null, encryptionMetadata ? encryptionMetadata.algorithm : null, encryptionMetadata ? encryptionMetadata.iv : null, encryptionMetadata ? encryptionMetadata.authTag : null, encryptionMetadata ? encryptionMetadata.hmac : null, encryptionMetadata ? encryptionMetadata.keyVersion : null); } getBackupMetadata(backupId) { const stmt = this.db.prepare(` SELECT * FROM backups WHERE id = ? AND deleted_at IS NULL `); return stmt.get(backupId) || null; } getLatestBackup(filePath) { const stmt = this.db.prepare(` SELECT * FROM backups WHERE file_path = ? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1 `); return stmt.get(filePath) || null; } getBackupByTimestamp(filePath, timestamp) { const stmt = this.db.prepare(` SELECT * FROM backups WHERE file_path = ? AND deleted_at IS NULL AND created_at <= ? ORDER BY created_at DESC LIMIT 1 `); return stmt.get(filePath, timestamp.toISOString()) || null; } getBackupByHash(filePath, hash) { const stmt = this.db.prepare(` SELECT * FROM backups WHERE file_path = ? AND original_hash = ? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1 `); return stmt.get(filePath, hash) || null; } checkRateLimit(agentId) { const stmt = this.db.prepare(` SELECT COUNT(*) as count FROM restore_rate_limits WHERE agent_id = ? AND restored_at > datetime('now', '-1 hour') `); const result = stmt.get(agentId); return result.count < this.rateLimitConfig.maxRestoresPerHour; } recordRestore(backupId, agentId, filePath) { const stmt = this.db.prepare(` INSERT INTO restore_rate_limits (id, backup_id, agent_id, file_path, restored_at) VALUES (?, ?, ?, ?, datetime('now')) `); stmt.run(randomUUID(), backupId, agentId, filePath); } logAuditEntry(entry) { const stmt = this.db.prepare(` INSERT INTO backup_audit_log ( id, backup_id, operation, agent_id, status, file_path, backup_path, timestamp, duration_ms, error_message, error_code, metadata ) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), ?, ?, ?, ?) `); stmt.run(randomUUID(), entry.backupId, entry.operation, entry.agentId, entry.status, entry.filePath, entry.backupPath || null, entry.durationMs || null, entry.errorMessage || null, entry.errorCode || null, entry.metadata ? JSON.stringify(entry.metadata) : null); } } /** * Singleton instance */ let defaultManager = null; /** * Get the default backup manager instance */ export function getBackupManager(config) { if (!defaultManager) { defaultManager = new BackupManager(config); } return defaultManager; } /** * Execute a function with automatic backup * * @param filePath - File to backup * @param fn - Function to execute * @param options - Backup options * @returns Promise that resolves with function result */ export async function withBackup(filePath, fn, options) { const manager = getBackupManager(); await manager.createBackup(filePath, options); return fn(); } //# sourceMappingURL=backup-manager.js.map