From 018ac8f138cbeb49f835378b005e3b922e14b9c3 Mon Sep 17 00:00:00 2001 From: Nish <257724087+nish3451@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:22:03 +0530 Subject: [PATCH] feat(service): add restore command for the private state backup roundtrip service-state-backup.mjs gains a restore mode that validates the snapshot exactly like verify, refuses any pre-existing canonical root (clean-clone restore only), stages beside the target repo, and swaps each root atomically via rename with full rollback on failure. The service-engine test roundtrip now restores through the product command instead of cpSync, and new tests cover missing/malformed input, existing-target refusal, byte+mode parity, re-backup equivalence, and an injected mid-swap interruption that fails closed and preserves the target. --- scripts/service-state-backup.mjs | 57 ++++++++++++++++++++++- scripts/test-service-engine.mjs | 80 ++++++++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 6 deletions(-) diff --git a/scripts/service-state-backup.mjs b/scripts/service-state-backup.mjs index 236e7204..6ac6c111 100644 --- a/scripts/service-state-backup.mjs +++ b/scripts/service-state-backup.mjs @@ -206,6 +206,58 @@ function create(repoPath, requestedOutput) { } } +function restore(repoPath, inputPath) { + const verified = verify(repoPath, inputPath) + const repoRoot = realpathSync(resolve(repoPath)) + const manifest = validateManifest(JSON.parse(readFileSync(join(verified.input, MANIFEST), "utf8"))) + const roots = [...RECORD_ROOTS, OUTPUT_ROOT].filter(root => verified.roots.includes(root)) + const release = acquireLock(queuePaths(repoRoot).lockDir) + let staging = "" + let swapped = [] + let committed = false + try { + for (const root of roots) { + assert(!entryExists(join(repoRoot, root)), `restore target already exists: ${root}`) + } + staging = join(dirname(repoRoot), `.service-restore-staging-${randomUUID()}`) + mkdirSync(staging, {mode: 0o700}) + chmodSync(staging, 0o700) + for (const root of roots) { + const destination = join(staging, root) + privateDirectory(destination, staging) + walk(join(verified.input, root), {copyTo: destination}) + } + assert.deepEqual(walk(staging, {verifyModes: true}), manifest.entries, "staged restore does not match the backup manifest") + for (const root of roots) { + const target = join(repoRoot, root) + mkdirSync(dirname(target), {recursive: true}) + renameSync(join(staging, root), target) + swapped.push(root) + if (process.env.SERVICE_BACKUP_TEST_INTERRUPT_SWAP === "1") { + throw new Error("service state restore interrupted mid-swap (test injection)") + } + } + committed = true + for (const root of swapped) { + let current = dirname(join(repoRoot, root)) + while (current !== repoRoot && containedBy(repoRoot, current)) { + chmodSync(current, 0o700) + current = dirname(current) + } + } + if (staging) rmSync(staging, {recursive: true, force: true}) + return {status: "restored", input: verified.input, target: repoRoot, roots, files: verified.files} + } catch (error) { + if (!committed) { + for (const root of swapped) rmSync(join(repoRoot, root), {recursive: true, force: true}) + } + if (staging) rmSync(staging, {recursive: true, force: true}) + throw error + } finally { + release() + } +} + function option(args, name) { const index = args.findIndex(value => value === `--${name}` || value.startsWith(`--${name}=`)) if (index < 0) return "" @@ -214,9 +266,10 @@ function option(args, name) { try { const [mode, ...args] = process.argv.slice(2) - assert(["create", "verify"].includes(mode), "usage: service-state-backup.mjs create --output /absolute/path | verify --input /absolute/path") + assert(["create", "verify", "restore"].includes(mode), "usage: service-state-backup.mjs create --output /absolute/path | verify --input /absolute/path | restore --input /absolute/path") const repoRoot = process.env.SERVICE_REPO_ROOT || process.cwd() - console.log(JSON.stringify(mode === "create" ? create(repoRoot, option(args, "output")) : verify(repoRoot, option(args, "input")), null, 2)) + const result = mode === "create" ? create(repoRoot, option(args, "output")) : mode === "verify" ? verify(repoRoot, option(args, "input")) : restore(repoRoot, option(args, "input")) + console.log(JSON.stringify(result, null, 2)) } catch (error) { console.error(`service state backup failed: ${error.message}`) process.exit(1) diff --git a/scripts/test-service-engine.mjs b/scripts/test-service-engine.mjs index d9ba5a10..10e892ec 100644 --- a/scripts/test-service-engine.mjs +++ b/scripts/test-service-engine.mjs @@ -1120,11 +1120,13 @@ try { const restoredRoot = join(backupRoundtripParent, "restored") md(restoredRoot) for (const root of ["contracts", GROWTH]) cpSync(rp(root), join(restoredRoot, root), {recursive: true}) - for (const root of ["clients", "prospects", DECISIONS, "runs"]) { - const source = join(snapshot, root) - if (ex(source)) cpSync(source, join(restoredRoot, root), {recursive: true}) - } const restoredEnv = {...E(QUEUE_TEST_NOW), SERVICE_REPO_ROOT: restoredRoot} + const productRestored = run(BACKUP, ["restore", "--input", snapshot], restoredEnv) + eq(productRestored.status, 0, productRestored.stderr) + const productRestoredOutput = JSON.parse(productRestored.stdout) + eq(productRestoredOutput.status, "restored") + deq(productRestoredOutput.roots, ["clients", "prospects", DECISIONS, "runs/service-engine/outputs"]) + assert(productRestoredOutput.files > 0) for (const [mode, extra] of [ ["prepare", []], ["check", []], @@ -2195,6 +2197,76 @@ try { const inRepoBackup = rp("runtime", "in-repo-backup") neq(run(BACKUP, ["create", "--output", inRepoBackup]).status, 0) eq(ex(inRepoBackup), false) + + // ——— restore: product command into isolated clean-clone targets, fail closed ——— + const restoreTarget = join(backupParent, "restore-target") + const prepareRestoreTarget = () => { + rm(restoreTarget, {recursive: true, force: true}) + md(restoreTarget) + for (const root of ["contracts", GROWTH]) cpSync(rp(root), join(restoreTarget, root), {recursive: true}) + } + const restoreEnv = {...E(), SERVICE_REPO_ROOT: restoreTarget} + prepareRestoreTarget() + const missingRestoreInputBefore = snap(restoreTarget) + const missingRestoreInput = run(BACKUP, ["restore", "--input", join(backupParent, "missing-snapshot")], restoreEnv) + neq(missingRestoreInput.status, 0) + mat(missingRestoreInput.stderr, /no such file or directory/) + deq(snap(restoreTarget), missingRestoreInputBefore) + const missingManifestBackup = join(backupParent, "missing-manifest-snapshot") + cpSync(backupPath, missingManifestBackup, {recursive: true}) + un(join(missingManifestBackup, "manifest.json")) + const missingManifestBefore = snap(restoreTarget) + const missingManifestRestore = run(BACKUP, ["restore", "--input", missingManifestBackup], restoreEnv) + neq(missingManifestRestore.status, 0) + mat(missingManifestRestore.stderr, /no such file or directory/) + deq(snap(restoreTarget), missingManifestBefore) + rm(missingManifestBackup, {recursive: true, force: true}) + const corruptedBackup = join(backupParent, "corrupted-snapshot") + cpSync(backupPath, corruptedBackup, {recursive: true}) + const corruptedBackupDecision = backedUpFiles.find(entry => entry.path.startsWith("service-decisions/")) + const corruptedBackupDecisionPath = join(corruptedBackup, corruptedBackupDecision.path) + wf(corruptedBackupDecisionPath, Buffer.concat([rf(corruptedBackupDecisionPath), Buffer.from(" ")])) + const corruptedRestoreBefore = snap(restoreTarget) + const corruptedRestore = run(BACKUP, ["restore", "--input", corruptedBackup], restoreEnv) + neq(corruptedRestore.status, 0) + mat(corruptedRestore.stderr, /file manifest mismatch/) + deq(snap(restoreTarget), corruptedRestoreBefore) + rm(corruptedBackup, {recursive: true, force: true}) + md(join(restoreTarget, "clients", "existing"), {recursive: true}) + aw(join(restoreTarget, "clients", "existing", "note.json"), {kept: true}) + const existingTargetBefore = snap(restoreTarget) + const existingTargetRestore = run(BACKUP, ["restore", "--input", backupPath], restoreEnv) + neq(existingTargetRestore.status, 0) + mat(existingTargetRestore.stderr, /restore target already exists: clients/) + deq(snap(restoreTarget), existingTargetBefore) + prepareRestoreTarget() + const successfulRestore = run(BACKUP, ["restore", "--input", backupPath], restoreEnv) + eq(successfulRestore.status, 0, successfulRestore.stderr) + const successfulRestoreOutput = JSON.parse(successfulRestore.stdout) + eq(successfulRestoreOutput.status, "restored") + deq(successfulRestoreOutput.roots, ["clients", "prospects", "service-decisions", "runs/service-engine/outputs"]) + for (const [relativePath, hash] of Object.entries(snap(backupPath))) { + if (relativePath === "manifest.json") continue + eq(sha256(rf(join(restoreTarget, relativePath))), hash, `restore changed bytes: ${relativePath}`) + } + for (const entry of manifest.entries) { + const restoredEntryPath = join(restoreTarget, entry.path) + eq(statSync(restoredEntryPath).mode & 0o777, entry.type === "directory" ? 0o700 : 0o600, `restore permission mismatch: ${entry.path}`) + } + const reBackupPath = join(backupParent, "re-backup") + eq(run(BACKUP, ["create", "--output", reBackupPath], {...E(), SERVICE_REPO_ROOT: restoreTarget}).status, 0) + deq(rj(join(reBackupPath, "manifest.json")).entries, manifest.entries) + eq(run(BACKUP, ["verify", "--input", reBackupPath]).status, 0) + prepareRestoreTarget() + const interruptedBefore = snap(restoreTarget) + const interruptedRestore = run(BACKUP, ["restore", "--input", backupPath], {...restoreEnv, SERVICE_BACKUP_TEST_INTERRUPT_SWAP: "1"}) + neq(interruptedRestore.status, 0) + mat(interruptedRestore.stderr, /interrupted mid-swap/) + deq(snap(restoreTarget), interruptedBefore) + for (const root of ["clients", "prospects", "service-decisions", "runs/service-engine/outputs"]) eq(ex(join(restoreTarget, root)), false) + assert(!readdirSync(backupParent).some(name => name.startsWith(".service-restore-staging-"))) + eq(run(BACKUP, ["verify", "--input", backupPath]).status, 0) + rm(restoreTarget, {recursive: true, force: true}) } finally { rm(backupParent, {recursive: true, force: true}) }