mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 15:26:25 +00:00
* fix(setup): replace the fixed Hopper download deadline with a stall timeout Hopper package downloads aborted after a fixed 120 s, which was also the largest deadline the download owner accepted. The current 32 MB Linux package needs about 132 s at the reporter's sustained 245 KB/s, so setup could never finish on that connection, and the timeout surfaced only as Node's generic abort message. Abort a download when no response bytes arrive for 30 s, or when it outlives a 15-minute overall bound, with a tagged HopperDownloadTimeoutError that names the URL, the limit reached and the bytes received. Linux and macOS setup already relay a thrown download error's message; the Linux publisher now keeps it for release metadata and public key fetches instead of a generic verification failure. Metadata and key fetches keep their 10 s bound. Refs #7077 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(setup): keep the macOS release feed on the metadata limit The stall-timeout change left the macOS release feed fetch on the package download bounds. That fetch could then wait up to 15 minutes instead of the 120 s it had, while the installation guide says release metadata keeps a 10 s limit. Only the Linux publisher owned that limit. Move the 10 s metadata limit into HopperDownload.ts beside the package bounds. The Linux feed and key fetches use it, and the macOS installer now passes it for its feed fetch through the installer host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
574 lines
18 KiB
TypeScript
574 lines
18 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { err, ok } from "../domain/result.js";
|
|
import {
|
|
HOPPER_METADATA_TIMEOUT_MS,
|
|
type HopperDownload,
|
|
type HopperDownloadFailure,
|
|
} from "./HopperDownload.js";
|
|
import {
|
|
installMacHopper,
|
|
macHopperHostVersionDetail,
|
|
macHopperIncomparableMinimumDetail,
|
|
type MacHopperBundleVerification,
|
|
type MacHopperBundleInstallResult,
|
|
type MacHopperInstallHost,
|
|
} from "./MacHopper.js";
|
|
|
|
class FakeMacHopperHost implements MacHopperInstallHost {
|
|
readonly archive = new TextEncoder().encode("hopper-dmg");
|
|
feedFailure: HopperDownloadFailure | undefined;
|
|
packageFailure: HopperDownloadFailure | undefined;
|
|
feedBytes: Uint8Array | undefined;
|
|
packageDownloads = 0;
|
|
mounts = 0;
|
|
verification: MacHopperBundleVerification = "verified";
|
|
existing = false;
|
|
mountSucceeds = true;
|
|
copySucceeds = true;
|
|
mountFailure: Error | undefined;
|
|
verificationFailure: Error | undefined;
|
|
badHash = false;
|
|
unmounts = 0;
|
|
cleanups = 0;
|
|
installs = 0;
|
|
opens = 0;
|
|
replaces = false;
|
|
downloads: Array<{ url: string; timeoutMs: number | undefined }> = [];
|
|
|
|
download = (
|
|
url: string,
|
|
options?: { readonly timeoutMs?: number },
|
|
): Promise<HopperDownload> => {
|
|
this.downloads.push({ url, timeoutMs: options?.timeoutMs });
|
|
const feed = url.includes("files-api");
|
|
if (!feed) this.packageDownloads += 1;
|
|
const failure = feed ? this.feedFailure : this.packageFailure;
|
|
if (failure !== undefined) return Promise.resolve(err(failure));
|
|
return Promise.resolve(
|
|
ok(
|
|
feed
|
|
? (this.feedBytes ??
|
|
new TextEncoder().encode(
|
|
JSON.stringify({
|
|
"OS X": {
|
|
filename:
|
|
"https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg",
|
|
file_length: String(this.archive.byteLength),
|
|
file_hash: this.badHash
|
|
? "0".repeat(40)
|
|
: createHash("sha1").update(this.archive).digest("hex"),
|
|
},
|
|
}),
|
|
))
|
|
: this.archive,
|
|
),
|
|
);
|
|
};
|
|
createTemporaryDirectory = () => Promise.resolve("/tmp/rea-hopper-test");
|
|
createMountDirectory = () => Promise.resolve();
|
|
writePackage = () => Promise.resolve();
|
|
mount = () => {
|
|
this.mounts += 1;
|
|
return this.mountFailure === undefined
|
|
? Promise.resolve(this.mountSucceeds)
|
|
: Promise.reject(this.mountFailure);
|
|
};
|
|
unmount = () => {
|
|
this.unmounts += 1;
|
|
return Promise.resolve();
|
|
};
|
|
appBundle = () => Promise.resolve("/mounted/Hopper Disassembler.app");
|
|
validBundle = () =>
|
|
this.verificationFailure === undefined
|
|
? Promise.resolve(this.verification)
|
|
: Promise.reject(this.verificationFailure);
|
|
hostVersion: string | undefined = "14.5";
|
|
appMinimum: string | undefined = "12.4";
|
|
appMinimumFailure: Error | undefined;
|
|
appMinimumReads = 0;
|
|
macosVersion = () => Promise.resolve(this.hostVersion);
|
|
minimumSystemVersion = () => {
|
|
this.appMinimumReads += 1;
|
|
return this.appMinimumFailure === undefined
|
|
? Promise.resolve(this.appMinimum)
|
|
: Promise.reject(this.appMinimumFailure);
|
|
};
|
|
destinationExists = () => Promise.resolve(this.existing);
|
|
installBundle = (
|
|
_source: string,
|
|
_destination: string,
|
|
replaceExisting: boolean,
|
|
) => {
|
|
this.installs += 1;
|
|
this.replaces = replaceExisting;
|
|
return Promise.resolve<MacHopperBundleInstallResult>(
|
|
this.copySucceeds
|
|
? { status: "installed" }
|
|
: {
|
|
status: "failed",
|
|
reason: "copy",
|
|
diagnostic: "ditto: destination is read-only",
|
|
},
|
|
);
|
|
};
|
|
openApplication = () => {
|
|
this.opens += 1;
|
|
return Promise.resolve();
|
|
};
|
|
cleanup = () => {
|
|
this.cleanups += 1;
|
|
return Promise.resolve();
|
|
};
|
|
destination = () => "/home/user/Applications/Hopper Disassembler.app";
|
|
}
|
|
|
|
const feedText = async (host: FakeMacHopperHost): Promise<string> => {
|
|
const feed = await host.download("files-api");
|
|
if (!feed.ok) throw new Error("Expected the fixture feed");
|
|
return new TextDecoder().decode(feed.value);
|
|
};
|
|
|
|
describe("macOS Hopper installation", () => {
|
|
it("installs, verifies, opens, and cleans the official package", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
await expect(installMacHopper({}, host)).resolves.toEqual({
|
|
status: "installed",
|
|
launcherPath:
|
|
"/home/user/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
|
|
});
|
|
expect(host.opens).toBe(1);
|
|
expect(host.unmounts).toBe(1);
|
|
expect(host.cleanups).toBe(1);
|
|
});
|
|
|
|
it("bounds the release feed by the metadata limit and the package by the download bounds", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
await installMacHopper({}, host);
|
|
expect(host.downloads).toEqual([
|
|
{
|
|
url: expect.stringContaining("request=releases"),
|
|
timeoutMs: HOPPER_METADATA_TIMEOUT_MS,
|
|
},
|
|
{
|
|
url: "https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg",
|
|
timeoutMs: undefined,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("preserves installation when macOS cannot open the installed app", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.openApplication = () =>
|
|
Promise.reject(new Error("LaunchServices refused"));
|
|
|
|
await expect(installMacHopper({}, host)).resolves.toEqual({
|
|
status: "installed",
|
|
launcherPath:
|
|
"/home/user/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
|
|
openFailure: "LaunchServices refused",
|
|
});
|
|
expect(host.installs).toBe(1);
|
|
expect(host.unmounts).toBe(1);
|
|
expect(host.cleanups).toBe(1);
|
|
});
|
|
|
|
it("rejects an integrity mismatch before mounting", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.badHash = true;
|
|
await expect(installMacHopper({}, host)).resolves.toEqual({
|
|
status: "failed",
|
|
reason: "integrity",
|
|
});
|
|
expect(host.unmounts).toBe(0);
|
|
expect(host.cleanups).toBe(0);
|
|
});
|
|
|
|
it("refuses to overwrite an existing application", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.existing = true;
|
|
await expect(installMacHopper({}, host)).resolves.toEqual({
|
|
status: "failed",
|
|
reason: "destination_exists",
|
|
});
|
|
});
|
|
|
|
it("replaces the managed application when explicitly requested", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.existing = true;
|
|
|
|
await expect(
|
|
installMacHopper({ replaceExisting: true }, host),
|
|
).resolves.toMatchObject({ status: "installed" });
|
|
expect(host.replaces).toBe(true);
|
|
});
|
|
|
|
it("cleans and detaches after bundle validation fails", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.verification = "invalid_signature";
|
|
await expect(installMacHopper({}, host)).resolves.toEqual({
|
|
status: "failed",
|
|
reason: "bundle",
|
|
});
|
|
expect(host.unmounts).toBe(1);
|
|
expect(host.cleanups).toBe(1);
|
|
});
|
|
|
|
it("preserves a thrown mount-stage diagnostic and cleans the temporary image", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.mountFailure = new Error("hdiutil attach: resource busy");
|
|
|
|
await expect(installMacHopper({}, host)).resolves.toMatchObject({
|
|
status: "failed",
|
|
reason: "mount",
|
|
diagnostic: "hdiutil attach: resource busy",
|
|
});
|
|
expect(host.unmounts).toBe(0);
|
|
expect(host.cleanups).toBe(1);
|
|
});
|
|
|
|
it("preserves the copy diagnostic from the owned install operation", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.copySucceeds = false;
|
|
|
|
await expect(installMacHopper({}, host)).resolves.toMatchObject({
|
|
status: "failed",
|
|
reason: "copy",
|
|
diagnostic: "ditto: destination is read-only",
|
|
});
|
|
expect(host.installs).toBe(1);
|
|
expect(host.unmounts).toBe(1);
|
|
expect(host.cleanups).toBe(1);
|
|
});
|
|
|
|
it("reports a publisher mismatch without copying or launching the bundle", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.verification = "unverified_publisher";
|
|
await expect(installMacHopper({}, host)).resolves.toEqual({
|
|
status: "failed",
|
|
reason: "publisher_verification_failed",
|
|
});
|
|
expect(host.installs).toBe(0);
|
|
expect(host.opens).toBe(0);
|
|
expect(host.unmounts).toBe(1);
|
|
expect(host.cleanups).toBe(1);
|
|
});
|
|
|
|
it("preserves a thrown signature-check diagnostic as a bundle failure", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.verificationFailure = new Error("codesign: helper unavailable");
|
|
|
|
await expect(installMacHopper({}, host)).resolves.toMatchObject({
|
|
status: "failed",
|
|
reason: "bundle",
|
|
diagnostic: "codesign: helper unavailable",
|
|
});
|
|
expect(host.installs).toBe(0);
|
|
expect(host.unmounts).toBe(1);
|
|
expect(host.cleanups).toBe(1);
|
|
});
|
|
});
|
|
|
|
const PACKAGE_URL =
|
|
"https://www.hopperapp.com:443/downloader/public/Hopper-6.6.1-demo.dmg";
|
|
|
|
/** The refusal detail carries the observed facts, independent of its wording. */
|
|
const expectNamed = (
|
|
result: Awaited<ReturnType<typeof installMacHopper>>,
|
|
facts: readonly string[],
|
|
): void => {
|
|
const diagnostic = result.status === "failed" ? result.diagnostic : undefined;
|
|
for (const fact of facts) expect(diagnostic).toContain(fact);
|
|
};
|
|
|
|
/** The fixture's macOS row with vendor fields such as `deployment_target`. */
|
|
const macFeed = (
|
|
host: FakeMacHopperHost,
|
|
fields: Readonly<Record<string, unknown>>,
|
|
): Uint8Array =>
|
|
new TextEncoder().encode(
|
|
JSON.stringify({
|
|
"OS X": {
|
|
filename: PACKAGE_URL,
|
|
file_length: String(host.archive.byteLength),
|
|
file_hash: createHash("sha1").update(host.archive).digest("hex"),
|
|
version: "6.6.1",
|
|
...fields,
|
|
},
|
|
}),
|
|
);
|
|
|
|
describe("macOS Hopper release host requirements", () => {
|
|
it.each(["12", "12.0", "12.3", "12.3.9"])(
|
|
"refuses macOS %s below the release deployment target before downloading the package",
|
|
async (hostVersion) => {
|
|
const host = new FakeMacHopperHost();
|
|
host.hostVersion = hostVersion;
|
|
host.feedBytes = macFeed(host, { deployment_target: "12.4" });
|
|
|
|
const result = await installMacHopper({}, host);
|
|
expect(result).toEqual({
|
|
status: "failed",
|
|
reason: "incompatible_host_version",
|
|
diagnostic: macHopperHostVersionDetail({
|
|
source: "release_feed",
|
|
packageUrl: PACKAGE_URL,
|
|
minimumVersion: "12.4",
|
|
hostVersion,
|
|
}),
|
|
});
|
|
expectNamed(result, [PACKAGE_URL, "12.4", hostVersion]);
|
|
expect(host.packageDownloads).toBe(0);
|
|
expect(host.mounts).toBe(0);
|
|
expect(host.installs).toBe(0);
|
|
},
|
|
);
|
|
|
|
it.each(["12.4", "12.4.0", "12.4.1", "12.10", "13", "26.1"])(
|
|
"installs on macOS %s at or above both declared minimums",
|
|
async (hostVersion) => {
|
|
const host = new FakeMacHopperHost();
|
|
host.hostVersion = hostVersion;
|
|
host.feedBytes = macFeed(host, { deployment_target: "12.4" });
|
|
|
|
expect(await installMacHopper({}, host)).toMatchObject({
|
|
status: "installed",
|
|
});
|
|
expect(host.installs).toBe(1);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ label: "an undeclared", fields: {} },
|
|
{ label: "an unrecognized", fields: { deployment_target: "twelve" } },
|
|
{ label: "a non-text", fields: { deployment_target: 12.4 } },
|
|
{ label: "a lower", fields: { deployment_target: "11.0" } },
|
|
])(
|
|
"lets the signed app refuse the host after $label feed deployment target",
|
|
async ({ fields }) => {
|
|
const host = new FakeMacHopperHost();
|
|
host.hostVersion = "12.0";
|
|
host.feedBytes = macFeed(host, fields);
|
|
|
|
const result = await installMacHopper({}, host);
|
|
expect(result).toEqual({
|
|
status: "failed",
|
|
reason: "incompatible_host_version",
|
|
diagnostic: macHopperHostVersionDetail({
|
|
source: "signed_app",
|
|
minimumVersion: "12.4",
|
|
hostVersion: "12.0",
|
|
}),
|
|
});
|
|
expectNamed(result, ["LSMinimumSystemVersion", "12.4", "12.0"]);
|
|
expect(host.packageDownloads).toBe(1);
|
|
expect(host.installs).toBe(0);
|
|
expect(host.opens).toBe(0);
|
|
expect(host.unmounts).toBe(1);
|
|
expect(host.cleanups).toBe(1);
|
|
},
|
|
);
|
|
|
|
it.each(["invalid_signature", "unverified_publisher"] as const)(
|
|
"reads the app's requirement only after its signature checks pass: %s",
|
|
async (verification) => {
|
|
const host = new FakeMacHopperHost();
|
|
host.verification = verification;
|
|
|
|
expect(await installMacHopper({}, host)).toMatchObject({
|
|
status: "failed",
|
|
});
|
|
expect(host.appMinimumReads).toBe(0);
|
|
expect(host.installs).toBe(0);
|
|
},
|
|
);
|
|
|
|
it("installs when neither the feed nor the signed app declares a minimum", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.hostVersion = "12.0";
|
|
host.appMinimum = undefined;
|
|
|
|
expect(await installMacHopper({}, host)).toMatchObject({
|
|
status: "installed",
|
|
});
|
|
});
|
|
|
|
it("reports an incomparable signed-app minimum as a bundle failure without copying", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.appMinimum = "12.x";
|
|
|
|
const result = await installMacHopper({}, host);
|
|
expect(result).toEqual({
|
|
status: "failed",
|
|
reason: "bundle",
|
|
diagnostic: macHopperIncomparableMinimumDetail("12.x", "14.5"),
|
|
});
|
|
expectNamed(result, ['"12.x"', "14.5"]);
|
|
expect(host.installs).toBe(0);
|
|
expect(host.unmounts).toBe(1);
|
|
expect(host.cleanups).toBe(1);
|
|
});
|
|
|
|
it("preserves an unreadable signed-app requirement as a bundle diagnostic", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.appMinimumFailure = new Error("Info.plist: permission denied");
|
|
|
|
expect(await installMacHopper({}, host)).toMatchObject({
|
|
status: "failed",
|
|
reason: "bundle",
|
|
diagnostic: "Info.plist: permission denied",
|
|
});
|
|
expect(host.installs).toBe(0);
|
|
expect(host.unmounts).toBe(1);
|
|
expect(host.cleanups).toBe(1);
|
|
});
|
|
|
|
it.each([undefined, "", "14beta", "14.0\n"])(
|
|
"refuses an incomparable host version %j before any vendor request",
|
|
async (hostVersion) => {
|
|
const host = new FakeMacHopperHost();
|
|
host.hostVersion = hostVersion;
|
|
const download = host.download;
|
|
let requests = 0;
|
|
host.download = (url) => {
|
|
requests += 1;
|
|
return download(url);
|
|
};
|
|
|
|
expect(await installMacHopper({}, host)).toEqual({
|
|
status: "failed",
|
|
reason: "unsupported_host",
|
|
});
|
|
expect(requests).toBe(0);
|
|
expect(host.mounts).toBe(0);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("reports an unresolved destination probe without claiming an existing installation", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.destinationExists = async () => {
|
|
throw Object.assign(new Error("destination inspection denied"), {
|
|
code: "EACCES",
|
|
});
|
|
};
|
|
expect(await installMacHopper({ replaceExisting: true }, host)).toMatchObject(
|
|
{
|
|
status: "failed",
|
|
reason: "destination_inspection",
|
|
diagnostic: "destination inspection denied",
|
|
},
|
|
);
|
|
expect(host.installs).toBe(0);
|
|
expect(host.unmounts).toBe(0);
|
|
});
|
|
|
|
it("retains the exact DMG temporary root with its retry capability", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
const retained: {
|
|
close: () => Promise<void>;
|
|
resources: readonly string[];
|
|
}[] = [];
|
|
let allowCleanup = false;
|
|
const result = await installMacHopper(
|
|
{},
|
|
{
|
|
...host,
|
|
cleanup: async () => {
|
|
if (!allowCleanup) throw new Error("DMG root busy");
|
|
},
|
|
retainCleanup: (close, resources) => {
|
|
retained.push({ close, resources });
|
|
},
|
|
},
|
|
);
|
|
expect(result).toMatchObject({
|
|
status: "installed",
|
|
cleanupFailure: expect.stringContaining("DMG root busy"),
|
|
});
|
|
expect(retained).toHaveLength(1);
|
|
expect(retained[0]?.resources).toEqual(["/tmp/rea-hopper-test"]);
|
|
allowCleanup = true;
|
|
await expect(retained[0]!.close()).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("refuses malformed UTF-8 release metadata before downloading or mounting the package", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
const text = await feedText(host);
|
|
// Replacement decoding would produce valid JSON and hide the malformed transport byte.
|
|
host.feedBytes = new Uint8Array([
|
|
...new TextEncoder().encode('{"vendor_note":"'),
|
|
0xff,
|
|
...new TextEncoder().encode('",' + text.slice(1)),
|
|
]);
|
|
expect(await installMacHopper({}, host)).toMatchObject({
|
|
status: "failed",
|
|
reason: "release_metadata",
|
|
});
|
|
expect(host.packageDownloads).toBe(0);
|
|
expect(host.mounts).toBe(0);
|
|
expect(host.installs).toBe(0);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"accepts a genuine replacement character in release metadata (BOM=%s)",
|
|
async (bom) => {
|
|
const host = new FakeMacHopperHost();
|
|
const text = (await feedText(host)).replace(
|
|
'{"OS X":{',
|
|
'{"OS X":{"vendor_note":"雪�",',
|
|
);
|
|
host.feedBytes = new TextEncoder().encode((bom ? "\uFEFF" : "") + text);
|
|
expect(await installMacHopper({}, host)).toMatchObject({
|
|
status: "installed",
|
|
});
|
|
expect(host.packageDownloads).toBe(1);
|
|
expect(host.mounts).toBe(1);
|
|
expect(host.installs).toBe(1);
|
|
},
|
|
);
|
|
|
|
it("refuses release URLs that normalize outside the public channel before package download", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
const row = JSON.parse(await feedText(host));
|
|
row["OS X"].filename =
|
|
"https://www.hopperapp.com:443/downloader/public/%2e%2e/private/Hopper-demo.dmg";
|
|
host.feedBytes = new TextEncoder().encode(JSON.stringify(row));
|
|
|
|
expect(await installMacHopper({}, host)).toMatchObject({
|
|
status: "failed",
|
|
reason: "release_metadata",
|
|
});
|
|
expect(host.packageDownloads).toBe(0);
|
|
expect(host.mounts).toBe(0);
|
|
});
|
|
|
|
it.each(["feed", "package"] as const)(
|
|
"attributes a failed %s request to its stage before any mount",
|
|
async (stage) => {
|
|
const host = new FakeMacHopperHost();
|
|
const failure = {
|
|
url: "https://fixture.invalid/request",
|
|
kind: "timeout",
|
|
limit: "stalled",
|
|
timeoutMs: 30_000,
|
|
receivedBytes: 0,
|
|
} as const;
|
|
if (stage === "feed") host.feedFailure = failure;
|
|
else host.packageFailure = failure;
|
|
expect(await installMacHopper({}, host)).toEqual({
|
|
status: "failed",
|
|
reason: stage === "feed" ? "release_metadata" : "download",
|
|
download: {
|
|
request: stage === "feed" ? "release_feed" : "package",
|
|
...failure,
|
|
},
|
|
});
|
|
expect(host.packageDownloads).toBe(stage === "feed" ? 0 : 1);
|
|
expect(host.mounts).toBe(0);
|
|
expect(host.cleanups).toBe(0);
|
|
},
|
|
);
|