import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { ensureDataLayout, getDataConfig } from "./lib/data-dir.mjs"; const args = parseArgs(process.argv.slice(2)); if (!args.from) { throw new Error("Usage: node scripts/restore.mjs --from [--target ] [--force]"); } const targetEnv = { ...process.env, DATA_DIR: args.target || process.env.DATA_DIR, DATABASE_PATH: args.target ? undefined : process.env.DATABASE_PATH, }; const config = ensureDataLayout(getDataConfig(targetEnv)); const backupDir = path.resolve(args.from); const backupDbPath = path.join(backupDir, "neta.db"); const backupUploadsDir = path.join(backupDir, "uploads"); const manifestPath = path.join(backupDir, "manifest.json"); if (!fs.existsSync(backupDbPath)) { throw new Error(`Backup database not found: ${backupDbPath}`); } verifyManifest(backupDir, manifestPath); if (fs.existsSync(config.databasePath) && !args.force) { throw new Error(`Target database exists: ${config.databasePath}. Pass --force to overwrite.`); } const restoreId = `${process.pid}-${Date.now()}`; const stagedDatabasePath = `${config.databasePath}.restore-stage-${restoreId}`; const rollbackDatabasePath = `${config.databasePath}.restore-rollback-${restoreId}`; const stagedUploadsDir = `${config.uploadsDir}.restore-stage-${restoreId}`; const rollbackUploadsDir = `${config.uploadsDir}.restore-rollback-${restoreId}`; let databaseMovedToRollback = false; let uploadsMovedToRollback = false; let stagedDatabaseInstalled = false; let stagedUploadsInstalled = false; try { fs.copyFileSync(backupDbPath, stagedDatabasePath, fs.constants.COPYFILE_EXCL); if (hashFile(stagedDatabasePath) !== hashFile(backupDbPath)) { throw new Error("Staged database checksum does not match the verified backup."); } fs.mkdirSync(stagedUploadsDir); if (fs.existsSync(backupUploadsDir)) { copyDirectory(backupUploadsDir, stagedUploadsDir); } if (fs.existsSync(config.databasePath)) { fs.renameSync(config.databasePath, rollbackDatabasePath); databaseMovedToRollback = true; } if (fs.existsSync(config.uploadsDir)) { fs.renameSync(config.uploadsDir, rollbackUploadsDir); uploadsMovedToRollback = true; } fs.renameSync(stagedDatabasePath, config.databasePath); stagedDatabaseInstalled = true; fs.renameSync(stagedUploadsDir, config.uploadsDir); stagedUploadsInstalled = true; } catch (error) { if (stagedDatabaseInstalled && fs.existsSync(config.databasePath)) { fs.rmSync(config.databasePath, { force: true }); } if (stagedUploadsInstalled && fs.existsSync(config.uploadsDir)) { fs.rmSync(config.uploadsDir, { recursive: true, force: true }); } if (databaseMovedToRollback && fs.existsSync(rollbackDatabasePath)) { fs.renameSync(rollbackDatabasePath, config.databasePath); } if (uploadsMovedToRollback && fs.existsSync(rollbackUploadsDir)) { fs.renameSync(rollbackUploadsDir, config.uploadsDir); } throw error; } finally { fs.rmSync(stagedDatabasePath, { force: true }); fs.rmSync(stagedUploadsDir, { recursive: true, force: true }); } fs.rmSync(rollbackDatabasePath, { force: true }); fs.rmSync(rollbackUploadsDir, { recursive: true, force: true }); console.log(`Backup restored from ${backupDir} to ${config.dataDir}`); function parseArgs(values) { const parsed = { force: false }; for (let index = 0; index < values.length; index += 1) { const value = values[index]; if (value === "--force") { parsed.force = true; } else if (value === "--from") { parsed.from = values[index + 1]; index += 1; } else if (value === "--target") { parsed.target = values[index + 1]; index += 1; } } return parsed; } function copyDirectory(sourceDir, targetDir) { fs.mkdirSync(targetDir, { recursive: true }); for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { const sourcePath = path.join(sourceDir, entry.name); const targetPath = path.join(targetDir, entry.name); if (entry.isSymbolicLink()) { throw new Error(`Backup contains a symbolic link: ${sourcePath}`); } else if (entry.isDirectory()) { copyDirectory(sourcePath, targetPath); } else if (entry.isFile()) { fs.copyFileSync(sourcePath, targetPath); } else { throw new Error(`Backup contains an unsupported filesystem entry: ${sourcePath}`); } } } function verifyManifest(rootDir, manifestFile) { if (!fs.existsSync(manifestFile)) { throw new Error(`Backup manifest not found: ${manifestFile}`); } const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8")); if ( (manifest.format !== undefined && manifest.format !== "neta-backup") || (manifest.version !== undefined && manifest.version !== 1) ) { throw new Error("Backup manifest format or version is not supported."); } if (!Array.isArray(manifest.files) || manifest.files.length === 0) { throw new Error("Backup manifest has no file entries."); } const normalizedRoot = path.resolve(rootDir); const verifiedPaths = new Set(); for (const entry of manifest.files) { if ( !entry || typeof entry.path !== "string" || typeof entry.bytes !== "number" || typeof entry.sha256 !== "string" || !/^[0-9a-f]{64}$/i.test(entry.sha256) ) { throw new Error("Backup manifest contains an invalid file entry."); } const filePath = path.resolve(normalizedRoot, entry.path); if (!filePath.startsWith(`${normalizedRoot}${path.sep}`)) { throw new Error(`Backup manifest path escapes backup root: ${entry.path}`); } const stat = fs.lstatSync(filePath); if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== entry.bytes) { throw new Error(`Backup file metadata mismatch: ${entry.path}`); } const actualHash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); if (!crypto.timingSafeEqual(Buffer.from(actualHash, "hex"), Buffer.from(entry.sha256, "hex"))) { throw new Error(`Backup checksum mismatch: ${entry.path}`); } verifiedPaths.add(entry.path.replace(/\\/g, "/")); } const actualPaths = collectBackupFiles(normalizedRoot, normalizedRoot); if ( actualPaths.length !== verifiedPaths.size || actualPaths.some((filePath) => !verifiedPaths.has(filePath)) ) { throw new Error("Backup contains files that are missing from the checksum manifest."); } } function collectBackupFiles(rootDir, currentDir) { const files = []; for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { const entryPath = path.join(currentDir, entry.name); if (entryPath === path.join(rootDir, "manifest.json")) continue; if (entry.isSymbolicLink()) throw new Error(`Backup contains a symbolic link: ${entry.name}`); if (entry.isDirectory()) { files.push(...collectBackupFiles(rootDir, entryPath)); } else if (entry.isFile()) { files.push(path.relative(rootDir, entryPath).replace(/\\/g, "/")); } } return files; } function hashFile(filePath) { return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); }