Files
rea/tests/boundary/setup/macosHopperPublisherVerification.test.ts
Kaoru0822andClaude Opus 5.5 365c3245e8 fix(setup): refuse Hopper on macOS below the release's minimum (#8548)
* fix(setup): refuse Hopper on macOS below the release's minimum

Hopper setup planning accepted any macOS 12+ host as supported, and the
installer never compared the host with the selected release. The current
publisher-signed Hopper 6.6.1-demo requires macOS 12.4 (feed
deployment_target and the app's LSMinimumSystemVersion), so setup on
12.0-12.3 installed an app the host cannot run.

The installer now takes the minimum from the release that owns it: the
feed's deployment_target before the package download, and the signed
app's LSMinimumSystemVersion (decoded in-process after signature
verification) before copying. A host below either fails with the new
incompatible_host_version code and an installer detail naming both
versions; an incomparable app value is a bundle failure. Planning keeps
REA's own macOS 12 floor but reports support as unknown, since the
release minimum is read only after approval, and the plan discloses the
check. Info.plist decoding is shared with app-bundle resolution.

Fixes #8502

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(setup): compare the macOS Hopper plan detail with its owning disclosure

The planning test froze an explanatory sentence from the setup plan. The
macOS install detail now lives in macHopperInstallDisclosure beside the
commands and origins the installer owns, the plan uses it, and the test
compares the plan with that constant while keeping its independent
support-state assertions. An installer test also pins that the signed
app's requirement is read only after both signature checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(setup): compare macOS Hopper refusal details with their owners

The installer's refusal details were template literals inside
installMacHopper, so tests copied whole sentences. The installer now
formats them through exported macHopperHostVersionDetail and
macHopperIncomparableMinimumDetail. Tests compare each diagnostic and the
setup remediation with those owners and SetupInstallFailure's recovery,
and separately assert the URL, versions, setting name, failure code and
request order. The Info.plist type refusal is checked by error type, key
and path instead of a sentence fragment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 00:05:30 -08:00

184 lines
6.9 KiB
TypeScript

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 =>
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>${identifier}</string>
<key>CFBundleExecutable</key>
<string>hopper</string>
<key>CFBundlePackageType</key>
<string>APPL</string>${entries}
</dict>
</plist>
`;
/** 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<string> => {
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 <key>LSMinimumSystemVersion</key>\n ${value}`;
/** An unsigned bundle carrying only an Info.plist, for requirement reads. */
const infoPlistBundle = async (entries: string): Promise<string> => {
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("<string>12.4</string>"), 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("<string>13.0.1</string>"));
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("<real>12.4</real>"));
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}"' <mounted-hopper.app>`,
);
});
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");
},
);
});