fix(backup): accept root-relative hardlink targets (#89328)

This commit is contained in:
Abner Shang
2026-06-02 14:09:21 +08:00
committed by GitHub
parent 4df832412e
commit 5be282e459
2 changed files with 43 additions and 4 deletions

View File

@@ -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");

View File

@@ -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}`,
);
}
}