fix: write backup archives atomically

This commit is contained in:
SC-Claw 2026-03-09 02:52:42 +08:00 committed by Gustavo Madeira Santana
parent bef8409562
commit 88e52ff19d
2 changed files with 71 additions and 1 deletions

View File

@ -0,0 +1,62 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
const tarCreateMock = vi.hoisted(() => vi.fn());
const backupVerifyCommandMock = vi.hoisted(() => vi.fn());
vi.mock("tar", () => ({
c: tarCreateMock,
}));
vi.mock("./backup-verify.js", () => ({
backupVerifyCommand: backupVerifyCommandMock,
}));
const { backupCreateCommand } = await import("./backup.js");
describe("backupCreateCommand atomic archive write", () => {
let tempHome: TempHomeEnv;
beforeEach(async () => {
tempHome = await createTempHomeEnv("openclaw-backup-atomic-test-");
tarCreateMock.mockReset();
backupVerifyCommandMock.mockReset();
});
afterEach(async () => {
await tempHome.restore();
});
it("does not leave a partial final archive behind when tar creation fails", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
const archiveDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-failure-"));
try {
await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8");
await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8");
tarCreateMock.mockRejectedValueOnce(new Error("disk full"));
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const outputPath = path.join(archiveDir, "backup.tar.gz");
await expect(
backupCreateCommand(runtime, {
output: outputPath,
}),
).rejects.toThrow(/disk full/i);
await expect(fs.access(outputPath)).rejects.toThrow();
const remaining = await fs.readdir(archiveDir);
expect(remaining).toEqual([]);
} finally {
await fs.rm(archiveDir, { recursive: true, force: true });
}
});
});

View File

@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@ -119,6 +120,10 @@ async function assertOutputPathReady(outputPath: string): Promise<void> {
}
}
function buildTempArchivePath(outputPath: string): string {
return `${outputPath}.${randomUUID()}.tmp`;
}
async function canonicalizePathForContainment(targetPath: string): Promise<string> {
const resolved = path.resolve(targetPath);
try {
@ -265,6 +270,7 @@ export async function backupCreateCommand(
await fs.mkdir(path.dirname(outputPath), { recursive: true });
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-"));
const manifestPath = path.join(tempDir, "manifest.json");
const tempArchivePath = buildTempArchivePath(outputPath);
try {
const manifest = buildManifest({
createdAt,
@ -281,7 +287,7 @@ export async function backupCreateCommand(
await tar.c(
{
file: outputPath,
file: tempArchivePath,
gzip: true,
portable: true,
preservePaths: true,
@ -295,7 +301,9 @@ export async function backupCreateCommand(
},
[manifestPath, ...result.assets.map((asset) => asset.sourcePath)],
);
await fs.rename(tempArchivePath, outputPath);
} finally {
await fs.rm(tempArchivePath, { force: true }).catch(() => undefined);
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
}