diff --git a/src/commands/backup-verify.test.ts b/src/commands/backup-verify.test.ts index 7723a3c46cd..9288d2fb8c1 100644 --- a/src/commands/backup-verify.test.ts +++ b/src/commands/backup-verify.test.ts @@ -167,6 +167,64 @@ describe("backupVerifyCommand", () => { } }); + it("fails when archive paths contain backslashes", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-backslash-")); + const archivePath = path.join(tempDir, "broken.tar.gz"); + const manifestPath = path.join(tempDir, "manifest.json"); + const payloadPath = path.join(tempDir, "payload.txt"); + try { + const rootName = "2026-03-09T00-00-00.000Z-openclaw-backup"; + const invalidPath = `${rootName}/payload\\..\\escaped.txt`; + const manifest = { + schemaVersion: 1, + createdAt: "2026-03-09T00:00:00.000Z", + archiveRoot: rootName, + runtimeVersion: "test", + platform: process.platform, + nodeVersion: process.version, + assets: [ + { + kind: "state", + sourcePath: "/tmp/.openclaw", + archivePath: invalidPath, + }, + ], + }; + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + await fs.writeFile(payloadPath, "payload\n", "utf8"); + await tar.c( + { + file: archivePath, + gzip: true, + portable: true, + preservePaths: true, + onWriteEntry: (entry) => { + if (entry.path === manifestPath) { + entry.path = `${rootName}/manifest.json`; + return; + } + if (entry.path === payloadPath) { + entry.path = invalidPath; + } + }, + }, + [manifestPath, payloadPath], + ); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( + /forward slashes/i, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("ignores payload manifest.json files when locating the backup manifest", async () => { const stateDir = path.join(tempHome.home, ".openclaw"); const externalWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-")); @@ -271,4 +329,64 @@ describe("backupVerifyCommand", () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it("fails when the archive contains duplicate payload entries", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-duplicate-payload-")); + const archivePath = path.join(tempDir, "broken.tar.gz"); + const manifestPath = path.join(tempDir, "manifest.json"); + const payloadPathA = path.join(tempDir, "payload-a.txt"); + const payloadPathB = path.join(tempDir, "payload-b.txt"); + try { + const rootName = "2026-03-09T00-00-00.000Z-openclaw-backup"; + const payloadArchivePath = `${rootName}/payload/posix/tmp/.openclaw/payload.txt`; + const manifest = { + schemaVersion: 1, + createdAt: "2026-03-09T00:00:00.000Z", + archiveRoot: rootName, + runtimeVersion: "test", + platform: process.platform, + nodeVersion: process.version, + assets: [ + { + kind: "state", + sourcePath: "/tmp/.openclaw", + archivePath: payloadArchivePath, + }, + ], + }; + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + await fs.writeFile(payloadPathA, "payload-a\n", "utf8"); + await fs.writeFile(payloadPathB, "payload-b\n", "utf8"); + await tar.c( + { + file: archivePath, + gzip: true, + portable: true, + preservePaths: true, + onWriteEntry: (entry) => { + if (entry.path === manifestPath) { + entry.path = `${rootName}/manifest.json`; + return; + } + if (entry.path === payloadPathA || entry.path === payloadPathB) { + entry.path = payloadArchivePath; + } + }, + }, + [manifestPath, payloadPathA, payloadPathB], + ); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( + /duplicate entry path/i, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/commands/backup-verify.ts b/src/commands/backup-verify.ts index 109955bdfb1..0199c8de259 100644 --- a/src/commands/backup-verify.ts +++ b/src/commands/backup-verify.ts @@ -67,6 +67,9 @@ function normalizeArchivePath(entryPath: string, label: string): string { if (trimmed.startsWith("/") || WINDOWS_ABSOLUTE_ARCHIVE_PATH_RE.test(trimmed)) { throw new Error(`${label} must be relative: ${entryPath}`); } + if (trimmed.includes("\\")) { + throw new Error(`${label} must use forward slashes: ${entryPath}`); + } if (trimmed.split("/").some((segment) => segment === "." || segment === "..")) { throw new Error(`${label} contains path traversal segments: ${entryPath}`); } @@ -260,6 +263,19 @@ function formatResult(result: BackupVerifyResult): string { ].join("\n"); } +function findDuplicateNormalizedEntryPath( + entries: Array<{ normalized: string }>, +): string | undefined { + const seen = new Set(); + for (const entry of entries) { + if (seen.has(entry.normalized)) { + return entry.normalized; + } + seen.add(entry.normalized); + } + return undefined; +} + export async function backupVerifyCommand( runtime: RuntimeEnv, opts: BackupVerifyOptions, @@ -280,6 +296,10 @@ export async function backupVerifyCommand( if (manifestMatches.length !== 1) { throw new Error(`Expected exactly one backup manifest entry, found ${manifestMatches.length}.`); } + const duplicateEntryPath = findDuplicateNormalizedEntryPath(entries); + if (duplicateEntryPath) { + throw new Error(`Archive contains duplicate entry path: ${duplicateEntryPath}`); + } const manifestEntryPath = manifestMatches[0]?.raw; if (!manifestEntryPath) { throw new Error("Backup archive manifest entry could not be resolved.");