fix(backup): constrain restore targets

This commit is contained in:
Peter Steinberger
2026-05-15 17:59:02 +01:00
parent e0d9199a36
commit 8d86b7b5d1
2 changed files with 124 additions and 9 deletions

View File

@@ -92,6 +92,7 @@ describe("backupRestoreCommand", () => {
afterEach(async () => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
await fs.rm(tempDir, { recursive: true, force: true });
});
@@ -118,6 +119,7 @@ describe("backupRestoreCommand", () => {
await fs.writeFile(path.join(restoredStateDir, "state.txt"), "restored\n");
await createSqliteDb(path.join(restoredStateDir, "state", "openclaw.sqlite"), "restored");
await createBackupArchive({ archivePath, sourceStateDir, restoredStateDir });
vi.stubEnv("OPENCLAW_STATE_DIR", sourceStateDir);
const runtime = createRuntime();
const result = await backupRestoreCommand(runtime, { archive: archivePath, dryRun: true });
@@ -130,7 +132,7 @@ describe("backupRestoreCommand", () => {
expect(await fs.readFile(path.join(sourceStateDir, "state.txt"), "utf8")).toBe("current\n");
});
it("restores verified SQLite snapshots to their recorded source paths", async () => {
it("restores verified SQLite snapshots to the current state path", async () => {
const sourceStateDir = path.join(tempDir, "state");
const restoredStateDir = path.join(tempDir, "snapshot-state");
const archivePath = path.join(tempDir, "backup.tar.gz");
@@ -141,6 +143,7 @@ describe("backupRestoreCommand", () => {
await fs.writeFile(path.join(restoredStateDir, "state.txt"), "restored\n");
await createSqliteDb(path.join(restoredStateDir, "state", "openclaw.sqlite"), "restored");
await createBackupArchive({ archivePath, sourceStateDir, restoredStateDir });
vi.stubEnv("OPENCLAW_STATE_DIR", sourceStateDir);
const runtime = createRuntime();
const result = await backupRestoreCommand(runtime, { archive: archivePath, yes: true });
@@ -152,4 +155,44 @@ describe("backupRestoreCommand", () => {
expect(await fs.readFile(path.join(sourceStateDir, "state.txt"), "utf8")).toBe("restored\n");
expect(readSqliteValue(path.join(sourceStateDir, "state", "openclaw.sqlite"))).toBe("restored");
});
it("does not trust archived source paths as restore destinations", async () => {
const currentStateDir = path.join(tempDir, "current-state");
const archivedSourceStateDir = path.join(tempDir, "archived-machine-state");
const restoredStateDir = path.join(tempDir, "snapshot-state");
const archivePath = path.join(tempDir, "backup.tar.gz");
await fs.mkdir(currentStateDir, { recursive: true });
await fs.writeFile(path.join(currentStateDir, "state.txt"), "current\n");
await createSqliteDb(path.join(currentStateDir, "state", "openclaw.sqlite"), "current");
await fs.mkdir(archivedSourceStateDir, { recursive: true });
await fs.writeFile(path.join(archivedSourceStateDir, "state.txt"), "archived-machine\n");
await fs.mkdir(restoredStateDir, { recursive: true });
await fs.writeFile(path.join(restoredStateDir, "state.txt"), "restored\n");
await createSqliteDb(path.join(restoredStateDir, "state", "openclaw.sqlite"), "restored");
await createBackupArchive({
archivePath,
sourceStateDir: archivedSourceStateDir,
restoredStateDir,
});
vi.stubEnv("OPENCLAW_STATE_DIR", currentStateDir);
const runtime = createRuntime();
const result = await backupRestoreCommand(runtime, { archive: archivePath, yes: true });
expect(result?.restoredAssets).toEqual([
expect.objectContaining({
kind: "state",
originalSourcePath: archivedSourceStateDir,
sourcePath: currentStateDir,
status: "restored",
}),
]);
expect(await fs.readFile(path.join(currentStateDir, "state.txt"), "utf8")).toBe("restored\n");
expect(readSqliteValue(path.join(currentStateDir, "state", "openclaw.sqlite"))).toBe(
"restored",
);
expect(await fs.readFile(path.join(archivedSourceStateDir, "state.txt"), "utf8")).toBe(
"archived-machine\n",
);
});
});

View File

@@ -3,8 +3,10 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import * as tar from "tar";
import { resolveConfigPath, resolveOAuthDir, resolveStateDir } from "../config/config.js";
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
import { shortenHomePath, resolveUserPath } from "../utils.js";
import { resolveBackupPlanFromDisk, type BackupAssetKind } from "./backup-shared.js";
import { verifyBackupArchive } from "./backup-verify.js";
export type BackupRestoreOptions = {
@@ -21,6 +23,7 @@ export type BackupRestoreResult = {
verified: true;
restoredAssets: Array<{
kind: string;
originalSourcePath: string;
sourcePath: string;
archivePath: string;
status: "planned" | "restored";
@@ -76,6 +79,80 @@ async function replacePathFromExtracted(params: {
}
}
function normalizeRestoreAssetKind(kind: string): BackupAssetKind {
switch (kind) {
case "state":
case "config":
case "credentials":
case "workspace":
return kind;
default:
throw new Error(`Backup restore does not support asset kind: ${kind}`);
}
}
async function resolveWorkspaceRestoreTargets(): Promise<Set<string>> {
const plan = await resolveBackupPlanFromDisk({ includeWorkspace: true });
return new Set(plan.workspaceDirs.map((workspaceDir) => path.resolve(workspaceDir)));
}
async function resolveBackupRestoreTarget(params: {
kind: string;
sourcePath: string;
workspaceTargets?: Set<string>;
}): Promise<string> {
const kind = normalizeRestoreAssetKind(params.kind);
switch (kind) {
case "state":
return path.resolve(resolveStateDir());
case "config":
return path.resolve(resolveConfigPath());
case "credentials":
return path.resolve(resolveOAuthDir());
case "workspace": {
const sourcePath = path.resolve(params.sourcePath);
const workspaceTargets = params.workspaceTargets ?? (await resolveWorkspaceRestoreTargets());
if (!workspaceTargets.has(sourcePath)) {
throw new Error(
`Backup workspace restore target is not in the current OpenClaw workspace configuration: ${params.sourcePath}`,
);
}
return sourcePath;
}
}
}
async function resolveBackupRestoreAssets(
assets: Array<{ kind: string; sourcePath: string; archivePath: string }>,
): Promise<BackupRestoreResult["restoredAssets"]> {
const needsWorkspaceTargets = assets.some((asset) => asset.kind === "workspace");
const workspaceTargets = needsWorkspaceTargets
? await resolveWorkspaceRestoreTargets()
: undefined;
const seenTargets = new Set<string>();
const restoredAssets: BackupRestoreResult["restoredAssets"] = [];
for (const asset of assets) {
const sourcePath = await resolveBackupRestoreTarget({
kind: asset.kind,
sourcePath: asset.sourcePath,
workspaceTargets,
});
const targetKey = path.resolve(sourcePath);
if (seenTargets.has(targetKey)) {
throw new Error(`Backup restore contains duplicate target path: ${sourcePath}`);
}
seenTargets.add(targetKey);
restoredAssets.push({
kind: asset.kind,
originalSourcePath: asset.sourcePath,
sourcePath,
archivePath: asset.archivePath,
status: "planned",
});
}
return restoredAssets;
}
function formatBackupRestoreSummary(result: BackupRestoreResult): string[] {
const lines = [
`Backup archive: ${result.archivePath}`,
@@ -118,21 +195,16 @@ export async function backupRestoreCommand(
cwd: tempDir,
});
const { manifest } = verified;
const restoredAssets: BackupRestoreResult["restoredAssets"] = [];
for (const asset of manifest.assets) {
const restoredAssets = await resolveBackupRestoreAssets(manifest.assets);
for (const asset of restoredAssets) {
const extractedPath = extractedArchivePath(tempDir, asset.archivePath);
await fs.access(extractedPath);
restoredAssets.push({
kind: asset.kind,
sourcePath: asset.sourcePath,
archivePath: asset.archivePath,
status: dryRun ? "planned" : "restored",
});
if (!dryRun) {
await replacePathFromExtracted({
extractedPath,
targetPath: asset.sourcePath,
});
asset.status = "restored";
}
}