test: add archive staging helper coverage

This commit is contained in:
Peter Steinberger 2026-03-13 23:49:07 +00:00
parent fffe587e27
commit 8240fc519a
1 changed files with 181 additions and 0 deletions

View File

@ -0,0 +1,181 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
ArchiveSecurityError,
createArchiveSymlinkTraversalError,
mergeExtractedTreeIntoDestination,
prepareArchiveDestinationDir,
prepareArchiveOutputPath,
withStagedArchiveDestination,
} from "./archive-staging.js";
const directorySymlinkType = process.platform === "win32" ? "junction" : undefined;
async function withTempDir(prefix: string, run: (dir: string) => Promise<void>) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
try {
await run(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
describe("archive-staging helpers", () => {
it("accepts real destination directories and returns their real path", async () => {
await withTempDir("openclaw-archive-staging-", async (rootDir) => {
const destDir = path.join(rootDir, "dest");
await fs.mkdir(destDir, { recursive: true });
await expect(prepareArchiveDestinationDir(destDir)).resolves.toBe(await fs.realpath(destDir));
});
});
it.runIf(process.platform !== "win32")(
"rejects symlink and non-directory archive destinations",
async () => {
await withTempDir("openclaw-archive-staging-", async (rootDir) => {
const realDestDir = path.join(rootDir, "real-dest");
const symlinkDestDir = path.join(rootDir, "dest-link");
const fileDest = path.join(rootDir, "dest.txt");
await fs.mkdir(realDestDir, { recursive: true });
await fs.symlink(realDestDir, symlinkDestDir, directorySymlinkType);
await fs.writeFile(fileDest, "nope", "utf8");
await expect(prepareArchiveDestinationDir(symlinkDestDir)).rejects.toMatchObject({
code: "destination-symlink",
} satisfies Partial<ArchiveSecurityError>);
await expect(prepareArchiveDestinationDir(fileDest)).rejects.toMatchObject({
code: "destination-not-directory",
} satisfies Partial<ArchiveSecurityError>);
});
},
);
it("creates in-destination parent directories for file outputs", async () => {
await withTempDir("openclaw-archive-staging-", async (rootDir) => {
const destDir = path.join(rootDir, "dest");
await fs.mkdir(destDir, { recursive: true });
const destinationRealDir = await prepareArchiveDestinationDir(destDir);
const outPath = path.join(destDir, "nested", "payload.txt");
await expect(
prepareArchiveOutputPath({
destinationDir: destDir,
destinationRealDir,
relPath: "nested/payload.txt",
outPath,
originalPath: "nested/payload.txt",
isDirectory: false,
}),
).resolves.toBeUndefined();
await expect(fs.stat(path.dirname(outPath))).resolves.toMatchObject({
isDirectory: expect.any(Function),
});
});
});
it.runIf(process.platform !== "win32")(
"rejects output paths that traverse a destination symlink",
async () => {
await withTempDir("openclaw-archive-staging-", async (rootDir) => {
const destDir = path.join(rootDir, "dest");
const outsideDir = path.join(rootDir, "outside");
const linkDir = path.join(destDir, "escape");
await fs.mkdir(destDir, { recursive: true });
await fs.mkdir(outsideDir, { recursive: true });
await fs.symlink(outsideDir, linkDir, directorySymlinkType);
const destinationRealDir = await prepareArchiveDestinationDir(destDir);
await expect(
prepareArchiveOutputPath({
destinationDir: destDir,
destinationRealDir,
relPath: "escape/payload.txt",
outPath: path.join(linkDir, "payload.txt"),
originalPath: "escape/payload.txt",
isDirectory: false,
}),
).rejects.toMatchObject({
code: "destination-symlink-traversal",
} satisfies Partial<ArchiveSecurityError>);
});
},
);
it("cleans up staged archive directories after success and failure", async () => {
await withTempDir("openclaw-archive-staging-", async (rootDir) => {
const destDir = path.join(rootDir, "dest");
await fs.mkdir(destDir, { recursive: true });
const destinationRealDir = await prepareArchiveDestinationDir(destDir);
let successStage = "";
await withStagedArchiveDestination({
destinationRealDir,
run: async (stagingDir) => {
successStage = stagingDir;
await fs.writeFile(path.join(stagingDir, "payload.txt"), "ok", "utf8");
},
});
await expect(fs.stat(successStage)).rejects.toMatchObject({ code: "ENOENT" });
let failureStage = "";
await expect(
withStagedArchiveDestination({
destinationRealDir,
run: async (stagingDir) => {
failureStage = stagingDir;
throw new Error("boom");
},
}),
).rejects.toThrow("boom");
await expect(fs.stat(failureStage)).rejects.toMatchObject({ code: "ENOENT" });
});
});
it.runIf(process.platform !== "win32")(
"merges staged trees and rejects symlink entries from the source",
async () => {
await withTempDir("openclaw-archive-staging-", async (rootDir) => {
const sourceDir = path.join(rootDir, "source");
const sourceNestedDir = path.join(sourceDir, "nested");
const destDir = path.join(rootDir, "dest");
const outsideDir = path.join(rootDir, "outside");
await fs.mkdir(sourceNestedDir, { recursive: true });
await fs.mkdir(destDir, { recursive: true });
await fs.mkdir(outsideDir, { recursive: true });
await fs.writeFile(path.join(sourceNestedDir, "payload.txt"), "hi", "utf8");
const destinationRealDir = await prepareArchiveDestinationDir(destDir);
await mergeExtractedTreeIntoDestination({
sourceDir,
destinationDir: destDir,
destinationRealDir,
});
await expect(
fs.readFile(path.join(destDir, "nested", "payload.txt"), "utf8"),
).resolves.toBe("hi");
await fs.symlink(outsideDir, path.join(sourceDir, "escape"), directorySymlinkType);
await expect(
mergeExtractedTreeIntoDestination({
sourceDir,
destinationDir: destDir,
destinationRealDir,
}),
).rejects.toMatchObject({
code: "destination-symlink-traversal",
} satisfies Partial<ArchiveSecurityError>);
});
},
);
it("builds a typed archive symlink traversal error", () => {
const error = createArchiveSymlinkTraversalError("nested/payload.txt");
expect(error).toBeInstanceOf(ArchiveSecurityError);
expect(error.code).toBe("destination-symlink-traversal");
expect(error.message).toContain("nested/payload.txt");
});
});