From 7709e4a21974e2d84ef4e50de9eca47d61fda435 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 00:06:47 +0000 Subject: [PATCH] test: extract archive helper coverage --- src/infra/archive-helpers.test.ts | 125 ++++++++++++++++++++++++++++++ src/infra/archive.test.ts | 23 +----- 2 files changed, 126 insertions(+), 22 deletions(-) create mode 100644 src/infra/archive-helpers.test.ts diff --git a/src/infra/archive-helpers.test.ts b/src/infra/archive-helpers.test.ts new file mode 100644 index 00000000000..178a1989733 --- /dev/null +++ b/src/infra/archive-helpers.test.ts @@ -0,0 +1,125 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; +import { + createTarEntryPreflightChecker, + fileExists, + readJsonFile, + resolveArchiveKind, + resolvePackedRootDir, + withTimeout, +} from "./archive.js"; + +const tempDirs = createTrackedTempDirs(); +const createTempDir = () => tempDirs.make("openclaw-archive-helper-test-"); + +afterEach(async () => { + vi.useRealTimers(); + await tempDirs.cleanup(); +}); + +describe("archive helpers", () => { + it.each([ + { input: "/tmp/file.zip", expected: "zip" }, + { input: "/tmp/file.TAR.GZ", expected: "tar" }, + { input: "/tmp/file.tgz", expected: "tar" }, + { input: "/tmp/file.tar", expected: "tar" }, + { input: "/tmp/file.txt", expected: null }, + ])("detects archive kind for $input", ({ input, expected }) => { + expect(resolveArchiveKind(input)).toBe(expected); + }); + + it("resolves packed roots from package dir or single extracted root dir", async () => { + const directDir = await createTempDir(); + const fallbackDir = await createTempDir(); + await fs.mkdir(path.join(directDir, "package"), { recursive: true }); + await fs.mkdir(path.join(fallbackDir, "bundle-root"), { recursive: true }); + + await expect(resolvePackedRootDir(directDir)).resolves.toBe(path.join(directDir, "package")); + await expect(resolvePackedRootDir(fallbackDir)).resolves.toBe( + path.join(fallbackDir, "bundle-root"), + ); + }); + + it("rejects unexpected packed root layouts", async () => { + const multipleDir = await createTempDir(); + const emptyDir = await createTempDir(); + await fs.mkdir(path.join(multipleDir, "a"), { recursive: true }); + await fs.mkdir(path.join(multipleDir, "b"), { recursive: true }); + await fs.writeFile(path.join(emptyDir, "note.txt"), "hi", "utf8"); + + await expect(resolvePackedRootDir(multipleDir)).rejects.toThrow(/unexpected archive layout/i); + await expect(resolvePackedRootDir(emptyDir)).rejects.toThrow(/unexpected archive layout/i); + }); + + it("returns work results and propagates errors before timeout", async () => { + await expect(withTimeout(Promise.resolve("ok"), 100, "extract zip")).resolves.toBe("ok"); + await expect( + withTimeout(Promise.reject(new Error("boom")), 100, "extract zip"), + ).rejects.toThrow("boom"); + }); + + it("rejects when archive work exceeds the timeout", async () => { + vi.useFakeTimers(); + const late = new Promise((resolve) => setTimeout(() => resolve("ok"), 50)); + const result = withTimeout(late, 1, "extract tar"); + const pending = expect(result).rejects.toThrow("extract tar timed out after 1ms"); + await vi.advanceTimersByTimeAsync(1); + await pending; + }); + + it("preflights tar entries for blocked link types, path escapes, and size budgets", () => { + const checker = createTarEntryPreflightChecker({ + rootDir: "/tmp/dest", + limits: { + maxEntries: 1, + maxEntryBytes: 8, + maxExtractedBytes: 12, + }, + }); + + expect(() => checker({ path: "package/link", type: "SymbolicLink", size: 0 })).toThrow( + "tar entry is a link: package/link", + ); + expect(() => checker({ path: "../escape.txt", type: "File", size: 1 })).toThrow( + /escapes destination|absolute/i, + ); + + checker({ path: "package/ok.txt", type: "File", size: 8 }); + expect(() => checker({ path: "package/second.txt", type: "File", size: 1 })).toThrow( + "archive entry count exceeds limit", + ); + }); + + it("treats stripped-away tar entries as no-ops and enforces extracted byte budgets", () => { + const checker = createTarEntryPreflightChecker({ + rootDir: "/tmp/dest", + stripComponents: 1, + limits: { + maxEntries: 4, + maxEntryBytes: 16, + maxExtractedBytes: 10, + }, + }); + + expect(() => checker({ path: "package", type: "Directory", size: 0 })).not.toThrow(); + checker({ path: "package/a.txt", type: "File", size: 6 }); + expect(() => checker({ path: "package/b.txt", type: "File", size: 6 })).toThrow( + "archive extracted size exceeds limit", + ); + }); + + it("reads JSON files and reports file existence", async () => { + const dir = await createTempDir(); + const jsonPath = path.join(dir, "data.json"); + const badPath = path.join(dir, "bad.json"); + await fs.writeFile(jsonPath, '{"ok":true}', "utf8"); + await fs.writeFile(badPath, "{not json", "utf8"); + + await expect(readJsonFile<{ ok: boolean }>(jsonPath)).resolves.toEqual({ ok: true }); + await expect(readJsonFile(badPath)).rejects.toThrow(); + await expect(fileExists(jsonPath)).resolves.toBe(true); + await expect(fileExists(path.join(dir, "missing.json"))).resolves.toBe(false); + }); +}); diff --git a/src/infra/archive.test.ts b/src/infra/archive.test.ts index 14c546e7674..d77b1e0bdb4 100644 --- a/src/infra/archive.test.ts +++ b/src/infra/archive.test.ts @@ -6,7 +6,7 @@ import * as tar from "tar"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { withRealpathSymlinkRebindRace } from "../test-utils/symlink-rebind-race.js"; import type { ArchiveSecurityError } from "./archive.js"; -import { extractArchive, resolveArchiveKind, resolvePackedRootDir } from "./archive.js"; +import { extractArchive, resolvePackedRootDir } from "./archive.js"; let fixtureRoot = ""; let fixtureCount = 0; @@ -82,19 +82,6 @@ afterAll(async () => { }); describe("archive utils", () => { - it("detects archive kinds", () => { - const cases = [ - { input: "/tmp/file.zip", expected: "zip" }, - { input: "/tmp/file.tgz", expected: "tar" }, - { input: "/tmp/file.tar.gz", expected: "tar" }, - { input: "/tmp/file.tar", expected: "tar" }, - { input: "/tmp/file.txt", expected: null }, - ] as const; - for (const testCase of cases) { - expect(resolveArchiveKind(testCase.input), testCase.input).toBe(testCase.expected); - } - }); - it.each([{ ext: "zip" as const }, { ext: "tar" as const }])( "extracts $ext archives", async ({ ext }) => { @@ -329,14 +316,6 @@ describe("archive utils", () => { }, ); - it("fails resolvePackedRootDir when extract dir has multiple root dirs", async () => { - const workDir = await makeTempDir("packed-root"); - const extractDir = path.join(workDir, "extract"); - await fs.mkdir(path.join(extractDir, "a"), { recursive: true }); - await fs.mkdir(path.join(extractDir, "b"), { recursive: true }); - await expect(resolvePackedRootDir(extractDir)).rejects.toThrow(/unexpected archive layout/i); - }); - it("rejects tar entries with absolute extraction paths", async () => { await withArchiveCase("tar", async ({ workDir, archivePath, extractDir }) => { const inputDir = path.join(workDir, "input");