diff --git a/scripts/verify-package-pack.mjs b/scripts/verify-package-pack.mjs index 2ec1b94ee4..891d3c6be0 100644 --- a/scripts/verify-package-pack.mjs +++ b/scripts/verify-package-pack.mjs @@ -17,7 +17,10 @@ export async function verifyPackagePack({ root, workspace }) { }) ).stdout.trim(), ); - const packedFiles = (await exec("tar", ["-tf", tarball])).stdout.split("\n"); + const listing = (await exec("tar", ["-tf", tarball])).stdout; + // A bare LF identifies LF output; preceding CRs then belong to filenames. + const lineEnding = /(^|[^\r])\n/u.test(listing) ? "\n" : "\r\n"; + const packedFiles = listing.split(lineEnding); const packedManifest = JSON.parse( (await exec("tar", ["-xOf", tarball, "package/package.json"])).stdout, ); diff --git a/tests/boundary/process/packageTarListing.test.ts b/tests/boundary/process/packageTarListing.test.ts new file mode 100644 index 0000000000..58e48cbd1c --- /dev/null +++ b/tests/boundary/process/packageTarListing.test.ts @@ -0,0 +1,99 @@ +import { execFile } from "node:child_process"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { pathToFileURL } from "node:url"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const execFileAsync = promisify(execFile); +const packerUrl = pathToFileURL( + resolve("scripts/verify-package-pack.mjs"), +).href; +const catalogUrl = pathToFileURL(resolve("dist/catalogIdentity.js")).href; +const bridge = "package/bridge/hopper_bridge.py"; +const members = [ + bridge, + "package/bridge/cutter/rea_bridge.py", + "package/bridge/ghidra/ReaGhidraBridge.java", + "package/bridge/ghidra/ReaGhidraBundleActivator.java", + "package/bridge/ghidra/ReaGhidraExtensions.java", + "package/bridge/ghidra/ReaGhidraPrepareCom.java", + "package/bridge/ghidra/ReaGhidraPrepareRaw.java", + "package/bridge/ghidra/ReaGhidraApplySeeds.java", + "package/bridge/ghidra/ReaGhidraAnalysisOptions.java", + "package/bridge/ghidra/ReaGhidraNoReturnFix.java", + "package/bridge/ghidra/ReaGhidraMipsSoftFloat.java", +]; + +async function verifyListing(listing: string) { + const workspace = await createTestTempDirectory("rea-tar-listing-"); + const { stdout } = await execFileAsync(process.execPath, [ + "--input-type=module", + "--eval", + `import childProcess from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; +import { promisify } from "node:util"; +import { CATALOG_IDENTITY } from ${JSON.stringify(catalogUrl)}; +let bridgeParsed = false; +childProcess.execFile = () => { throw new Error("Expected promisified execution"); }; +childProcess.execFile[promisify.custom] = async (command, args) => { + let stdout; + if ((command === "npm" && args[0] === "pack") || (command === process.execPath && args[1] === "pack")) stdout = "fixture.tgz\\n"; + else if (command === "tar" && args[0] === "-tf") stdout = ${JSON.stringify(listing)}; + else if (command === "tar" && args[0] === "-xOf" && args[2] === "package/package.json") + stdout = JSON.stringify({ bin: { rea: "scripts/rea.mjs", "rea-agents": "scripts/rea.mjs" }, mcpName: "io.github.morluto/rea", dependencies: { "@lydell/node-pty": "1.1.0" } }); + else if (command === "tar" && args[0] === "-xOf" && args[2] === "package/skills/reverse-engineer-anything/SKILL.md") + stdout = " tool_count: " + CATALOG_IDENTITY.counts.mcp_tools + "\\n"; + else if (command === "tar" && args[0] === "-xf") stdout = ""; + else if (command === "python3") { bridgeParsed = true; stdout = ""; } + else throw new Error("Unexpected command: " + command + " " + JSON.stringify(args)); + return { stdout, stderr: "" }; +}; +syncBuiltinESMExports(); +const { verifyPackagePack } = await import(${JSON.stringify(packerUrl)}); +try { + const result = await verifyPackagePack({ root: process.cwd(), workspace: ${JSON.stringify(workspace)} }); + process.stdout.write(JSON.stringify({ ...result, bridgeParsed })); +} catch (cause) { + process.stdout.write(JSON.stringify({ error: cause.message, bridgeParsed })); +}`, + ]); + return { result: JSON.parse(stdout), workspace }; +} + +it("preserves a literal trailing CR in an LF-listed member", async () => { + const listing = + members.map((path) => (path === bridge ? `${path}\r` : path)).join("\n") + + "\n"; + const { result } = await verifyListing(listing); + expect(result).toEqual({ + error: `package omitted ${bridge}`, + bridgeParsed: false, + }); +}); + +it("accepts exact bridge entries from a CRLF tar listing and still rejects generated bytecode", async () => { + const ending = "\r\n"; + const { result, workspace } = await verifyListing( + members.join(ending) + ending, + ); + expect(result).toEqual({ + tarball: join(workspace, "pack/fixture.tgz"), + bridgeParsed: true, + }); + + const rejected = await verifyListing( + [...members, "package/bridge/stale.pyc"].join(ending) + ending, + ); + expect(rejected.result).toEqual({ + error: "package contained generated Python bytecode", + bridgeParsed: false, + }); +}); + +it("accepts exact bridge entries from an LF tar listing", async () => { + const { result } = await verifyListing(members.join("\n") + "\n"); + expect(result.bridgeParsed).toBe(true); +});