mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
0211a07ec5
commit
1a91630b88
4 files changed
+171
-11
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user