import { execFile } from "node:child_process"; import { appendFile, copyFile, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { macHopperInstallDisclosure, systemMacHopperInstallHost, } from "../../../src/application/MacHopper.js"; import { verifyMacHopperNativeHelper } from "../../../src/hopper/HopperMacPublisher.js"; import { HOPPER_PUBLISHER } from "../../../src/domain/hopperPublisher.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const run = promisify(execFile); const itDarwin = it.skipIf(process.platform !== "darwin"); const executableSegments = ["Contents", "MacOS", "hopper"]; const infoPlist = (identifier: string, entries = ""): string => ` CFBundleIdentifier ${identifier} CFBundleExecutable hopper CFBundlePackageType APPL${entries} `; /** Build a minimal `.app` whose launcher is a real signed Mach-O copy. */ const buildBundle = async ( root: string, identifier: string, options: { readonly sign?: boolean } = {}, ): Promise => { const app = join(root, "Hopper Disassembler.app"); const executable = join(app, ...executableSegments); await mkdir(join(app, "Contents", "MacOS"), { recursive: true }); await copyFile("/usr/bin/true", executable); await writeFile(join(app, "Contents", "Info.plist"), infoPlist(identifier)); if (options.sign !== false) await run("/usr/bin/codesign", [ "--sign", "-", "--force", "--deep", "--identifier", identifier, app, ]); return app; }; const minimumEntry = (value: string): string => `\n LSMinimumSystemVersion\n ${value}`; /** An unsigned bundle carrying only an Info.plist, for requirement reads. */ const infoPlistBundle = async (entries: string): Promise => { const root = await createTestTempDirectory("rea-hopper-minimum-"); const app = join(root, "Hopper Disassembler.app"); await mkdir(join(app, "Contents"), { recursive: true }); await writeFile( join(app, "Contents", "Info.plist"), infoPlist(HOPPER_PUBLISHER.bundleIdentifier, entries), ); return app; }; const host = () => systemMacHopperInstallHost("/home/rea-test", { ...process.env }, () => Promise.resolve(undefined), ); describe("macOS Hopper publisher verification", () => { itDarwin( "rejects an ad-hoc signed bundle carrying the pinned bundle identifier", async () => { const root = await createTestTempDirectory("rea-hopper-publisher-"); const app = await buildBundle(root, HOPPER_PUBLISHER.bundleIdentifier); // The pre-pinned checks accept this bundle: `codesign --verify` succeeds // for ad-hoc signatures and the identifier clause alone also matches. await run("/usr/bin/codesign", ["--verify", "--deep", "--strict", app]); await expect(host().validBundle(app)).resolves.toBe( "unverified_publisher", ); }, ); itDarwin("rejects an unsigned bundle before any copy", async () => { const root = await createTestTempDirectory("rea-hopper-unsigned-"); const app = await buildBundle(root, HOPPER_PUBLISHER.bundleIdentifier, { sign: false, }); await expect(host().validBundle(app)).resolves.toBe("invalid_signature"); }); itDarwin("rejects a bundle modified after it was signed", async () => { const root = await createTestTempDirectory("rea-hopper-tampered-"); const app = await buildBundle(root, HOPPER_PUBLISHER.bundleIdentifier); await appendFile(join(app, ...executableSegments), "appended\n"); await expect(host().validBundle(app)).resolves.toBe("invalid_signature"); }); it.each([ { entries: minimumEntry("12.4"), declared: "12.4" }, { entries: "", declared: undefined }, ])( "reads the XML Info.plist minimum macOS version $declared", async ({ entries, declared }) => { const app = await infoPlistBundle(entries); await expect(host().minimumSystemVersion(app)).resolves.toBe(declared); }, ); itDarwin("reads a binary Info.plist minimum macOS version", async () => { const app = await infoPlistBundle(minimumEntry("13.0.1")); await run("/usr/bin/plutil", [ "-convert", "binary1", join(app, "Contents", "Info.plist"), ]); await expect(host().minimumSystemVersion(app)).resolves.toBe("13.0.1"); }); it("refuses a minimum macOS version that is not text", async () => { const app = await infoPlistBundle(minimumEntry("12.4")); const failure = host().minimumSystemVersion(app); await expect(failure).rejects.toBeInstanceOf(TypeError); // The refusal names the key and the exact plist it read. await expect(failure).rejects.toThrow("LSMinimumSystemVersion"); await expect(failure).rejects.toThrow(join(app, "Contents", "Info.plist")); }); it("discloses the exact code requirement the installer enforces", () => { expect(macHopperInstallDisclosure.commands).toContain( `codesign --verify --strict '-R= identifier "${HOPPER_PUBLISHER.bundleIdentifier}" and anchor apple generic and certificate leaf[subject.OU] = "${HOPPER_PUBLISHER.teamIdentifier}"' `, ); }); itDarwin( "withholds native-helper provenance from custom signed or modified executables", async () => { const root = await createTestTempDirectory( "rea-hopper-helper-publisher-", ); const app = await buildBundle(root, "hopper"); const executable = join(app, ...executableSegments); await expect( verifyMacHopperNativeHelper(executable, process.env), ).resolves.toBe("unknown"); await appendFile(executable, "modified\n"); await expect( verifyMacHopperNativeHelper(executable, process.env), ).resolves.toBe("unknown"); }, ); // Opt-in positive control: point this at a mounted official Hopper bundle, // for example `hdiutil attach -readonly -nobrowse Hopper-6.6.0-demo.dmg`. // It proves the pinned requirement is satisfiable by the real publisher, so // a quoting or argument mistake cannot hide behind the rejection arms. const realApp = process.env.REA_HOPPER_MACOS_APP; it.skipIf(realApp === undefined)( "accepts the officially signed Hopper application", async () => { await expect(host().validBundle(realApp ?? "")).resolves.toBe("verified"); await expect(host().minimumSystemVersion(realApp ?? "")).resolves.toMatch( /^\d+(?:\.\d+)*$/u, ); await expect( verifyMacHopperNativeHelper( join(realApp ?? "", ...executableSegments), process.env, ), ).resolves.toBe("verified"); }, ); });