From 306d531f6f97d4954dca6c2a976d50e83b07edbe Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:38:24 +0800 Subject: [PATCH] fix(packaging): distinguish tar line endings from filename bytes Parse one listing separator so CRLF output matches packaged bridge paths while literal CR bytes in LF listings remain part of filenames. Retain generated-bytecode rejection. Refs #4154 Co-authored-by: abdooo2235 Co-authored-by: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> --- scripts/verify-package-pack.mjs | 5 +- .../process/packageTarListing.test.ts | 99 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 tests/boundary/process/packageTarListing.test.ts 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); +});