diff --git a/docs/installation.md b/docs/installation.md index 73b3da1cb9..1b3777400a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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. diff --git a/src/application/HopperDownload.ts b/src/application/HopperDownload.ts index c9cb542c88..efe25c32b9 100644 --- a/src/application/HopperDownload.ts +++ b/src/application/HopperDownload.ts @@ -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 => { 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; } diff --git a/src/application/LinuxHopper.ts b/src/application/LinuxHopper.ts index 871b5be48f..dc7e953b43 100644 --- a/src/application/LinuxHopper.ts +++ b/src/application/LinuxHopper.ts @@ -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 diff --git a/tests/process-global/hopperDownloadHttpFailure.test.ts b/tests/process-global/hopperDownloadHttpFailure.test.ts new file mode 100644 index 0000000000..5906d215c2 --- /dev/null +++ b/tests/process-global/hopperDownloadHttpFailure.test.ts @@ -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() + .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().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().mockResolvedValue(new Response("package")), + ); + expect(await downloadHopperPackage(url)).toEqual({ + ok: true, + bytes: Buffer.from("package"), + }); +});