From 5be282e459eecbfe063f3e3c37f7e8105b2e4325 Mon Sep 17 00:00:00 2001 From: Abner Shang <75654486+abnershang@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:09:21 +0800 Subject: [PATCH] fix(backup): accept root-relative hardlink targets (#89328) --- src/commands/backup-verify.test.ts | 33 ++++++++++++++++++++++++++++++ src/commands/backup-verify.ts | 14 +++++++++---- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/commands/backup-verify.test.ts b/src/commands/backup-verify.test.ts index a1fe469ce83..3cdc3113ff3 100644 --- a/src/commands/backup-verify.test.ts +++ b/src/commands/backup-verify.test.ts @@ -358,6 +358,39 @@ describe("backupVerifyCommand", () => { } }); + it("accepts root-relative internal hardlink targets from older backups", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-rootless-linkpath-")); + const archivePath = path.join(tempDir, "backup.tar.gz"); + const rootRelativeTargetPath = "payload/posix/tmp/.openclaw/target.txt"; + const payloadArchivePath = `${TEST_ARCHIVE_ROOT}/${rootRelativeTargetPath}`; + const hardlinkArchivePath = `${TEST_ARCHIVE_ROOT}/payload/posix/tmp/.openclaw/hardlink.txt`; + try { + const archive = gzipSync( + Buffer.concat([ + encodeTarEntry({ + path: `${TEST_ARCHIVE_ROOT}/manifest.json`, + contents: `${JSON.stringify(createBackupManifest(payloadArchivePath), null, 2)}\n`, + }), + encodeTarEntry({ path: payloadArchivePath, contents: "payload\n" }), + encodeTarEntry({ + path: hardlinkArchivePath, + type: "Link", + linkpath: rootRelativeTargetPath, + }), + Buffer.alloc(1024), + ]), + ); + await fs.writeFile(archivePath, archive); + + const runtime = createBackupVerifyRuntime(); + await expect(backupVerifyCommand(runtime, { archive: archivePath })).resolves.toMatchObject({ + ok: true, + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("rejects hardlink targets missing from archive entries", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-missing-linkpath-")); const archivePath = path.join(tempDir, "broken.tar.gz"); diff --git a/src/commands/backup-verify.ts b/src/commands/backup-verify.ts index c443cfd8a42..892583497c5 100644 --- a/src/commands/backup-verify.ts +++ b/src/commands/backup-verify.ts @@ -301,14 +301,20 @@ function verifyHardlinkTargetsAgainstArchiveRoot( ): void { const normalizedRoot = normalizeArchiveRoot(archiveRoot); for (const target of hardlinkTargets) { - if (!isArchivePathWithin(target.normalized, normalizedRoot)) { + // Older backup archives may store hardlink linkpath values relative to the + // archive root instead of including the root segment. Accept that form only + // when it resolves to a real entry inside this archive. + const normalizedTarget = isArchivePathWithin(target.normalized, normalizedRoot) + ? target.normalized + : path.posix.join(normalizedRoot, target.normalized); + if (!isArchivePathWithin(normalizedTarget, normalizedRoot)) { throw new Error( - `Archive hardlink target is outside the declared archive root: ${target.entryPath} -> ${target.normalized}`, + `Archive hardlink target is outside the declared archive root: ${target.entryPath} -> ${normalizedTarget}`, ); } - if (!entries.has(target.normalized)) { + if (!entries.has(normalizedTarget)) { throw new Error( - `Archive hardlink target is missing from archive entries: ${target.entryPath} -> ${target.normalized}`, + `Archive hardlink target is missing from archive entries: ${target.entryPath} -> ${normalizedTarget}`, ); } }