* 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>
184 lines
6.9 KiB
TypeScript
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");
|
|
},
|
|
);
|
|
});
|