diff --git a/src/infra/archive-staging.test.ts b/src/infra/archive-staging.test.ts new file mode 100644 index 00000000000..8d15a501ed5 --- /dev/null +++ b/src/infra/archive-staging.test.ts @@ -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) { + 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); + await expect(prepareArchiveDestinationDir(fileDest)).rejects.toMatchObject({ + code: "destination-not-directory", + } satisfies Partial); + }); + }, + ); + + 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); + }); + }, + ); + + 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); + }); + }, + ); + + 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"); + }); +});