fix(setup): preserve Linux Hopper HTTP failure status (#7183)

Preserve observed failed-response HTTP status through Linux Hopper setup diagnostics without changing download rejection, retry, or installation policy. Independently validated on latest main through red/green regressions, actual TLS sockets, focused checks and CI. Closes #7182.
This commit is contained in:
Wenyu Chiou authored and GitHub committed 2026-10-26 20:45:06 +08:00
1 parent 0211a07ec5
commit 1a91630b88
4 files changed
+171 -11

No files matched your search

+4
View File
@@ -660,6 +660,10 @@ an existing Hopper launcher is selected through `HOPPER_LAUNCHER_PATH`. Ghidra
and IDA have their own provider-specific host requirements; Windows Ghidra uses
the [experimental P0 boundary](windows-ghidra-p0.md).
If the Linux package download receives an HTTP failure, setup includes the
observed HTTP status in its `download_failed` diagnostic. The status does not
establish the underlying cause or guarantee that retrying will succeed.
Hopper metadata and package downloads require HTTPS throughout the redirect
chain; a redirect to HTTP is refused before that destination is requested.
+17 -5
View File
@@ -1,3 +1,10 @@
/** Download result; HTTP failures may carry their observed status for diagnostics. */
export interface HopperDownload {
readonly ok: boolean;
readonly bytes: Uint8Array;
readonly httpStatus?: number;
}
/** Maximum admitted vendor response bytes, shared by Hopper setup adapters. */
export const HOPPER_DOWNLOAD_BYTES = 100_000_000;
@@ -9,7 +16,7 @@ export const downloadHopperPackage = async (
readonly maxBytes?: number;
readonly timeoutMs?: number;
} = {},
): Promise<{ readonly ok: boolean; readonly bytes: Uint8Array }> => {
): Promise<HopperDownload> => {
const timeoutMs = options.timeoutMs ?? 120_000;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000)
throw new RangeError("Invalid Hopper response time budget");
@@ -29,10 +36,15 @@ export const downloadHopperPackage = async (
);
const refused = { ok: false, bytes: new Uint8Array() };
if (response === undefined) return refused;
if (
!response.ok ||
Number(response.headers.get("content-length")) > maxBytes
) {
if (!response.ok) {
await response.body?.cancel();
return Number.isInteger(response.status) &&
response.status >= 300 &&
response.status <= 599
? { ...refused, httpStatus: response.status }
: refused;
}
if (Number(response.headers.get("content-length")) > maxBytes) {
await response.body?.cancel();
return refused;
}
+18 -6
View File
@@ -7,7 +7,10 @@ import { join, posix } from "node:path";
import { execFileBoundedOutput } from "../process/ExecFileBoundedOutput.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { downloadHopperPackage } from "./HopperDownload.js";
import {
downloadHopperPackage,
type HopperDownload,
} from "./HopperDownload.js";
import {
inspectLinuxHopperPythonRuntime,
linuxHopperPythonUnavailable,
@@ -117,10 +120,7 @@ export const supportsReaHost = (
): boolean => distribution?.packageFamily !== undefined;
/** Download response kept independent of Node's global fetch implementation. */
export interface LinuxHopperDownload {
readonly ok: boolean;
readonly bytes: Uint8Array;
}
export type LinuxHopperDownload = HopperDownload;
/** One failed package-manager command, with the output that explains it. */
export interface LinuxPackageInstallFailure {
@@ -401,7 +401,19 @@ export const installLinuxHopper = async (
const release = selected.value;
stage = "download";
const archiveDownload = await host.download(release.filename, options);
if (!archiveDownload.ok) return { status: "failed", reason: "download" };
if (!archiveDownload.ok)
return {
status: "failed",
reason: "download",
...(archiveDownload.httpStatus === undefined
? {}
: {
operationFailure: {
operation: "download",
detail: `Package download returned HTTP ${archiveDownload.httpStatus}.`,
},
}),
};
stage = "archive_write";
temporary = await host.createTemporaryDirectory();
// Linux package paths stay POSIX even when this host is exercised from
@@ -0,0 +1,132 @@
import { afterEach, expect, it, vi } from "vitest";
import { createDoctorHostFixture } from "../../src/application/Doctor.fixture.js";
import { downloadHopperPackage } from "../../src/application/HopperDownload.js";
import {
systemLinuxHopperInstallHost,
type LinuxHopperInstallHost,
} from "../../src/application/LinuxHopper.js";
import { systemSetupHost } from "../../src/application/SetupHost.js";
import { ok } from "../../src/domain/result.js";
vi.mock("../../src/application/LinuxHopper.js", async (importOriginal) => ({
...(await importOriginal<
typeof import("../../src/application/LinuxHopper.js")
>()),
systemLinuxHopperInstallHost: vi.fn(),
}));
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const url =
"https://www.hopperapp.com/downloader/public/Hopper-fixture-Linux-demo.deb";
const generic =
"REA could not download or unpack the official Hopper package. Check network and filesystem access, then retry setup.";
const installHost = (): LinuxHopperInstallHost => {
const unexpected = vi.fn((): never => {
throw new Error("Unexpected installation effect");
});
return {
distribution: async () => ({
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
}),
release: async () =>
ok({
version: "fixture",
filename: url,
file_length: "1",
file_hash: "0".repeat(40),
main_exe_hash: "0".repeat(64),
signature: "AA==",
public: "1",
beta: "0",
}),
download: downloadHopperPackage,
createTemporaryDirectory: unexpected,
writeArchive: unexpected,
installPackage: unexpected,
verifyArchive: unexpected,
retainVerifiedArchive: unexpected,
launcherStatus: unexpected,
cleanup: unexpected,
};
};
const setupFailure = async () => {
vi.mocked(systemLinuxHopperInstallHost).mockReturnValue(installHost());
const host = systemSetupHost(
createDoctorHostFixture({ platform: "linux" }),
{},
);
try {
return await host.installHopper(false);
} finally {
expect(await host.close?.()).toBeUndefined();
}
};
it.each([404, 503])(
"preserves HTTP %i from download through Linux setup diagnostics",
async (status) => {
const cancel = vi.fn();
const fetch = vi
.fn<typeof globalThis.fetch>()
.mockImplementation(
async () => new Response(new ReadableStream({ cancel }), { status }),
);
vi.stubGlobal("fetch", fetch);
expect(await downloadHopperPackage(url)).toEqual({
ok: false,
bytes: new Uint8Array(),
httpStatus: status,
});
expect(await setupFailure()).toEqual({
status: "failed",
code: "download_failed",
remediation: `download: Package download returned HTTP ${status}. ${generic}`,
});
expect(fetch).toHaveBeenCalledTimes(2);
expect(cancel).toHaveBeenCalledTimes(2);
},
);
it.each([undefined, null, 0, 200, 600, 503.5, "503", Number.NaN])(
"keeps generic diagnostics when the failed response status is %s",
async (status) => {
vi.stubGlobal(
"fetch",
vi.fn<typeof globalThis.fetch>().mockImplementation(async () => {
const response = new Response(null, { status: 503 });
Object.defineProperty(response, "status", { value: status });
return response;
}),
);
expect(await downloadHopperPackage(url)).toEqual({
ok: false,
bytes: new Uint8Array(),
});
expect(await setupFailure()).toEqual({
status: "failed",
code: "download_failed",
remediation: generic,
});
},
);
it("keeps a successful download unchanged", async () => {
vi.stubGlobal(
"fetch",
vi.fn<typeof globalThis.fetch>().mockResolvedValue(new Response("package")),
);
expect(await downloadHopperPackage(url)).toEqual({
ok: true,
bytes: Buffer.from("package"),
});
});