Files
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

323 lines
10 KiB
TypeScript

import {
access,
mkdir,
mkdtemp,
readFile,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
installMacHopperBundle,
systemMacHopperInstallHost,
type MacHopperBundleInstallOperations,
} from "../../../src/application/MacHopper.js";
import { SetupCleanupOwner } from "../../../src/application/SetupCleanupOwner.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
const temporaryRoot = async (): Promise<string> => {
const root = await mkdtemp(join(tmpdir(), "rea-hopper-owned-stage-test-"));
roots.push(root);
return root;
};
const bundleFiles = (
overrides: Partial<MacHopperBundleInstallOperations> = {},
captureRoot?: (root: string, mode: number) => void,
): MacHopperBundleInstallOperations => ({
async createStagingRoot(parent) {
await mkdir(parent, { recursive: true });
const root = await mkdtemp(join(parent, ".rea-hopper-stage-"));
captureRoot?.(root, (await stat(root)).mode & 0o777);
return root;
},
async copyBundle(source, stage) {
const executable = join(stage, "Contents/MacOS/hopper");
await mkdir(join(stage, "Contents/MacOS"), { recursive: true });
await writeFile(executable, source);
},
requireLauncher: async (path) => access(path).then(() => undefined),
async pathExists(path) {
try {
await access(path);
return true;
} catch (cause: unknown) {
if (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT"
)
return false;
throw cause;
}
},
rename,
removeStagingRoot: (path) => rm(path, { recursive: true }),
...overrides,
});
const writeBundle = async (path: string, content: string): Promise<void> => {
await mkdir(join(path, "Contents/MacOS"), { recursive: true });
await writeFile(join(path, "Contents/MacOS/hopper"), content);
};
describe("macOS Hopper staging isolation", () => {
it("rejects a staged bundle without its launcher before moving the original", async () => {
const root = await temporaryRoot();
const destination = join(root, "Applications/Hopper Disassembler.app");
await writeBundle(destination, "original installation");
let stagingRoot = "";
const result = await installMacHopperBundle(
"replacement without launcher",
destination,
true,
bundleFiles(
{
async copyBundle(_source, stage) {
await mkdir(join(stage, "Contents/MacOS"), { recursive: true });
},
},
(path) => (stagingRoot = path),
),
() => undefined,
);
expect(result).toMatchObject({
status: "failed",
reason: "launcher_missing",
diagnostic: expect.stringContaining("hopper"),
});
expect(
await readFile(join(destination, "Contents/MacOS/hopper"), "utf8"),
).toBe("original installation");
await expect(
access(join(stagingRoot, "previous.app")),
).rejects.toMatchObject({
code: "ENOENT",
});
await expect(access(stagingRoot)).rejects.toMatchObject({ code: "ENOENT" });
});
it("leaves pre-existing staging-looking sibling directories untouched", async () => {
const root = await temporaryRoot();
const applications = join(root, "Applications");
const destination = join(applications, "Hopper Disassembler.app");
const oldStage = `${destination}.rea-stage`;
const oldPrevious = `${oldStage}-previous`;
await Promise.all([
writeBundle(oldStage, "user stage sentinel"),
writeBundle(oldPrevious, "user backup sentinel"),
]);
let stagingRoot = "";
let stagingMode = 0;
await expect(
installMacHopperBundle(
"new bundle",
destination,
false,
bundleFiles({}, (path, mode) => {
stagingRoot = path;
stagingMode = mode;
}),
() => undefined,
),
).resolves.toEqual({ status: "installed" });
expect(
await readFile(join(oldStage, "Contents/MacOS/hopper"), "utf8"),
).toBe("user stage sentinel");
expect(
await readFile(join(oldPrevious, "Contents/MacOS/hopper"), "utf8"),
).toBe("user backup sentinel");
expect(
await readFile(join(destination, "Contents/MacOS/hopper"), "utf8"),
).toBe("new bundle");
expect(stagingMode).toBe(0o700);
await expect(access(stagingRoot)).rejects.toMatchObject({ code: "ENOENT" });
});
});
describe("macOS Hopper replacement ownership", () => {
it("keeps the committed replacement when owned-root cleanup fails", async () => {
const root = await temporaryRoot();
const destination = join(root, "Applications/Hopper Disassembler.app");
await writeBundle(destination, "previous installation");
let stagingRoot = "";
let failedCleanup = false;
const retained: (() => Promise<void>)[] = [];
const result = await installMacHopperBundle(
"new installation",
destination,
true,
bundleFiles(
{
removeStagingRoot: async (path) => {
if (!failedCleanup) {
failedCleanup = true;
throw new Error("simulated cleanup denial");
}
await rm(path, { recursive: true });
},
},
(path) => (stagingRoot = path),
),
(cleanup, resources) => {
expect(resources).toEqual([stagingRoot]);
retained.push(cleanup);
},
);
expect(result).toMatchObject({
status: "installed",
cleanupFailure: expect.stringContaining("simulated cleanup denial"),
});
expect(
await readFile(join(destination, "Contents/MacOS/hopper"), "utf8"),
).toBe("new installation");
expect(
await readFile(
join(stagingRoot, "previous.app/Contents/MacOS/hopper"),
"utf8",
),
).toBe("previous installation");
expect(retained).toHaveLength(1);
await retained[0]!();
expect(
await readFile(join(destination, "Contents/MacOS/hopper"), "utf8"),
).toBe("new installation");
await expect(access(stagingRoot)).rejects.toMatchObject({ code: "ENOENT" });
});
it("retains a moved backup and never overwrites a later destination on rollback", async () => {
const root = await temporaryRoot();
const destination = join(root, "Applications/Hopper Disassembler.app");
await writeBundle(destination, "original installation");
let stagingRoot = "";
let backupMoved = false;
let concurrentInstallCreated = false;
const rollbackRetained: (() => Promise<void>)[] = [];
const realRename = rename;
const result = await installMacHopperBundle(
"replacement installation",
destination,
true,
bundleFiles(
{
rename: async (source, target) => {
if (source === destination && target.endsWith("/previous.app")) {
await realRename(source, target);
backupMoved = true;
return;
}
if (source.endsWith("/new.app") && target === destination) {
await writeBundle(destination, "later installation");
concurrentInstallCreated = true;
throw new Error("simulated competing install");
}
await realRename(source, target);
},
},
(path) => (stagingRoot = path),
),
(cleanup, resources) => {
expect(resources).toEqual([stagingRoot]);
rollbackRetained.push(cleanup);
},
);
expect(result).toMatchObject({
status: "failed",
diagnostic: expect.stringContaining("simulated competing install"),
cleanupFailure: expect.stringContaining(
"previous Hopper bundle remains owned",
),
});
expect(backupMoved).toBe(true);
expect(concurrentInstallCreated).toBe(true);
expect(
await readFile(join(destination, "Contents/MacOS/hopper"), "utf8"),
).toBe("later installation");
expect(
await readFile(
join(stagingRoot, "previous.app/Contents/MacOS/hopper"),
"utf8",
),
).toBe("original installation");
expect(rollbackRetained).toHaveLength(1);
await expect(rollbackRetained[0]!()).rejects.toThrow(
"destination is occupied",
);
expect(
await readFile(
join(stagingRoot, "previous.app/Contents/MacOS/hopper"),
"utf8",
),
).toBe("original installation");
await rm(destination, { recursive: true });
await rollbackRetained[0]!();
expect(
await readFile(join(destination, "Contents/MacOS/hopper"), "utf8"),
).toBe("original installation");
await expect(access(stagingRoot)).rejects.toMatchObject({ code: "ENOENT" });
});
});
it("reports only retained Mac cleanup roots and clears the live report after exact retries", async () => {
const home = await temporaryRoot();
const temporary = join(home, "owned-dmg");
const stage = join(home, "owned-stage");
await Promise.all([mkdir(temporary), mkdir(stage)]);
const host = systemMacHopperInstallHost(home, { HOME: home }, () =>
Promise.resolve("14.0"),
);
let allowTemporary = false;
let allowStage = false;
let temporaryCalls = 0;
host.retainCleanup?.(async () => {
temporaryCalls++;
if (!allowTemporary) throw new Error("DMG root busy");
await rm(temporary, { recursive: true });
}, [temporary]);
host.retainCleanup?.(async () => {
if (!allowStage) throw new Error("staging root busy");
await rm(stage, { recursive: true });
}, [stage]);
const cleanup = new SetupCleanupOwner([{ close: () => host.close() }]);
const failure = await cleanup.close();
expect(failure).toBeDefined();
expect(projectAnalysisError(failure!).details?.resources).toEqual([
temporary,
stage,
]);
allowTemporary = true;
expect((await cleanup.close())?.cleanupResources).toEqual([stage]);
expect(failure?.cleanupResources).toEqual([stage]);
await expect(access(temporary)).rejects.toMatchObject({ code: "ENOENT" });
allowStage = true;
expect(await cleanup.close()).toBeUndefined();
expect(failure?.cleanupIncomplete).toBe(false);
expect(temporaryCalls).toBe(2);
await expect(access(stage)).rejects.toMatchObject({ code: "ENOENT" });
});