diff --git a/src/commands/backup.atomic.test.ts b/src/commands/backup.atomic.test.ts new file mode 100644 index 00000000000..4f16634eacb --- /dev/null +++ b/src/commands/backup.atomic.test.ts @@ -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 }); + } + }); +}); diff --git a/src/commands/backup.ts b/src/commands/backup.ts index b1fc2f1ad50..5e344dc640e 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -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 { } } +function buildTempArchivePath(outputPath: string): string { + return `${outputPath}.${randomUUID()}.tmp`; +} + async function canonicalizePathForContainment(targetPath: string): Promise { 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); }