From 70cd4cbfb849b61f05d97d2690372a9782831426 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:15:55 +0800 Subject: [PATCH] fix(hopper)!: verify current Linux releases with the publisher key Replace fixed Linux Hopper release pins with current vendor feed metadata, complete detached-PGP package verification under the stable publisher primary key, and authenticated package evidence for offline admission. Share parsed publisher facts across approved setup, doctor and the pre-execution guard. Preserve existing ownership, cancellation, private-display and cleanup rules. Fixes #3577 Normalize empty XDG settings, preserve backups during approved cache repair, and retain publisher cleanup through cancellation and shutdown. Exercise cold offline verification without reusing process-local proof state. Serialize approved cache publication across processes, restore old evidence on failure, and share the existing lease owner from process without changing its identities. Validation: 438 focused tests (six platform-related skips), full static/documentation checks, real Ubuntu 24.04 official Hopper 6.6.0 setup/CLI/MCP, offline ordinary-user admission, launcher mutation rejection, and cold/warm verification measurements. Official RPM metadata mismatch is tracked separately in #3578 and fails closed. BREAKING CHANGE: Older Linux Hopper builds admitted by the fixed allowlist now require retained publisher-signed package evidence or an approved update to the current public demo. Update explicit HOPPER_LAUNCHER_PATH values when migrating; the live feed exposes only its current release. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- docs/installation.md | 31 + licenses/npm-dependencies.txt | 7 +- licenses/npm-runtime-notices.txt | 176 +++- package-lock.json | 19 + package.json | 1 + scripts/hopper-demo-x11.py | 22 +- scripts/verify-linux-setup-e2e.mjs | 21 +- scripts/verify-package-environment.mjs | 9 + scripts/verify-package-platform.mjs | 25 +- scripts/verify-real-hopper-fat.mjs | 2 +- scripts/verify-real-hopper.mjs | 2 +- src/application/Doctor.fixture.ts | 12 +- src/application/Doctor.test.ts | 14 +- src/application/Doctor.ts | 11 +- src/application/DoctorDiagnostics.ts | 43 +- src/application/HopperDownload.ts | 22 +- src/application/HopperReleaseFeed.test.ts | 77 ++ src/application/HopperReleaseFeed.ts | 125 +++ src/application/LinuxHopper.test.ts | 104 ++- src/application/LinuxHopper.ts | 277 +++--- src/application/LinuxHopperPublisher.ts | 833 ++++++++++++++++++ src/application/Setup.planning.test.ts | 39 + src/application/SetupHost.ts | 43 +- src/application/SetupInstallFailure.ts | 25 +- src/application/SetupPlan.ts | 18 +- src/application/SetupTypes.ts | 2 + src/artifacts/ArtifactResourceScope.ts | 10 +- src/domain/analysisErrorProjection.ts | 3 + src/domain/hopperErrors.ts | 11 + src/ghidra/GhidraDoctor.test.ts | 1 - src/hopper/BridgeLauncher.ts | 68 +- src/hopper/HopperClient.ts | 30 +- src/hopper/HopperTargetLease.test.ts | 2 +- src/process/DetachedPgpSignature.test.ts | 128 +++ src/process/DetachedPgpSignature.ts | 101 +++ .../HopperLease.ts} | 18 + .../providers/hopperStartupRecovery.test.ts | 2 +- .../cli/doctorHopperDiscovery.test.ts | 1 + .../cli/hopperLaunchFailureCli.test.ts | 2 +- .../process/binarySessionCleanupRetry.test.ts | 2 +- .../providers/hopper/bridgeLauncher.test.ts | 193 +++- .../hopper/hopperClient.helperFailure.test.ts | 2 +- .../hopper/hopperVerificationCleanup.test.ts | 2 +- .../linuxHopperPublisherVerification.test.ts | 765 ++++++++++++++++ .../packagePlatformDiagnostics.test.ts | 45 + tests/fixtures/hopperCachePublisher.mjs | 31 + tests/fixtures/hopperOwnerLifetime.py | 3 +- tests/fixtures/hopperRejectionFeed.mjs | 36 + .../hopperLeaseDescriptorPermissions.test.ts | 2 +- 49 files changed, 3195 insertions(+), 223 deletions(-) create mode 100644 src/application/HopperReleaseFeed.test.ts create mode 100644 src/application/HopperReleaseFeed.ts create mode 100644 src/application/LinuxHopperPublisher.ts create mode 100644 src/process/DetachedPgpSignature.test.ts create mode 100644 src/process/DetachedPgpSignature.ts rename src/{hopper/HopperTargetLease.ts => process/HopperLease.ts} (94%) create mode 100644 tests/boundary/setup/linuxHopperPublisherVerification.test.ts create mode 100644 tests/fixtures/hopperCachePublisher.mjs create mode 100644 tests/fixtures/hopperRejectionFeed.mjs diff --git a/docs/installation.md b/docs/installation.md index a428fbcb16..39a53bb701 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -541,6 +541,37 @@ search permissions (`0700`) on a directory you own before retrying. On supported Linux distributions, approved setup verifies Hopper's official `.deb`, `.rpm`, or Arch package before invoking the native package manager. +The official release feed selects the current public demo without a version +pin. REA verifies the package's detached PGP signature against Hopper's stable +primary fingerprint `7C2D73BAFAF5B9A710A69DA1A26DF815099B1BCD`, then checks +that the signed package and installed launcher agree with `main_exe_hash`. +Rotating signing subkeys remain bound to that primary identity. +The feed row itself is not PGP-signed; doctor labels its display version as +`reported_release_version`, separately from the authenticated launcher identity. + +The approval plan includes the caller-selected cache location. Approved setup +retains the signed archive, public key and release metadata under +`${XDG_CACHE_HOME:-$HOME/.cache}/rea/hopper-releases/`. +An empty `XDG_CACHE_HOME` uses `$HOME/.cache`. Approved setup can repair a +damaged caller-owned entry after authenticating a fresh package; the previous +entry is preserved beside it as `.backup-`. Ordinary doctor +and launches reject damaged evidence and never repair it implicitly. +Approved publications of the same canonical cache entry share a process-backed +lease. Concurrent setup waits without moving another writer's evidence; a +failed replacement restores the previous directory when possible and reports +any retained backup path. Unavailable reads or native inspection tools do not +authorize replacing existing evidence. +Doctor and launches authenticate this evidence for offline use; an editable +digest receipt alone cannot authorize a launcher. Verification reuses at most +eight immutable package proofs per process, keyed by the complete package, +key, signature, package format and expected launcher digest. Cached file reads +still check identity and content. Without retained evidence, inspecting an +existing install requires the matching current official package and network +access. Successful live inspection can be reused within that process after +rehashing the launcher; it does not write a persistent cache or claim that a +previously observed release is still the latest. Older uncached builds require +an approved update while the feed exposes only its current release. + REA runs the supported demo build on a private Xvfb display and selects Hopper's offered demo mode for each analysis session; it does not require the user's desktop display. Unattended package-manager access requires diff --git a/licenses/npm-dependencies.txt b/licenses/npm-dependencies.txt index 2f991e933b..31b00b3be0 100644 --- a/licenses/npm-dependencies.txt +++ b/licenses/npm-dependencies.txt @@ -2,7 +2,7 @@ REA npm dependency inventory Prepared from the root package-lock.json; refresh this inventory when locked dependencies change. License identifiers are upstream lockfile declarations, not a legal assessment. Runtime includes transitive and optional platform packages; development includes build, test and website tooling. -Locked package entries: 721; runtime: 177. +Locked package entries: 722; runtime: 178. Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt. @algolia/abtesting@1.25.0 | development | MIT @@ -2740,6 +2740,11 @@ oniguruma-to-es@3.1.1 | development | MIT Package: https://www.npmjs.com/package/oniguruma-to-es/v/3.1.1 Archive: https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz Integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ== +openpgp@6.3.2 | runtime | LGPL-3.0+ + Lockfile path: node_modules/openpgp + Package: https://www.npmjs.com/package/openpgp/v/6.3.2 + Archive: https://registry.npmjs.org/openpgp/-/openpgp-6.3.2.tgz + Integrity: sha512-wcZTzHz41LV8Y48zH/JlD1JT8YdNmpWOuMAjQ/podvvCwsjBEU37K3znJh4UQcj3hksE5z9TzvhbUbwzsGvlLQ== oxc-parser@0.150.0 | development | MIT Lockfile path: node_modules/oxc-parser Package: https://www.npmjs.com/package/oxc-parser/v/0.150.0 diff --git a/licenses/npm-runtime-notices.txt b/licenses/npm-runtime-notices.txt index 81adcd9ab4..d9efda90fa 100644 --- a/licenses/npm-runtime-notices.txt +++ b/licenses/npm-runtime-notices.txt @@ -1,7 +1,7 @@ REA third-party npm runtime copyright, license and NOTICE texts Collected from integrity-verified locked npm archives. Release-specific supplements and upstream notice gaps are identified in their entries. External engines are separately obtained; see THIRD_PARTY_NOTICES.md. -Runtime dependency fingerprint (SHA-256): 49b181923ab240c68c64d35ecdd8f93cb5cd516c765f2282d0e0eca0bcadd13e +Runtime dependency fingerprint (SHA-256): 131100b2e1e6ad5e55b10a0baf4be2d7a0838fd724fb307b9bafd73446b0d3ec ===== @babel/helper-string-parser@8.0.6 ===== Declared license: MIT @@ -5171,6 +5171,180 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +===== openpgp@6.3.2 ===== +Declared license: LGPL-3.0+ +Archive: https://registry.npmjs.org/openpgp/-/openpgp-6.3.2.tgz +Integrity: sha512-wcZTzHz41LV8Y48zH/JlD1JT8YdNmpWOuMAjQ/podvvCwsjBEU37K3znJh4UQcj3hksE5z9TzvhbUbwzsGvlLQ== +Scope: npm runtime dependency. GPLv3 terms incorporated by this LGPL text are preserved in licenses/GPL-3.0.txt. + +--- package/LICENSE --- + + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. + ===== pako@1.0.11 ===== Declared license: (MIT AND Zlib) Archive: https://registry.npmjs.org/pako/-/pako-1.0.11.tgz diff --git a/package-lock.json b/package-lock.json index 4e065d212e..1606244d90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "isomorphic-git": "1.38.7", "jsonc-parser": "3.3.1", "lossless-json": "4.3.1", + "openpgp": "6.3.2", "parse5": "8.0.1", "pino": "10.4.0", "playwright-core": "1.58.2", @@ -7871,6 +7872,24 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/openpgp": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/openpgp/-/openpgp-6.3.2.tgz", + "integrity": "sha512-wcZTzHz41LV8Y48zH/JlD1JT8YdNmpWOuMAjQ/podvvCwsjBEU37K3znJh4UQcj3hksE5z9TzvhbUbwzsGvlLQ==", + "license": "LGPL-3.0+", + "engines": { + "node": ">= 18.0.0", + "typescript": ">= 5.0.0" + }, + "peerDependencies": { + "@openpgp/web-stream-tools": "~0.3.0" + }, + "peerDependenciesMeta": { + "@openpgp/web-stream-tools": { + "optional": true + } + } + }, "node_modules/oxc-parser": { "version": "0.150.0", "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.150.0.tgz", diff --git a/package.json b/package.json index 12a81a9867..305c0bd4fd 100644 --- a/package.json +++ b/package.json @@ -337,6 +337,7 @@ "isomorphic-git": "1.38.7", "jsonc-parser": "3.3.1", "lossless-json": "4.3.1", + "openpgp": "6.3.2", "parse5": "8.0.1", "pino": "10.4.0", "playwright-core": "1.58.2", diff --git a/scripts/hopper-demo-x11.py b/scripts/hopper-demo-x11.py index da2fe884f7..a2fc414891 100644 --- a/scripts/hopper-demo-x11.py +++ b/scripts/hopper-demo-x11.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Inspect or activate Hopper's version-pinned demo dialog on a private X display.""" +"""Inspect or activate Hopper's authenticated demo dialog on a private X display.""" from __future__ import annotations @@ -8,6 +8,7 @@ import ctypes import hashlib import json import os +import re from pathlib import Path import secrets import select @@ -18,12 +19,6 @@ import threading import time -# Keep this digest aligned with LinuxHopper.ts and rerun real-Hopper verification -# before accepting another vendor build. -SUPPORTED_HOPPER_SHA256 = { - "0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded", - "1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13", -} EXPECTED_SCREEN = (1280, 1024) DIALOG_TITLES = {"Registration", "Registration — Hopper Disassembler"} HOPPER_WINDOW_TITLE = "Hopper Disassembler" @@ -121,6 +116,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--probe", action="store_true") parser.add_argument("--owner-stdin", action="store_true") parser.add_argument("--hopper") + parser.add_argument("--expected-launcher-sha256") parser.add_argument("--socket") parser.add_argument( "--strategy", @@ -661,7 +657,11 @@ def run_probe(args: argparse.Namespace) -> dict[str, object]: def run_launch(args: argparse.Namespace) -> int: - if args.hopper is None or args.socket is None: + if ( + args.hopper is None or args.socket is None + or args.expected_launcher_sha256 is None + or re.fullmatch(r"[a-f0-9]{64}", args.expected_launcher_sha256) is None + ): raise AdapterFailure( INVALID_LAUNCH_COMMAND, "invalid_launch_command", @@ -673,7 +673,9 @@ def run_launch(args: argparse.Namespace) -> int: hopper = Path(args.hopper) socket = Path(args.socket) try: - supported = digest(hopper) in SUPPORTED_HOPPER_SHA256 + # The parent owns publisher/package interpretation. Recheck its admitted + # launcher bytes immediately before execution, preserving all X11 guards. + supported = digest(hopper) == args.expected_launcher_sha256 except OSError as error: raise AdapterFailure( INVALID_LAUNCH_COMMAND, @@ -684,7 +686,7 @@ def run_launch(args: argparse.Namespace) -> int: raise AdapterFailure( UNSUPPORTED_HOPPER_BUILD, "unsupported_hopper_build", - "unsupported Hopper binary", + "Hopper binary differs from the authenticated release", ) command = args.command[1:] if args.command[:1] == ["--"] else args.command if not command or Path(command[0]).resolve() != hopper.resolve(): diff --git a/scripts/verify-linux-setup-e2e.mjs b/scripts/verify-linux-setup-e2e.mjs index e08e8b1606..a0b247f870 100644 --- a/scripts/verify-linux-setup-e2e.mjs +++ b/scripts/verify-linux-setup-e2e.mjs @@ -13,6 +13,7 @@ import { promisify } from "node:util"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; import { assertCleanLinuxHopperRuntime } from "./lib/clean-linux-hopper-runtime.mjs"; +import { isUnsupportedHopperLaunchFailure } from "./verify-package-platform.mjs"; const execFileAsync = promisify(execFile); const verifierRun = createVerifierRun(); @@ -89,16 +90,24 @@ try { await appendFile(modifiedHopper, new Uint8Array([0])); await chmod(modifiedHopper, 0o700); try { - await execFileAsync(command, ["analyze", "/usr/bin/true"], { - env: { ...process.env, HOPPER_LAUNCHER_PATH: modifiedHopper }, - timeout: 30_000, - }); + await execFileAsync( + command, + ["analyze", "/usr/bin/true", "--provider", "hopper", "--json"], + { + env: { ...process.env, HOPPER_LAUNCHER_PATH: modifiedHopper }, + timeout: 30_000, + }, + ); throw new Error("modified Hopper build was not rejected"); } catch (error) { - const output = `${error?.stdout ?? ""}\n${error?.stderr ?? ""}`; - if (!output.includes("unsupported_hopper_build")) + if ( + error?.code !== 1 || + typeof error.stdout !== "string" || + !isUnsupportedHopperLaunchFailure(JSON.parse(error.stdout)) + ) throw new Error( "modified Hopper build did not return its stable failure code", + { cause: error }, ); } } finally { diff --git a/scripts/verify-package-environment.mjs b/scripts/verify-package-environment.mjs index e57f7784b0..be2e81b865 100644 --- a/scripts/verify-package-environment.mjs +++ b/scripts/verify-package-environment.mjs @@ -1,5 +1,6 @@ import { chmod, mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { supportedClients } from "../dist/application/SupportedClients.js"; /** Create an isolated prefix, fake PATH, client configs, and environment. */ @@ -121,6 +122,14 @@ export const packageHopperEnvironment = (root, platform = process.platform) => { }; return { HOPPER_LAUNCHER_PATH: "/bin/sh", + // Feed availability must not turn this foreign-launcher rejection test into + // an unrelated networking failure. Successful vendor verification is real E2E coverage. + NODE_OPTIONS: [ + process.env.NODE_OPTIONS, + `--import=${pathToFileURL(join(root, "tests/fixtures/hopperRejectionFeed.mjs")).href}`, + ] + .filter(Boolean) + .join(" "), HOPPER_LOADER_ARGS_JSON: JSON.stringify([ "-c", 'node_path=$1; shift; "$node_path" "$@"', diff --git a/scripts/verify-package-platform.mjs b/scripts/verify-package-platform.mjs index 0dd67aa86c..11cb8fd245 100644 --- a/scripts/verify-package-platform.mjs +++ b/scripts/verify-package-platform.mjs @@ -88,14 +88,29 @@ export async function verifyPackagePlatform({ } } -/** Require the declared unsupported build or an exact missing-Xvfb diagnostic, never arbitrary provider failure. */ +/** One owner for unsupported-launch evidence across package and real-Hopper verification. */ +export const isUnsupportedHopperLaunchFailure = (failure) => { + const details = failure?.details; + const unsupportedBuild = + details?.failure_code === "unsupported_hopper_build" && + details.exit_code === 72; + const publisherRejection = + details?.stage === "launch" && + details.launch_rejection?.boundary === "publisher_verification" && + details.launch_rejection.reason === "unsupported_hopper_build" && + details.exit_code === undefined && + details.launcher === undefined; + return ( + failure?.code === "provider_unavailable" && + (publisherRejection || unsupportedBuild) + ); +}; + +/** Require an exact pre-execution rejection, observed helper exit, or missing-Xvfb diagnostic. */ export const assertLinuxPackageProviderFailure = (execution) => { const failure = json(execution.stdout); const details = failure?.details; const diagnostic = details?.diagnostics; - const unsupportedBuild = - details?.failure_code === "unsupported_hopper_build" && - details.exit_code === 72; const missingDisplayDependency = details?.failure_code === "runtime_dependency_unavailable" && details.exit_code === 79 && @@ -108,7 +123,7 @@ export const assertLinuxPackageProviderFailure = (execution) => { if ( execution.status !== 1 || failure?.code !== "provider_unavailable" || - (!unsupportedBuild && !missingDisplayDependency) + (!isUnsupportedHopperLaunchFailure(failure) && !missingDisplayDependency) ) throw new Error( `packaged Linux Hopper verification did not fail closed: ${JSON.stringify(failure)}`, diff --git a/scripts/verify-real-hopper-fat.mjs b/scripts/verify-real-hopper-fat.mjs index 5b1776ccb8..930935ef7c 100644 --- a/scripts/verify-real-hopper-fat.mjs +++ b/scripts/verify-real-hopper-fat.mjs @@ -23,7 +23,7 @@ import { requireMcpOperationResult, } from "./lib/mcp-verifier-results.mjs"; import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs"; -import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/hopper/HopperTargetLease.js"; +import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/process/HopperLease.js"; import { parseConfig } from "../dist/config/parseConfig.js"; import { verifyHopperSourceBinding, diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs index a82c1429a6..fbe2ff3757 100644 --- a/scripts/verify-real-hopper.mjs +++ b/scripts/verify-real-hopper.mjs @@ -12,7 +12,7 @@ import { requirePseudocode, } from "../dist/application/RealHopperAssertions.js"; import { HOPPER_PROVIDER_IDENTITY } from "../dist/hopper/HopperProviderCapabilities.js"; -import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/hopper/HopperTargetLease.js"; +import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/process/HopperLease.js"; import { REA_WORKFLOW_PROVIDER } from "../dist/application/InvestigationProviders.js"; import { loadRealHopperFixtureTargets } from "./lib/real-hopper-fixture.mjs"; import { verifyHopperStringObjects } from "./lib/real-hopper-search.mjs"; diff --git a/src/application/Doctor.fixture.ts b/src/application/Doctor.fixture.ts index de23a6e3db..6d49e5ac90 100644 --- a/src/application/Doctor.fixture.ts +++ b/src/application/Doctor.fixture.ts @@ -1,5 +1,6 @@ import { CATALOG_IDENTITY } from "../catalogIdentity.js"; import { PRODUCT_IDENTITY } from "../identity.js"; +import { ok } from "../domain/result.js"; import type { DoctorHost } from "./Doctor.js"; /** Healthy recording host for focused doctor and projection tests. */ @@ -14,7 +15,16 @@ export const createDoctorHostFixture = ( linuxDistribution: () => Promise.resolve(undefined), validTarget: (path) => Promise.resolve(path.includes("Hopper")), executable: (path) => Promise.resolve(path.includes("Hopper")), - supportedLinuxHopper: () => Promise.resolve(true), + inspectLinuxHopper: () => + Promise.resolve( + ok({ + sha256: "0".repeat(64), + version: "fixture", + primaryFingerprint: "0".repeat(40), + signingFingerprint: "1".repeat(40), + source: "signed_package_cache" as const, + }), + ), linuxDemoRuntimeCheck: () => Promise.resolve({ name: "hopper-demo-runtime", diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts index 2da77db087..2a2013c914 100644 --- a/src/application/Doctor.test.ts +++ b/src/application/Doctor.test.ts @@ -4,6 +4,8 @@ import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js"; import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js"; import { createDoctorHostFixture as host } from "./Doctor.fixture.js"; import { runDoctor } from "./Doctor.js"; +import { err } from "../domain/result.js"; +import { HopperReleaseError } from "./HopperReleaseFeed.js"; describe("doctor runtime check", () => { it("reports Node 25 as unsupported", async () => { @@ -617,12 +619,20 @@ describe("doctor Linux and Hopper discovery", () => { hopperSetupSupported: true, }), executable: (candidate) => Promise.resolve(candidate === path), - supportedLinuxHopper: () => Promise.resolve(false), + inspectLinuxHopper: () => + Promise.resolve( + err( + new HopperReleaseError( + "unsupported_hopper_build", + "Unverified fixture build", + ), + ), + ), }), ); expect( result.checks.find(({ name }) => name === "hopper-version")?.remediation, - ).toContain("Unset or update HOPPER_LAUNCHER_PATH"); + ).toContain("Check HOPPER_LAUNCHER_PATH"); }); }); diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index dc761fad84..7918582d03 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -19,11 +19,14 @@ import { macHopperRenamedLauncherCandidates, } from "./MacHopperPaths.js"; import { - linuxHopperBinarySupported, + inspectLinuxHopperLauncher, linuxSharedLibrariesAvailable, readLinuxDistribution, type LinuxDistribution, } from "./LinuxHopper.js"; +import type { Result } from "../domain/result.js"; +import type { HopperReleaseError } from "./HopperReleaseFeed.js"; +import type { VerifiedLinuxHopperLauncher } from "./LinuxHopperPublisher.js"; import { readInstalledSkillIdentity, type InstalledSkillIdentity, @@ -101,7 +104,9 @@ export interface DoctorHost { /** Route-specific target admission details, when supplied by the host. */ inspectTarget?(path: string): Promise; executable(path: string): Promise; - supportedLinuxHopper(path: string): Promise; + inspectLinuxHopper( + path: string, + ): Promise>; linuxDemoRuntimeCheck(): Promise; brewHopperPath(): Promise; manualHopperPaths(): Promise; @@ -512,7 +517,7 @@ export const systemDoctorHost = ( ), linuxDemoRuntimeCheck: options.linuxDemoRuntimeCheck ?? uncomposedLinuxDemoRuntimeCheck, - supportedLinuxHopper: linuxHopperBinarySupported, + inspectLinuxHopper: (path) => inspectLinuxHopperLauncher(path, environment), async brewHopperPath() { return probeHomebrew(async (command) => { let caskroom: string; diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts index 070ce4809e..a2e34d9757 100644 --- a/src/application/DoctorDiagnostics.ts +++ b/src/application/DoctorDiagnostics.ts @@ -209,17 +209,34 @@ const optionalHopperVersionCheck = async ( hopperPath: string | undefined, ): Promise => { if (host.platform !== "linux" || hopperPath === undefined) return undefined; - return check( - "hopper-version", - await host.supportedLinuxHopper(hopperPath), - hopperPath, - { - remediation: unsupportedHopperRemediation( - host.configuredHopperPath === hopperPath, - ), - classification: "config_drift", - }, - ); + const inspected = await host.inspectLinuxHopper(hopperPath); + const buildCheck = check("hopper-version", inspected.ok, hopperPath, { + remediation: unsupportedHopperRemediation( + host.configuredHopperPath === hopperPath, + ), + classification: + !inspected.ok && + [ + "release_metadata", + "download", + "verification_unavailable", + "cancelled", + "cleanup_failed", + ].includes(inspected.error.reason) + ? "unknown" + : "config_drift", + }); + return { + ...buildCheck, + details: inspected.ok + ? { + reported_release_version: inspected.value.version, + launcher_sha256: inspected.value.sha256, + primary_fingerprint: inspected.value.primaryFingerprint, + source: inspected.value.source, + } + : { reason: inspected.error.reason, message: inspected.error.message }, + }; }; const optionalHopperDemoCheck = async ( @@ -297,8 +314,8 @@ const providerDoctorChecks = ( const unsupportedHopperRemediation = (configured: boolean): string => configured - ? "Unset or update HOPPER_LAUNCHER_PATH, install the Hopper build supported by this REA release, or update REA." - : "Install the Hopper build supported by this REA release, or update REA for a newer Hopper build."; + ? "Check HOPPER_LAUNCHER_PATH and its publisher verification evidence. Run rea setup to install and retain an authenticated official Hopper package for offline use." + : "Run rea setup to install and retain an authenticated official Hopper package for offline use."; const parseMajor = (version: string | undefined): number => Number.parseInt(version?.split(".")[0] ?? "0", 10); diff --git a/src/application/HopperDownload.ts b/src/application/HopperDownload.ts index 976ed610b4..dc8cf66084 100644 --- a/src/application/HopperDownload.ts +++ b/src/application/HopperDownload.ts @@ -4,9 +4,23 @@ export const HOPPER_DOWNLOAD_BYTES = 100_000_000; /** Bound observed response bytes before retaining them, including chunked bodies. */ export const downloadHopperPackage = async ( url: string, - options: { readonly signal?: AbortSignal } = {}, + options: { + readonly signal?: AbortSignal; + readonly maxBytes?: number; + readonly timeoutMs?: number; + } = {}, ): Promise<{ readonly ok: boolean; readonly bytes: Uint8Array }> => { - const deadline = AbortSignal.timeout(120_000); + const timeoutMs = options.timeoutMs ?? 120_000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000) + throw new RangeError("Invalid Hopper response time budget"); + const deadline = AbortSignal.timeout(timeoutMs); + const maxBytes = options.maxBytes ?? HOPPER_DOWNLOAD_BYTES; + if ( + !Number.isSafeInteger(maxBytes) || + maxBytes < 1 || + maxBytes > HOPPER_DOWNLOAD_BYTES + ) + throw new RangeError("Invalid Hopper response byte budget"); const response = await fetch(url, { headers: { accept: "application/json", "user-agent": "rea-installer" }, signal: @@ -17,7 +31,7 @@ export const downloadHopperPackage = async ( const refused = { ok: false, bytes: new Uint8Array() }; if ( !response.ok || - Number(response.headers.get("content-length")) > HOPPER_DOWNLOAD_BYTES + Number(response.headers.get("content-length")) > maxBytes ) { await response.body?.cancel(); return refused; @@ -30,7 +44,7 @@ export const downloadHopperPackage = async ( while (true) { const chunk = await reader.read(); if (chunk.done) return { ok: true, bytes: Buffer.concat(chunks, bytes) }; - if (chunk.value.byteLength > HOPPER_DOWNLOAD_BYTES - bytes) { + if (chunk.value.byteLength > maxBytes - bytes) { await reader.cancel(); return refused; } diff --git a/src/application/HopperReleaseFeed.test.ts b/src/application/HopperReleaseFeed.test.ts new file mode 100644 index 0000000000..2cb2f4e252 --- /dev/null +++ b/src/application/HopperReleaseFeed.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { parseLinuxHopperReleases } from "./HopperReleaseFeed.js"; + +const release = { + version: "99.2.1", + filename: + "https://www.hopperapp.com:443/downloader/public/Hopper-99.2.1-Linux-demo.deb", + file_length: "123", + file_hash: "a".repeat(40), + main_exe_hash: "b".repeat(64), + signature: "AQ==", + public: "1", + beta: "0", + published_at: "preserved vendor field", +}; + +describe("Linux Hopper release feed boundary", () => { + it("preserves future release identity and partial platform facts", () => { + const parsed = parseLinuxHopperReleases({ + "Ubuntu / Mint": release, + "OS X": { signature: "Sparkle DSA" }, + }); + expect(parsed).toMatchObject({ + ok: true, + value: { + deb: { ok: true, value: release }, + rpm: { ok: false }, + arch: { ok: false }, + }, + }); + if (!parsed.ok || !parsed.value.deb.ok) throw new Error("Missing release"); + expect(parsed.value.deb.value.filename).toBe(release.filename); + }); + + it.each([ + { signature: undefined }, + { signature: "AQ==garbage" }, + { main_exe_hash: "b".repeat(40) }, + { file_length: "100000001" }, + { file_length: "NaN" }, + { public: "0" }, + { beta: "1" }, + { + filename: + "https://other.example/downloader/public/Hopper-99.2.1-Linux-demo.deb", + }, + { filename: release.filename + "?token=caller" }, + { + filename: release.filename.replace("https://", "https://user:password@"), + }, + { filename: release.filename.replace(".deb", ".rpm") }, + { filename: release.filename.replace("demo.deb", "full.deb") }, + ])( + "rejects metadata outside the selected public package contract: %j", + (changes) => { + expect( + parseLinuxHopperReleases({ + "Ubuntu / Mint": { ...release, ...changes }, + }), + ).toMatchObject({ + ok: true, + value: { deb: { ok: false, error: { reason: "release_metadata" } } }, + }); + }, + ); + + it.each([null, [], "html error page"])( + "keeps malformed feed roots unknown", + (raw) => { + expect(parseLinuxHopperReleases(raw)).toMatchObject({ + ok: false, + error: { reason: "release_metadata" }, + }); + }, + ); +}); diff --git a/src/application/HopperReleaseFeed.ts b/src/application/HopperReleaseFeed.ts new file mode 100644 index 0000000000..a6c89abb0c --- /dev/null +++ b/src/application/HopperReleaseFeed.ts @@ -0,0 +1,125 @@ +import { z } from "zod"; + +import { err, ok, type Result } from "../domain/result.js"; +import { HOPPER_DOWNLOAD_BYTES } from "./HopperDownload.js"; + +/** Vendor endpoints, shared by installation and existing-build inspection. */ +export const HOPPER_RELEASES_URL = + "https://www.hopperapp.com/include/files-api.php?request=releases&public=true"; +export const HOPPER_PUBLIC_KEY_URL = + "https://www.hopperapp.com/hopper-public-key.asc"; +export const HOPPER_DOWNLOAD_PREFIX = + "https://www.hopperapp.com:443/downloader/public/"; + +/** Stable publisher identity; signing subkeys are admitted through this primary. */ +export const HOPPER_LINUX_PRIMARY_FINGERPRINT = + "7C2D73BAFAF5B9A710A69DA1A26DF815099B1BCD"; + +export type LinuxPackageFamily = "deb" | "rpm" | "arch"; +const platforms = { + deb: "Ubuntu / Mint", + rpm: "Fedora", + arch: "Arch", +} as const; +const suffixes = { deb: ".deb", rpm: ".rpm", arch: ".pkg.tar.xz" } as const; + +export class HopperReleaseError extends Error { + readonly _tag = "HopperReleaseError"; + constructor( + readonly reason: + | "release_metadata" + | "download" + | "integrity" + | "publisher_verification_failed" + | "verification_unavailable" + | "unsupported_hopper_build" + | "cancelled" + | "cleanup_failed" + | "cache_missing", + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = this._tag; + } +} + +const sha256 = z.string().regex(/^[a-f0-9]{64}$/u); +const releaseSchema = z + .object({ + version: z.string().min(1), + filename: z.string().url(), + file_length: z + .string() + .regex(/^[1-9]\d*$/u) + .refine((text) => { + const count = Number(text); + return Number.isSafeInteger(count) && count <= HOPPER_DOWNLOAD_BYTES; + }), + file_hash: z.string().regex(/^[a-f0-9]{40}$/u), + main_exe_hash: sha256, + signature: z + .string() + .min(1) + .refine( + (text) => Buffer.from(text, "base64").toString("base64") === text, + ), + public: z.literal("1"), + beta: z.literal("0"), + }) + .passthrough(); + +/** Parsed once at the feed boundary; preserve the vendor's complete release row. */ +export type LinuxHopperRelease = z.infer; +export type LinuxHopperReleaseFeed = Readonly< + Record> +>; + +export const parseLinuxHopperRelease = ( + raw: unknown, + family: LinuxPackageFamily, +): Result => { + const parsed = releaseSchema.safeParse(raw); + if (!parsed.success) + return err( + new HopperReleaseError( + "release_metadata", + "The Hopper release omitted valid package identity or signature metadata.", + ), + ); + const url = new URL(parsed.data.filename); + if ( + url.origin !== "https://www.hopperapp.com" || + url.username !== "" || + url.password !== "" || + url.search !== "" || + url.hash !== "" || + !url.pathname.startsWith("/downloader/public/Hopper-") || + !url.pathname.endsWith(`-Linux-demo${suffixes[family]}`) + ) + return err( + new HopperReleaseError( + "release_metadata", + "The Hopper release is outside the official public Linux demo channel.", + ), + ); + return ok(parsed.data); +}; + +export const parseLinuxHopperReleases = ( + raw: unknown, +): Result => { + const feed = z.record(z.string(), z.unknown()).safeParse(raw); + if (!feed.success) + return err( + new HopperReleaseError( + "release_metadata", + "The Hopper releases response is not an object.", + ), + ); + return ok({ + deb: parseLinuxHopperRelease(feed.data[platforms.deb], "deb"), + rpm: parseLinuxHopperRelease(feed.data[platforms.rpm], "rpm"), + arch: parseLinuxHopperRelease(feed.data[platforms.arch], "arch"), + }); +}; diff --git a/src/application/LinuxHopper.test.ts b/src/application/LinuxHopper.test.ts index 3819ecfed4..e0ca5eef65 100644 --- a/src/application/LinuxHopper.test.ts +++ b/src/application/LinuxHopper.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from "vitest"; import { formatCommandForDisplay, installLinuxHopper, - linuxHopperLauncherDigestSupported, linuxPackageManagerCommands, linuxSharedLibrariesAvailable, parseLinuxDistribution, @@ -18,6 +17,12 @@ import { type LinuxPackageInstallFailure, type LinuxPackageInstallOutcome, } from "./LinuxHopper.js"; +import { err, ok } from "../domain/result.js"; +import { + HopperReleaseError, + type LinuxHopperRelease, +} from "./HopperReleaseFeed.js"; +import type { VerifiedLinuxHopperPackage } from "./LinuxHopperPublisher.js"; describe("Linux package-manager command display", () => { it("preserves argument boundaries for paths with spaces and quotes", () => { @@ -43,6 +48,9 @@ class RecordingLinuxHost implements LinuxHopperInstallHost { archiveOk = true; installSucceeds = true; cleanupFails = false; + signatureValid = true; + cacheWritable = true; + verifiedBeforeInstall = false; packageFailure: LinuxPackageInstallFailure = { command: "pacman -U --noconfirm /tmp/rea test/hopper.arch", exitCode: 1, @@ -57,6 +65,8 @@ class RecordingLinuxHost implements LinuxHopperInstallHost { distribution = (): Promise => Promise.resolve(this.distributionValue); + release = (family: LinuxPackageFamily) => + Promise.resolve(ok(releasesFor(new Uint8Array([1, 2, 3]))[family])); download = (url: string): Promise => { this.downloads.push(url); return Promise.resolve({ ok: this.archiveOk, bytes: this.archive }); @@ -64,10 +74,52 @@ class RecordingLinuxHost implements LinuxHopperInstallHost { createTemporaryDirectory = (): Promise => Promise.resolve("/tmp/rea test"); writeArchive = (): Promise => Promise.resolve(); + verifyArchive = (family: LinuxPackageFamily, release: LinuxHopperRelease) => { + if ( + createHash("sha1").update(this.archive).digest("hex") !== + release.file_hash + ) + return Promise.resolve( + err(new HopperReleaseError("integrity", "Fixture byte mismatch")), + ); + if (!this.signatureValid) + return Promise.resolve( + err( + new HopperReleaseError( + "publisher_verification_failed", + "Fixture signature rejection", + ), + ), + ); + this.verifiedBeforeInstall = true; + return Promise.resolve( + ok({ + family, + release, + packageSha256: createHash("sha256").update(this.archive).digest("hex"), + publicKey: "fixture", + primaryFingerprint: "0".repeat(40), + signingFingerprint: "1".repeat(40), + }), + ); + }; + retainVerifiedArchive = (_verified: VerifiedLinuxHopperPackage) => + Promise.resolve( + this.cacheWritable + ? ok(undefined) + : err( + new HopperReleaseError( + "verification_unavailable", + "Fixture cache permission rejection", + ), + ), + ); installPackage = ( family: LinuxPackageFamily, archive: string, ): Promise => { + if (!this.verifiedBeforeInstall) + throw new Error("Package manager ran before publisher verification"); this.installed.push({ family, archive }); const outcome: LinuxPackageInstallOutcome = this.installSucceeds ? { status: "installed" } @@ -160,20 +212,6 @@ describe("Linux Hopper host classification", () => { }); describe("Linux Hopper installation", () => { - it.each([ - [ - "Debian and Arch", - "0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded", - ], - ["RPM", "1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13"], - ])("accepts the pinned %s launcher build", (_, digest) => { - expect(linuxHopperLauncherDigestSupported(digest)).toBe(true); - }); - - it("rejects an unverified launcher build", () => { - expect(linuxHopperLauncherDigestSupported("0".repeat(64))).toBe(false); - }); - it.each([ ["deb", "apt-get", ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"]], [ @@ -239,6 +277,23 @@ describe("Linux Hopper package installation", () => { }); describe("Linux Hopper installation failure classification", () => { + it.each([ + ["signatureValid", "publisher_verification_failed"], + ["cacheWritable", "verification_unavailable"], + ] as const)( + "stops before privileged installation when %s fails", + async (field, reason) => { + const host = new RecordingLinuxHost(); + host[field] = false; + expect(await installFixture(host)).toMatchObject({ + status: "failed", + reason, + verificationFailure: { reason, message: expect.any(String) }, + }); + expect(host.installed).toEqual([]); + expect(host.cleaned).toEqual(["/tmp/rea test"]); + }, + ); it.each([ ["unsupported host", "unsupported_host"], ["package request failure", "download"], @@ -263,7 +318,7 @@ describe("Linux Hopper installation failure classification", () => { host.launcherStatusValue = "runtime_dependencies"; if (scenario === "unsupported build") host.launcherStatusValue = "unsupported_hopper_build"; - expect(await installFixture(host)).toEqual({ + expect(await installFixture(host)).toMatchObject({ status: "failed", reason, ...(scenario === "package manager failure" @@ -363,14 +418,16 @@ describe("Linux Hopper temporary cleanup ownership", () => { function releasesFor( bytes: Uint8Array, -): Record< - LinuxPackageFamily, - { filename: string; file_length: string; file_hash: string } -> { +): Record { const release = (extension: string) => ({ - filename: `https://www.hopperapp.com:443/downloader/public/Hopper.${extension}`, + version: "future-fixture", + filename: `https://www.hopperapp.com:443/downloader/public/Hopper-future-fixture-Linux-demo.${extension}`, file_length: String(bytes.byteLength), file_hash: createHash("sha1").update(bytes).digest("hex"), + main_exe_hash: "0".repeat(64), + signature: "AQ==", + public: "1" as const, + beta: "0" as const, }); return { deb: release("deb"), @@ -379,7 +436,4 @@ function releasesFor( }; } -const installFixture = (host: RecordingLinuxHost) => - installLinuxHopper(host, { - releases: releasesFor(new Uint8Array([1, 2, 3])), - }); +const installFixture = (host: RecordingLinuxHost) => installLinuxHopper(host); diff --git a/src/application/LinuxHopper.ts b/src/application/LinuxHopper.ts index b3503ae7eb..342fa20028 100644 --- a/src/application/LinuxHopper.ts +++ b/src/application/LinuxHopper.ts @@ -1,55 +1,37 @@ -import { createHash } from "node:crypto"; -import { createReadStream } from "node:fs"; import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFileBoundedOutput } from "../process/ExecFileBoundedOutput.js"; import { execFileOutput } from "../process/ExecFileOutput.js"; +import { downloadHopperPackage } from "./HopperDownload.js"; +import { hashStableFile } from "../filesystem/StableFileHash.js"; +import { err, type Result } from "../domain/result.js"; import { - downloadHopperPackage, - HOPPER_DOWNLOAD_BYTES, -} from "./HopperDownload.js"; + HOPPER_RELEASES_URL, + HOPPER_PUBLIC_KEY_URL, + HOPPER_DOWNLOAD_PREFIX, + HOPPER_LINUX_PRIMARY_FINGERPRINT, + HopperReleaseError, + type LinuxHopperRelease, + type LinuxPackageFamily, +} from "./HopperReleaseFeed.js"; +import { + LinuxHopperPublisher, + retryLinuxHopperPublisherCleanup, + type VerifiedLinuxHopperPackage, + type VerifiedLinuxHopperLauncher, +} from "./LinuxHopperPublisher.js"; + +export type { LinuxPackageFamily } from "./HopperReleaseFeed.js"; /** Package-manager output retained per stream without stopping the command. */ const PACKAGE_MANAGER_OUTPUT_BYTES = 64 * 1024; -const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/"; -const SUPPORTED_HOPPER_SHA256 = new Set([ - "0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded", - "1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13", -]); -interface LinuxHopperRelease { - readonly filename: string; - readonly file_length: string; - readonly file_hash: string; -} -const SUPPORTED_RELEASES: Readonly< - Record -> = { - deb: { - filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.deb`, - file_length: "35755772", - file_hash: "e4f79dff602648a8ff4a88b773875b6bfac0dc65", - }, - rpm: { - filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.rpm`, - file_length: "32264475", - file_hash: "b9a65f2f583a386d440d4401d9140aef3bf305de", - }, - arch: { - filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.pkg.tar.xz`, - file_length: "29405968", - file_hash: "92d780b61cab38eda95dc4112dee4fbeb0920f46", - }, -}; - -export type LinuxPackageFamily = "deb" | "rpm" | "arch"; /** Exact stable external effects disclosed before a Linux Hopper installation. */ export interface LinuxHopperInstallDisclosure { - readonly downloadUrl: string; - readonly expectedBytes: number; - readonly expectedSha1: string; + readonly networkOrigins: readonly string[]; + readonly integrity: string; readonly commands: readonly string[]; } @@ -58,11 +40,13 @@ export const linuxHopperInstallDisclosure = ( family: LinuxPackageFamily, isRoot: boolean, ): LinuxHopperInstallDisclosure => { - const release = SUPPORTED_RELEASES[family]; return { - downloadUrl: release.filename, - expectedBytes: Number(release.file_length), - expectedSha1: release.file_hash, + networkOrigins: [ + HOPPER_RELEASES_URL, + HOPPER_PUBLIC_KEY_URL, + HOPPER_DOWNLOAD_PREFIX, + ], + integrity: `Detached PGP signature · primary ${HOPPER_LINUX_PRIMARY_FINGERPRINT} · package byte identity and installed launcher SHA-256 from the official feed`, commands: linuxPackageManagerCommands( family, ``, @@ -133,6 +117,10 @@ export type LinuxPackageInstallOutcome = /** External effects required to install the official Linux package. */ export interface LinuxHopperInstallHost { distribution(): Promise; + release( + family: LinuxPackageFamily, + signal?: AbortSignal, + ): Promise>; download( url: string, options: { readonly signal?: AbortSignal }, @@ -143,8 +131,23 @@ export interface LinuxHopperInstallHost { family: LinuxPackageFamily, archive: string, ): Promise; - launcherStatus(path: string): Promise; + verifyArchive( + family: LinuxPackageFamily, + release: LinuxHopperRelease, + archive: string, + signal?: AbortSignal, + ): Promise>; + retainVerifiedArchive( + verified: VerifiedLinuxHopperPackage, + archive: string, + signal?: AbortSignal, + ): Promise>; + launcherStatus( + path: string, + expectedSha256: string, + ): Promise; cleanup(path: string): Promise; + close?(): Promise; } /** Structured outcome for every expected Linux installation failure. */ @@ -163,6 +166,9 @@ export type LinuxHopperInstallResult = | "launcher_missing" | "runtime_dependencies" | "unsupported_hopper_build" + | "publisher_verification_failed" + | "verification_unavailable" + | "cleanup_failed" | "cancelled"; /** * The failing package-manager command and its output. Present only when @@ -170,6 +176,8 @@ export type LinuxHopperInstallResult = * package manager said instead of assuming an authorization problem. */ readonly packageFailure?: LinuxPackageInstallFailure; + /** Publisher-owned failure, retained for the public setup diagnostic. */ + readonly verificationFailure?: HopperReleaseError; } & { readonly cleanupFailure?: LinuxHopperTemporaryCleanupFailure }); /** Failed removal of an owned temporary install directory. */ @@ -270,9 +278,6 @@ export const installLinuxHopper = async ( host: LinuxHopperInstallHost = systemLinuxHopperInstallHost(process.env), options: { readonly signal?: AbortSignal; - readonly releases?: Readonly< - Record - >; } = {}, ): Promise => { if (options.signal?.aborted === true) @@ -284,16 +289,56 @@ export const installLinuxHopper = async ( let result: LinuxHopperInstallResult; try { result = await (async (): Promise => { - const release = (options.releases ?? SUPPORTED_RELEASES)[ - distribution.packageFamily - ]; + const selected = await host.release( + distribution.packageFamily, + options.signal, + ); + if (!selected.ok) + return { + status: "failed", + verificationFailure: selected.error, + reason: + selected.error.reason === "cancelled" + ? "cancelled" + : "release_metadata", + }; + const release = selected.value; const archiveDownload = await host.download(release.filename, options); if (!archiveDownload.ok) return { status: "failed", reason: "download" }; - if (!packageIntegrityMatches(archiveDownload.bytes, release)) - return { status: "failed", reason: "integrity" }; temporary = await host.createTemporaryDirectory(); const archive = join(temporary, `hopper.${distribution.packageFamily}`); await host.writeArchive(archive, archiveDownload.bytes); + const verified = await host.verifyArchive( + distribution.packageFamily, + release, + archive, + options.signal, + ); + if (!verified.ok) + return { + status: "failed", + verificationFailure: verified.error, + reason: + verified.error.reason === "cache_missing" + ? "verification_unavailable" + : verified.error.reason, + }; + const retained = await host.retainVerifiedArchive( + verified.value, + archive, + options.signal, + ); + if (!retained.ok) + return { + status: "failed", + verificationFailure: retained.error, + reason: + retained.error.reason === "cache_missing" + ? "verification_unavailable" + : retained.error.reason, + }; + if (options.signal?.aborted === true) + return { status: "failed", reason: "cancelled" }; const install = await host.installPackage( distribution.packageFamily, archive, @@ -305,7 +350,10 @@ export const installLinuxHopper = async ( packageFailure: install.failure, }; const launcherPath = "/opt/hopper/bin/Hopper"; - const launcherStatus = await host.launcherStatus(launcherPath); + const launcherStatus = await host.launcherStatus( + launcherPath, + release.main_exe_hash, + ); if (launcherStatus !== "ready") return { status: "failed", @@ -360,49 +408,82 @@ export const linuxHopperLauncherCandidates = ( /** Compose package-manager and linker checks from the selected caller environment. */ export const systemLinuxHopperInstallHost = ( environment: Readonly, -): LinuxHopperInstallHost => ({ - distribution: readLinuxDistribution, - download: downloadHopperPackage, - createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")), - writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }), - installPackage: (family, archive) => - installSystemPackage(family, archive, environment), - async launcherStatus(path) { - try { - await access(path); - const linked = await execFileOutput("ldd", [path], { env: environment }); - if (!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`)) - return "runtime_dependencies"; - return (await linuxHopperBinarySupported(path)) - ? "ready" - : "unsupported_hopper_build"; - } catch (cause: unknown) { - // best-effort cleanup: optional launcher probing; unreadable means missing. - void cause; - return "missing"; - } - }, - cleanup: (path) => rm(path, { recursive: true, force: true }), -}); - -/** Match the Linux Hopper build validated by the packaged demo-dialog adapter. */ -export const linuxHopperBinarySupported = async ( - path: string, -): Promise => { - try { - const hash = createHash("sha256"); - for await (const chunk of createReadStream(path)) hash.update(chunk); - return linuxHopperLauncherDigestSupported(hash.digest("hex")); - } catch (cause: unknown) { - // best-effort cleanup: optional build probing; unreadable means unsupported. - void cause; - return false; - } +): LinuxHopperInstallHost => { + const publisher = systemLinuxHopperPublisher(environment); + return { + distribution: readLinuxDistribution, + release: (family, signal) => publisher.currentRelease(family, signal), + download: downloadHopperPackage, + createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")), + writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }), + installPackage: (family, archive) => + installSystemPackage(family, archive, environment), + verifyArchive: (family, release, archive, signal) => + publisher.verifyArchive(family, release, archive, signal), + retainVerifiedArchive: (verified, archive, signal) => + publisher.retainVerifiedArchive(verified, archive, signal), + async launcherStatus(path, expectedSha256) { + try { + await access(path); + const linked = await execFileOutput("ldd", [path], { + env: environment, + }); + if ( + !linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`) + ) + return "runtime_dependencies"; + return (await hashStableFile(path)).sha256 === expectedSha256 + ? "ready" + : "unsupported_hopper_build"; + } catch (cause: unknown) { + // best-effort cleanup: optional launcher probing; unreadable means missing. + void cause; + return "missing"; + } + }, + cleanup: (path) => rm(path, { recursive: true, force: true }), + close: async () => { + await publisher.close(); + await retryLinuxHopperPublisherCleanup(); + }, + }; }; -/** Match a launcher extracted from one of the pinned official Linux packages. */ -export const linuxHopperLauncherDigestSupported = (digest: string): boolean => - SUPPORTED_HOPPER_SHA256.has(digest); +export const systemLinuxHopperPublisher = ( + environment: Readonly, +): LinuxHopperPublisher => + new LinuxHopperPublisher( + environment, + async () => (await readLinuxDistribution())?.packageFamily, + ); + +/** Read-only authentication; persistent package retention belongs to approved setup. */ +export const inspectLinuxHopperLauncher = async ( + path: string, + environment: Readonly, + signal?: AbortSignal, +): Promise> => { + const publisher = systemLinuxHopperPublisher(environment); + const result = await publisher.inspectLauncher(path, signal); + try { + await publisher.close(); + await retryLinuxHopperPublisherCleanup(); + return result; + } catch (cause: unknown) { + return err( + new HopperReleaseError( + "cleanup_failed", + "Hopper publisher verification retains incomplete cleanup.", + { + cause: new AggregateError( + result.ok ? [cause] : [result.error, cause], + "Publisher verification outcomes", + ), + }, + ), + ); + } +}; /** Interpret ldd output conservatively so a present but broken launcher is unhealthy. */ export const linuxSharedLibrariesAvailable = (output: string): boolean => @@ -492,14 +573,6 @@ export const linuxPackageManagerCommands = ( }); }; -const packageIntegrityMatches = ( - bytes: Uint8Array, - release: LinuxHopperRelease, -): boolean => - bytes.byteLength === Number(release.file_length) && - bytes.byteLength <= HOPPER_DOWNLOAD_BYTES && - createHash("sha1").update(bytes).digest("hex") === release.file_hash; - const packageFamilyFor = ( id: string, idLike: string | undefined, diff --git a/src/application/LinuxHopperPublisher.ts b/src/application/LinuxHopperPublisher.ts new file mode 100644 index 0000000000..79f09729e3 --- /dev/null +++ b/src/application/LinuxHopperPublisher.ts @@ -0,0 +1,833 @@ +import { createHash, randomUUID } from "node:crypto"; +import { copyFile, lstat, mkdir, rename, writeFile } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; + +import { z } from "zod"; +import { + ArtifactResourceScope, + type ArtifactResourceOwner, +} from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { hashStableFile } from "../filesystem/StableFileHash.js"; +import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; +import { verifyDetachedPgpSignature } from "../process/DetachedPgpSignature.js"; +import { execFileOutput } from "../process/ExecFileOutput.js"; +import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; +import { waitForAbortableDelay } from "../process/ProviderDeadline.js"; +import { + acquireHopperReleaseCacheLease, + type HopperTargetLease, +} from "../process/HopperLease.js"; +import { + downloadHopperPackage, + HOPPER_DOWNLOAD_BYTES, +} from "./HopperDownload.js"; +import { + HOPPER_LINUX_PRIMARY_FINGERPRINT, + HOPPER_PUBLIC_KEY_URL, + HOPPER_RELEASES_URL, + HopperReleaseError, + parseLinuxHopperRelease, + parseLinuxHopperReleases, + type LinuxHopperRelease, + type LinuxPackageFamily, +} from "./HopperReleaseFeed.js"; +import { withRegularFile } from "./RegularFileRead.js"; + +/** Metadata/key inspection budget, independent of the compressed package budget. */ +const RELEASE_METADATA_BYTES = 1024 * 1024; +/** Small feed/key requests must not consume the full package-download deadline. */ +const RELEASE_METADATA_TIMEOUT_MS = 10_000; +const cacheRecordSchema = z.object({ + format: z.literal("rea-signed-hopper-release-v1"), + family: z.enum(["deb", "rpm", "arch"]), + release: z.unknown(), +}); + +export interface VerifiedLinuxHopperPackage { + readonly release: LinuxHopperRelease; + readonly family: LinuxPackageFamily; + readonly packageSha256: string; + readonly publicKey: string; + readonly primaryFingerprint: string; + readonly signingFingerprint: string; +} + +export interface VerifiedLinuxHopperLauncher { + readonly sha256: string; + /** Display value from release metadata; the package signature binds bytes, not this row. */ + readonly version: string; + readonly primaryFingerprint: string; + readonly signingFingerprint: string; + readonly source: + | "signed_package_cache" + | "signed_vendor_release" + | "verified_process_cache"; +} + +/** Retain failed ephemeral cleanup independently of the caller's lifetime. */ +const pendingCleanup = new Set(); +/** Immutable cryptographic facts, bounded to eight packages for this process lifetime. */ +const packageProofs = new Map< + string, + Pick +>(); +const liveLauncherProofs = new Map(); +const retainProof = (cache: Map, key: string, proof: T): void => { + cache.delete(key); + cache.set(key, proof); + const oldest = cache.keys().next().value; + if (cache.size > 8 && oldest !== undefined) cache.delete(oldest); +}; + +/** One owner for the selected cache location, shared with the approval plan. */ +export const linuxHopperReleaseCacheDirectory = ( + environment: Readonly, +): Result => { + try { + const supplied = environment.XDG_CACHE_HOME || undefined; + if (supplied !== undefined && !isAbsolute(supplied)) + return err( + new HopperReleaseError( + "verification_unavailable", + "XDG_CACHE_HOME must be an absolute filesystem path.", + ), + ); + return ok( + join( + supplied ?? + join(homeDirectoryFromEnvironment(environment, "linux"), ".cache"), + "rea", + "hopper-releases", + ), + ); + } catch (cause: unknown) { + return err( + new HopperReleaseError( + "verification_unavailable", + "The Hopper release cache location could not be resolved.", + { cause }, + ), + ); + } +}; + +/** Authenticated package facts shared by setup, doctor, and the launch boundary. */ +export class LinuxHopperPublisher { + readonly #resources = new ArtifactResourceScope(); + #feed: ReturnType | undefined; + + constructor( + readonly environment: Readonly, + readonly packageFamily: () => Promise, + ) {} + + get cacheRoot(): string { + const location = linuxHopperReleaseCacheDirectory(this.environment); + if (!location.ok) throw location.error; + return location.value; + } + + async currentRelease( + family: LinuxPackageFamily, + signal?: AbortSignal, + ): Promise> { + try { + signal?.throwIfAborted(); + if (this.#feed === undefined) { + const response = await downloadHopperPackage(HOPPER_RELEASES_URL, { + ...(signal === undefined ? {} : { signal }), + maxBytes: RELEASE_METADATA_BYTES, + timeoutMs: RELEASE_METADATA_TIMEOUT_MS, + }); + if (!response.ok) + throw new HopperReleaseError( + "release_metadata", + "The official Hopper release feed is unavailable.", + ); + this.#feed = parseLinuxHopperReleases( + JSON.parse(decodeUtf8(response.bytes)), + ); + } + if (!this.#feed.ok) return this.#feed; + return this.#feed.value[family]; + } catch (cause: unknown) { + return err(releaseFailure(cause, signal, "release_metadata")); + } + } + + async verifyArchive( + family: LinuxPackageFamily, + release: LinuxHopperRelease, + archive: string, + signal?: AbortSignal, + suppliedPublicKey?: string, + ): Promise> { + try { + const bytes = await this.#read(archive, HOPPER_DOWNLOAD_BYTES, signal); + if ( + bytes.length !== Number(release.file_length) || + createHash("sha1").update(bytes).digest("hex") !== release.file_hash + ) + return err( + new HopperReleaseError( + "integrity", + "The Hopper package does not match the selected release bytes.", + ), + ); + let publicKey = suppliedPublicKey; + if (publicKey === undefined) { + const response = await downloadHopperPackage(HOPPER_PUBLIC_KEY_URL, { + ...(signal === undefined ? {} : { signal }), + maxBytes: RELEASE_METADATA_BYTES, + timeoutMs: RELEASE_METADATA_TIMEOUT_MS, + }); + if (!response.ok) + throw new HopperReleaseError( + "verification_unavailable", + "The official Hopper public key is unavailable.", + ); + publicKey = decodeUtf8(response.bytes); + } + const packageSha256 = createHash("sha256").update(bytes).digest("hex"); + // Every read still validates file identity, byte bounds and selected metadata. + // Reuse only proof of the exact signed bytes/key/signature/payload interpretation. + const proofKey = createHash("sha256") + .update( + JSON.stringify([ + family, + packageSha256, + publicKey, + release.signature, + release.main_exe_hash, + ]), + ) + .digest("hex"); + signal?.throwIfAborted(); + const existingProof = packageProofs.get(proofKey); + if (existingProof !== undefined) + return ok({ + release, + family, + packageSha256, + publicKey, + ...existingProof, + }); + const verified = await verifyDetachedPgpSignature( + bytes, + Buffer.from(release.signature, "base64"), + publicKey, + HOPPER_LINUX_PRIMARY_FINGERPRINT, + signal, + ); + if (!verified.ok) + return err( + new HopperReleaseError( + verified.error.reason === "cancelled" + ? "cancelled" + : "publisher_verification_failed", + verified.error.message, + { cause: verified.error }, + ), + ); + const mainExeHash = await this.#withTemporary(async (root) => { + // Extract only after authentication, from this operation's private byte snapshot. + const snapshot = join(root, "package"); + await writeFile(snapshot, bytes, { flag: "wx", mode: 0o600 }); + return this.#packageLauncherHash(family, snapshot, root, signal); + }); + if (mainExeHash !== release.main_exe_hash) + return err( + new HopperReleaseError( + "integrity", + `The signed ${family} package launcher SHA-256 ${mainExeHash} differs from the release main_exe_hash ${release.main_exe_hash}.`, + ), + ); + retainProof(packageProofs, proofKey, verified.value); + return ok({ + release, + family, + packageSha256, + publicKey, + ...verified.value, + }); + } catch (cause: unknown) { + return err(releaseFailure(cause, signal, "verification_unavailable")); + } + } + + /** Durable evidence contains the signed bytes; an editable digest receipt cannot authorize a build. */ + async retainVerifiedArchive( + verified: VerifiedLinuxHopperPackage, + archive: string, + signal?: AbortSignal, + ): Promise> { + try { + signal?.throwIfAborted(); + await mkdir(this.cacheRoot, { recursive: true, mode: 0o700 }); + const parent = await lstat(this.cacheRoot); + if ( + !parent.isDirectory() || + parent.isSymbolicLink() || + parent.uid !== process.getuid?.() + ) + throw new HopperReleaseError( + "verification_unavailable", + "The Hopper release cache is not a caller-owned directory.", + ); + const target = join(this.cacheRoot, verified.release.main_exe_hash); + const lease = await this.#acquireCacheLease( + verified.release.main_exe_hash, + signal, + ); + await this.#withOwnedResource( + { + kind: "lease", + resource: lease.socketPath ?? "hopper-release-cache-lease", + lease, + }, + async () => { + signal?.throwIfAborted(); + await this.#withTemporary(async (stage) => { + await copyFile(archive, join(stage, "archive")); + if ( + (await hashStableFile(join(stage, "archive"), signal)).sha256 !== + verified.packageSha256 + ) + throw new HopperReleaseError( + "integrity", + "The verified package changed before cache retention.", + ); + await writeFile(join(stage, "public-key.asc"), verified.publicKey, { + flag: "wx", + mode: 0o600, + }); + await writeFile( + join(stage, "release.json"), + JSON.stringify({ + format: "rea-signed-hopper-release-v1", + family: verified.family, + release: verified.release, + }) + "\n", + { flag: "wx", mode: 0o600 }, + ); + await this.#publishCacheStage( + stage, + target, + verified.release.main_exe_hash, + signal, + ); + }, this.cacheRoot); + }, + ); + return ok(undefined); + } catch (cause: unknown) { + return err(releaseFailure(cause, signal, "verification_unavailable")); + } + } + + async #publishCacheStage( + stage: string, + target: string, + digest: string, + signal?: AbortSignal, + ): Promise { + const existing = await this.#cachedPackage(digest, signal); + if (existing.ok) return; + if (existing.error.reason === "cache_missing") { + await rename(stage, target); + return; + } + if ( + ![ + "integrity", + "release_metadata", + "publisher_verification_failed", + ].includes(existing.error.reason) + ) + throw existing.error; + signal?.throwIfAborted(); + const previous = await lstat(target); + if ( + !previous.isDirectory() || + previous.isSymbolicLink() || + previous.uid !== process.getuid?.() + ) + throw existing.error; + // Only approved retention under the publication lease may repair an entry. + // Preserve malformed evidence without following links or deleting content. + const backup = `${target}.backup-${randomUUID()}`; + await rename(target, backup); + try { + const moved = await lstat(backup); + if (moved.dev !== previous.dev || moved.ino !== previous.ino) + throw new Error("The Hopper cache entry changed during repair."); + await rename(stage, target); + } catch (cause: unknown) { + const replacement = await this.#cachedPackage(digest, signal); + if (replacement.ok) return; + await this.#failedCachePublication( + target, + backup, + [cause, replacement.error], + replacement.error.reason === "cleanup_failed", + ); + } + } + + async #acquireCacheLease( + digest: string, + signal?: AbortSignal, + ): Promise { + const deadline = performance.now() + 120_000; + const runId = randomUUID(); + let delay = 25; + while (true) { + signal?.throwIfAborted(); + const acquired = await acquireHopperReleaseCacheLease({ + cacheDirectory: this.cacheRoot, + launcherSha256: digest, + runId, + }); + if (acquired.acquired) return acquired.lease; + const remaining = deadline - performance.now(); + if (remaining <= 0) + throw new HopperReleaseError( + "verification_unavailable", + "Another approved Hopper setup still owns this release cache. Retry after it finishes.", + ); + await waitForAbortableDelay(Math.min(delay, remaining), signal); + delay = Math.min(delay * 2, 200); + } + } + + async #failedCachePublication( + target: string, + backup: string, + causes: unknown[], + cleanupFailed = false, + ): Promise { + let preserved = backup; + try { + // Under the publication lease, restore the previous directory on failure. + // POSIX rename cannot replace another nonempty evidence directory. + await rename(backup, target); + preserved = target; + } catch (cause: unknown) { + causes.push(cause); + } + throw new HopperReleaseError( + cleanupFailed ? "cleanup_failed" : "verification_unavailable", + `The Hopper cache repair did not finish; previous evidence is preserved at ${preserved}. Retry approved setup.`, + { + cause: new AggregateError( + causes, + "Hopper cache publication and recovery outcomes", + ), + }, + ); + } + + async inspectLauncher( + path: string, + signal?: AbortSignal, + ): Promise> { + try { + const observed = await hashStableFile(path, signal); + const cached = await this.#cachedPackage(observed.sha256, signal); + if (cached.ok) { + if ((await hashStableFile(path, signal)).sha256 !== observed.sha256) + return err( + new HopperReleaseError( + "integrity", + "The Hopper launcher changed during publisher verification.", + ), + ); + return ok(launcherIdentity(cached.value, "signed_package_cache")); + } + if (cached.error.reason !== "cache_missing") return cached; + signal?.throwIfAborted(); + const processProof = liveLauncherProofs.get(observed.sha256); + if (processProof !== undefined) + return ok({ ...processProof, source: "verified_process_cache" }); + const family = await this.packageFamily(); + if (family === undefined) + return err( + new HopperReleaseError( + "verification_unavailable", + "No native Linux package family is available for Hopper publisher verification.", + ), + ); + const selected = await this.currentRelease(family, signal); + if (!selected.ok) return selected; + if (selected.value.main_exe_hash !== observed.sha256) + return err( + new HopperReleaseError( + "unsupported_hopper_build", + "The selected Hopper launcher does not match an authenticated cached release or the official current Linux demo.", + ), + ); + const verified = await this.#withTemporary(async (root) => { + const response = await downloadHopperPackage( + selected.value.filename, + signal === undefined ? {} : { signal }, + ); + if (!response.ok) + throw new HopperReleaseError( + "download", + "The official Hopper package is unavailable for publisher verification.", + ); + const archive = join(root, "archive"); + await writeFile(archive, response.bytes, { flag: "wx", mode: 0o600 }); + const verified = await this.verifyArchive( + family, + selected.value, + archive, + signal, + ); + if (!verified.ok) throw verified.error; + return verified.value; + }); + if ((await hashStableFile(path, signal)).sha256 !== observed.sha256) + return err( + new HopperReleaseError( + "integrity", + "The Hopper launcher changed during publisher verification.", + ), + ); + const identity = launcherIdentity(verified, "signed_vendor_release"); + retainProof(liveLauncherProofs, observed.sha256, identity); + return ok(identity); + } catch (cause: unknown) { + return err(releaseFailure(cause, signal, "verification_unavailable")); + } + } + + async close(): Promise { + try { + await this.#resources.close(); + pendingCleanup.delete(this.#resources); + } catch (cause: unknown) { + pendingCleanup.add(this.#resources); + throw cause; + } + } + + async #cachedPackage( + digest: string, + signal?: AbortSignal, + ): Promise> { + try { + const root = join(this.cacheRoot, digest); + try { + const directory = await lstat(root); + if (!directory.isDirectory() || directory.isSymbolicLink()) + throw new HopperReleaseError( + "integrity", + "The retained Hopper cache entry is not a regular directory.", + ); + } catch (cause: unknown) { + if (cause instanceof Error && Reflect.get(cause, "code") === "ENOENT") { + return err( + new HopperReleaseError( + "cache_missing", + "No retained signed Hopper package identifies this launcher.", + { cause }, + ), + ); + } + throw cause; + } + // Distinguish proven malformed/missing evidence from unavailable reads or + // inspection tools. Only the former authorize approved cache replacement. + for (const name of ["release.json", "public-key.asc", "archive"]) + await this.#assertCacheFile(join(root, name)); + const metadata = await this.#read( + join(root, "release.json"), + RELEASE_METADATA_BYTES, + signal, + ); + let value: unknown; + try { + value = JSON.parse(decodeUtf8(metadata)); + } catch (cause: unknown) { + throw new HopperReleaseError( + "release_metadata", + "The retained Hopper package metadata is malformed.", + { cause }, + ); + } + const parsed = cacheRecordSchema.safeParse(value); + if (!parsed.success) + throw new HopperReleaseError( + "release_metadata", + "The retained Hopper package metadata is malformed.", + ); + const release = parseLinuxHopperRelease( + parsed.data.release, + parsed.data.family, + ); + if (!release.ok) return release; + if (release.value.main_exe_hash !== digest) + throw new HopperReleaseError( + "integrity", + "The retained Hopper metadata does not identify this launcher.", + ); + const keyBytes = await this.#read( + join(root, "public-key.asc"), + RELEASE_METADATA_BYTES, + signal, + ); + let key: string; + try { + key = decodeUtf8(keyBytes); + } catch (cause: unknown) { + throw new HopperReleaseError( + "publisher_verification_failed", + "The retained Hopper public key is malformed.", + { cause }, + ); + } + return this.verifyArchive( + parsed.data.family, + release.value, + join(root, "archive"), + signal, + key, + ); + } catch (cause: unknown) { + return err(releaseFailure(cause, signal, "verification_unavailable")); + } + } + + async #assertCacheFile(path: string): Promise { + try { + const stats = await lstat(path); + if (!stats.isFile() || stats.isSymbolicLink()) + throw new HopperReleaseError( + "integrity", + `Required Hopper evidence is not a regular file: ${path}.`, + ); + } catch (cause: unknown) { + if (cause instanceof Error && Reflect.get(cause, "code") === "ENOENT") + throw new HopperReleaseError( + "integrity", + `Required Hopper evidence is missing: ${path}.`, + { cause }, + ); + throw cause; + } + } + + async #packageLauncherHash( + family: LinuxPackageFamily, + archive: string, + root: string, + signal?: AbortSignal, + ): Promise { + const commandOptions = { + env: this.environment, + signal, + timeout: 120_000, + stopSignal: "SIGKILL" as const, + maxBuffer: RELEASE_METADATA_BYTES, + }; + if (family === "rpm") { + const output = await execFileOutput( + "rpm", + [ + "-qp", + "--nosignature", + "--nodigest", + "--qf", + "%{FILEDIGESTALGO}\n[%{FILENAMES}\t%{FILEDIGESTS}\n]", + archive, + ], + commandOptions, + ); + const rows = output.stdout.split("\n"); + if (rows.shift() !== "8") + throw new HopperReleaseError( + "integrity", + "The signed RPM header does not use SHA-256 file digests.", + ); + const matches = rows.filter((row) => + row.startsWith("/opt/hopper/bin/Hopper\t"), + ); + const digest = matches[0]?.split("\t")[1]; + if ( + matches.length !== 1 || + digest === undefined || + !/^[a-f0-9]{64}$/u.test(digest) + ) + throw new HopperReleaseError( + "integrity", + "The signed RPM header has no unique Hopper launcher digest.", + ); + return digest; + } + const extracted = join(root, "extracted"); + await mkdir(extracted, { mode: 0o700 }); + if (family === "deb") + await execFileOutput( + "dpkg-deb", + ["--extract", archive, extracted], + commandOptions, + ); + else + await execFileOutput( + "tar", + ["-xf", archive, "-C", extracted, "--", "opt/hopper/bin/Hopper"], + commandOptions, + ); + return ( + await hashStableFile( + join(extracted, "opt", "hopper", "bin", "Hopper"), + signal, + ) + ).sha256; + } + + #read(path: string, limit: number, signal?: AbortSignal): Promise { + return withRegularFile( + path, + async (handle, stats) => { + if (stats.size > limit) + throw new HopperReleaseError( + "integrity", + "Retained Hopper evidence exceeds its resource byte budget.", + ); + const bytes = await readBoundedFileBytes(handle, stats.size, signal); + if (bytes === undefined) + throw new HopperReleaseError( + "integrity", + "Retained Hopper evidence changed during inspection.", + ); + return bytes; + }, + { signal, resources: this.#resources, symlinks: "reject" }, + ); + } + + async #withTemporary( + operation: (path: string) => Promise, + parent?: string, + ): Promise { + const root = await PrivateRuntimeRoot.create({ + prefix: "rea-hopper-publisher-", + ...(parent === undefined ? {} : { parent }), + }); + return this.#withOwnedResource( + { + kind: "private-runtime-root", + resource: root.path, + root, + }, + () => operation(root.path), + ); + } + + async #withOwnedResource( + owner: ArtifactResourceOwner, + operation: () => Promise, + ): Promise { + let outcome: + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly cause: unknown }; + try { + outcome = { ok: true, value: await operation() }; + } catch (cause: unknown) { + outcome = { ok: false, cause }; + } + const cleanup = await this.#resources.release(owner); + if (cleanup.kind === "failed") { + pendingCleanup.add(this.#resources); + const causes = outcome.ok + ? [cleanup.cause] + : [outcome.cause, cleanup.cause]; + throw new HopperReleaseError( + "cleanup_failed", + `Hopper publisher verification retained cleanup ownership for ${owner.resource}.`, + { + cause: new AggregateError( + causes, + "Publisher inspection and cleanup outcomes", + ), + }, + ); + } + if (!outcome.ok) throw outcome.cause; + return outcome.value; + } +} + +/** Retry only resources retained by this producer after failed cleanup. */ +export const retryLinuxHopperPublisherCleanup = async (): Promise => { + const causes: unknown[] = []; + const observations = []; + for (const scope of pendingCleanup) { + try { + await scope.close(); + pendingCleanup.delete(scope); + } catch (cause: unknown) { + causes.push(cause); + observations.push( + ArtifactReaderFailure.cleanupObservation( + cause, + "hopper-publisher-verification", + ), + ); + } + } + if (causes.length > 0) { + const cleanup = { + reason: observations.map(({ reason }) => reason).join("; "), + resources: [ + ...new Set(observations.flatMap(({ resources }) => resources)), + ], + }; + throw new ArtifactReaderFailure( + "unavailable", + `Hopper publisher cleanup remains incomplete: ${cleanup.resources.join(", ")}`, + { + cause: new AggregateError(causes, "Hopper publisher cleanup failures"), + cleanup, + }, + ); + } +}; + +const decodeUtf8 = (bytes: Uint8Array): string => + new TextDecoder("utf-8", { fatal: true }).decode(bytes); +const releaseFailure = ( + cause: unknown, + signal: AbortSignal | undefined, + fallback: HopperReleaseError["reason"], +): HopperReleaseError => + cause instanceof HopperReleaseError && cause.reason === "cleanup_failed" + ? cause + : signal?.aborted === true + ? new HopperReleaseError( + "cancelled", + "Hopper publisher verification was cancelled.", + { cause }, + ) + : cause instanceof HopperReleaseError + ? cause + : new HopperReleaseError( + fallback, + "Hopper publisher verification could not complete.", + { cause }, + ); +const launcherIdentity = ( + verified: VerifiedLinuxHopperPackage, + source: VerifiedLinuxHopperLauncher["source"], +): VerifiedLinuxHopperLauncher => ({ + sha256: verified.release.main_exe_hash, + version: verified.release.version, + primaryFingerprint: verified.primaryFingerprint, + signingFingerprint: verified.signingFingerprint, + source, +}); diff --git a/src/application/Setup.planning.test.ts b/src/application/Setup.planning.test.ts index b7137b9b87..22e9310e4e 100644 --- a/src/application/Setup.planning.test.ts +++ b/src/application/Setup.planning.test.ts @@ -5,6 +5,7 @@ import type { SetupProgressEvent } from "./SetupTypes.js"; import { FakeSetupHost, options } from "./Setup.fixture.js"; import { SetupSkillInstallError } from "./SetupSkill.js"; import { err, ok } from "../domain/result.js"; +import { HopperReleaseError } from "./HopperReleaseFeed.js"; const linkedSkillFailure = () => new SetupSkillInstallError({ @@ -16,6 +17,44 @@ const linkedSkillFailure = () => cause: undefined, }); +describe("Linux publisher cache approval", () => { + it.each([true, false])( + "discloses the selected cache or blocks an unresolved path before approval: %s", + async (valid) => { + const host = Object.assign(new FakeSetupHost("linux"), { + linuxHopperReleaseCache: valid + ? ok("/caller/cache/rea/hopper-releases") + : err( + new HopperReleaseError( + "verification_unavailable", + "Invalid selected cache path", + ), + ), + }); + host.distribution = { + id: "ubuntu", + versionId: "24.04", + packageFamily: "deb", + hopperSetupSupported: true, + }; + const result = await runSetup(options(false, true), host); + if (valid) + expect( + result.plannedActions.find(({ kind }) => kind === "install_hopper") + ?.detail, + ).toContain("/caller/cache/rea/hopper-releases/"); + else + expect(result).toMatchObject({ + status: "needs_human", + plannedActions: [], + }); + expect(host.hopperInstalls).toBe(0); + expect(host.configurations).toBe(0); + expect(host.skillInstalls).toBe(0); + }, + ); +}); + describe("selected skill admission", () => { it.each(["codex", "shared"] as const)( "keeps %s choices when an unselected client is linked", diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 2ae65a0487..0840385e4e 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -20,8 +20,10 @@ import { LinuxHopperTemporaryCleanupOwner, readLinuxDistribution, systemLinuxHopperInstallHost, + type LinuxHopperInstallHost, } from "./LinuxHopper.js"; import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js"; +import { linuxHopperReleaseCacheDirectory } from "./LinuxHopperPublisher.js"; import { clientEvidencePaths, supportedClients, @@ -156,6 +158,12 @@ export const systemSetupHost = ( return { platform, homeDirectory, + ...(platform === "linux" + ? { + linuxHopperReleaseCache: + linuxHopperReleaseCacheDirectory(environment), + } + : {}), skillDestinations: (clientIds) => skillDestinations(homeDirectory, clientIds, environment, platform), ...setupRegistration(platform, homeDirectory, environment), @@ -164,7 +172,7 @@ export const systemSetupHost = ( linuxDistribution: readLinuxDistribution, close: () => closeSetupResources(resources, macHopperHost, doctorHost, () => - pendingLinuxHopperCleanup.retry(linuxHopperHost.cleanup), + closeLinuxHopperInstallHost(linuxHopperHost, pendingLinuxHopperCleanup), ), initialSetupState: async ( scope?: DoctorScope, @@ -206,9 +214,19 @@ export const systemSetupHost = ( const result = await installLinuxHopper(linuxHopperHost); if (result.cleanupFailure !== undefined) pendingLinuxHopperCleanup.retain(result.cleanupFailure); - return result.status === "installed" - ? { status: "installed", launcherPath: result.launcherPath } - : setupInstallFailure(result.reason, result.packageFailure); + if (result.status === "installed") + return { status: "installed", launcherPath: result.launcherPath }; + const failure = setupInstallFailure( + result.reason, + result.packageFailure, + "linux", + ); + return result.verificationFailure !== undefined + ? { + ...failure, + remediation: `${result.verificationFailure.message} ${failure.remediation}`, + } + : failure; } if (macHopperHost === undefined) return setupInstallFailure("unsupported_host"); @@ -425,6 +443,23 @@ const configurationCleanupError = ( const errorMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); +const closeLinuxHopperInstallHost = async ( + host: LinuxHopperInstallHost, + temporaryOwner: LinuxHopperTemporaryCleanupOwner, +): Promise => { + const reasons: string[] = []; + const temporary = await temporaryOwner.retry(host.cleanup); + if (temporary !== undefined) reasons.push(temporary); + try { + await host.close?.(); + } catch (cause: unknown) { + reasons.push( + `Linux Hopper publisher cleanup failed: ${errorMessage(cause)}`, + ); + } + return reasons.length === 0 ? undefined : reasons.join("; "); +}; + /** Detect supported agents from their config files or stable installation markers. */ export const detectClients = async ( home: string, diff --git a/src/application/SetupInstallFailure.ts b/src/application/SetupInstallFailure.ts index 635918ca1e..6bb825054f 100644 --- a/src/application/SetupInstallFailure.ts +++ b/src/application/SetupInstallFailure.ts @@ -1,4 +1,5 @@ import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js"; +import { HOPPER_LINUX_PRIMARY_FINGERPRINT } from "./HopperReleaseFeed.js"; import type { LinuxPackageInstallFailure } from "./LinuxHopper.js"; export type SetupFailureCode = @@ -50,6 +51,8 @@ export type HopperInstallFailureReason = | "mount" | "bundle" | "publisher_verification_failed" + | "verification_unavailable" + | "cleanup_failed" | "copy"; const normalizePackageOutput = (output: string): string => @@ -99,6 +102,7 @@ const packageManagerRemediation = ( export const setupInstallFailure = ( reason: HopperInstallFailureReason, packageFailure?: LinuxPackageInstallFailure, + platform: NodeJS.Platform = "darwin", ): SetupHopperInstallFailure => { switch (reason) { case "unsupported_host": @@ -139,7 +143,7 @@ export const setupInstallFailure = ( status: "failed", code: "unsupported_hopper_build", remediation: - "The installed Hopper launcher does not match the build supported by this REA release. Unset HOPPER_LAUNCHER_PATH, reinstall through rea setup, or update REA.", + "The installed Hopper launcher does not match the authenticated vendor package. Check HOPPER_LAUNCHER_PATH and reinstall the official Hopper demo through rea setup.", }; case "cancelled": return { @@ -173,7 +177,24 @@ export const setupInstallFailure = ( return { status: "failed", code: "publisher_verification_failed", - remediation: `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`, + remediation: + platform === "linux" + ? `The Hopper package signature did not verify under the pinned primary PGP fingerprint ${HOPPER_LINUX_PRIMARY_FINGERPRINT}; the package manager was not run. Download the official package and retry setup.` + : `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`, + }; + case "verification_unavailable": + return { + status: "failed", + code: "publisher_verification_failed", + remediation: + "REA could not authenticate or retain the signed Hopper package. Check vendor access, native package inspection tools and caller-owned cache permissions, then retry setup.", + }; + case "cleanup_failed": + return { + status: "failed", + code: "cleanup_failed", + remediation: + "Hopper publisher verification retained temporary cleanup ownership. Retry cleanup before running setup again.", }; case "copy": return { diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index 682eebbc46..3a7b3d0f1d 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -161,6 +161,14 @@ export const planSetupActions = async (input: { const client = byId.get(id); return client === undefined ? [] : [client]; }); + const hopperCache = input.host.linuxHopperReleaseCache; + if (input.discovery.installHopper && hopperCache?.ok === false) + return { + plannedActions: [], + selectedClients, + clientsToConfigure: [], + blocker: hopperCache.error.message, + }; if ( selectedClients.length > 0 && input.host.registrationCommandBlocker !== undefined @@ -199,6 +207,9 @@ export const planSetupActions = async (input: { const plannedActions = setupPlan({ platform: input.host.platform, homeDirectory: input.host.homeDirectory, + ...(hopperCache?.ok === true + ? { hopperCacheDirectory: hopperCache.value } + : {}), installHopper: input.discovery.installHopper, installSkill: input.installSkill, skillClientIds: input.skillClientIds, @@ -264,6 +275,7 @@ const plannedClientWrites = ( const setupPlan = (input: { readonly platform: NodeJS.Platform; readonly homeDirectory: string; + readonly hopperCacheDirectory?: string; readonly installHopper: boolean; readonly installSkill: boolean; readonly skillClientIds: readonly string[]; @@ -289,7 +301,7 @@ const setupPlan = (input: { : "system package manager", detail: input.platform === "linux" - ? "Download, verify, and install Hopper plus its Xvfb demo-session dependencies. For the supported demo build, REA uses a private display and selects Hopper's offered demo mode for each analysis session." + ? `Download the current official Hopper demo, verify its PGP publisher and launcher bytes, and retain archive, public-key.asc and release.json under ${input.hopperCacheDirectory ?? `${input.homeDirectory}/.cache/rea/hopper-releases`}/ for offline verification. Preserve replaced damaged evidence beside it as .backup-. Install Hopper plus its Xvfb demo-session dependencies. REA uses a private display and selects Hopper's offered demo mode for each analysis session.` : "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.", external: true, operation: "install" as const, @@ -346,9 +358,9 @@ const linuxDisclosure = (family: LinuxPackageFamily) => { process.getuid?.() === 0, ); return { - networkOrigins: [disclosure.downloadUrl], + networkOrigins: disclosure.networkOrigins, commands: disclosure.commands, - integrity: `${String(disclosure.expectedBytes)} bytes · SHA-1 ${disclosure.expectedSha1}`, + integrity: disclosure.integrity, }; }; diff --git a/src/application/SetupTypes.ts b/src/application/SetupTypes.ts index 2bdb3f5b4c..afebad211a 100644 --- a/src/application/SetupTypes.ts +++ b/src/application/SetupTypes.ts @@ -1,5 +1,6 @@ import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js"; import type { LinuxDistribution } from "./LinuxHopper.js"; +import type { HopperReleaseError } from "./HopperReleaseFeed.js"; import type { SkillDestination, SkillInstallResult, @@ -67,6 +68,7 @@ export type ClientConfigurationInspection = export interface SetupHost { readonly platform: NodeJS.Platform; readonly homeDirectory: string; + readonly linuxHopperReleaseCache?: Result; skillDestinations(clientIds: readonly string[]): readonly SkillDestination[]; readonly registrationCommand: readonly string[]; /** Why client registration cannot proceed from this host. */ diff --git a/src/artifacts/ArtifactResourceScope.ts b/src/artifacts/ArtifactResourceScope.ts index 8728996311..f069845357 100644 --- a/src/artifacts/ArtifactResourceScope.ts +++ b/src/artifacts/ArtifactResourceScope.ts @@ -8,8 +8,13 @@ import { ArtifactReaderFailure } from "./ArtifactReader.js"; import type { SafeOutputTree } from "./SafeOutputTree.js"; import type { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; -/** One artifact resource and its format-specific cleanup owner. */ +/** One artifact resource and the capability that owns its cleanup. */ export type ArtifactResourceOwner = + | { + readonly kind: "lease"; + readonly resource: string; + readonly lease: { release(): Promise }; + } | { readonly kind: "javascript-source-analysis"; readonly resource: string; @@ -156,6 +161,9 @@ export class ArtifactResourceScope { const cleanup = async (owner: ArtifactResourceOwner): Promise => { switch (owner.kind) { + case "lease": + await owner.lease.release(); + return; case "javascript-source-analysis": await owner.analysis.close(); return; diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index e73de5e92a..d916db49df 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -422,6 +422,9 @@ const providerErrorDetails = ( return { stage: "launch", provider_state: "unknown", + ...(error.launchRejection === undefined + ? {} + : { launch_rejection: { ...error.launchRejection } }), retry_action: error.ownerRunId === undefined ? "retry" : "unknown", ...(error.ownerRunId === undefined ? {} diff --git a/src/domain/hopperErrors.ts b/src/domain/hopperErrors.ts index 2ec3135c70..955991e653 100644 --- a/src/domain/hopperErrors.ts +++ b/src/domain/hopperErrors.ts @@ -148,23 +148,34 @@ export class HopperProcessError extends HopperError { } } +/** Parsed facts about rejecting an application before execution. */ +export interface HopperLaunchRejection { + /** The selected application was rejected before it was executed. */ + readonly boundary: "publisher_verification"; + /** Parsed failure reason supplied by the verification owner. */ + readonly reason: string; +} + /** Hopper or the repository bridge could not be started. */ export class HopperStartError extends HopperError { readonly _tag = "HopperStartError"; override readonly userMessage: string | undefined; readonly ownerRunId: string | undefined; readonly launcherFailure: HopperLauncherOutcome | undefined; + readonly launchRejection: HopperLaunchRejection | undefined; constructor( options?: ErrorOptions & { readonly userMessage?: string; readonly ownerRunId?: string; readonly launcherFailure?: HopperLauncherOutcome; + readonly launchRejection?: HopperLaunchRejection; }, ) { super("Hopper application bridge could not be started", options); this.userMessage = options?.userMessage; this.ownerRunId = options?.ownerRunId; this.launcherFailure = options?.launcherFailure; + this.launchRejection = options?.launchRejection; } } diff --git a/src/ghidra/GhidraDoctor.test.ts b/src/ghidra/GhidraDoctor.test.ts index c15ff8a101..0a98bcb759 100644 --- a/src/ghidra/GhidraDoctor.test.ts +++ b/src/ghidra/GhidraDoctor.test.ts @@ -69,7 +69,6 @@ const doctorHost = ( validTarget: () => Promise.resolve(true), executable: (path) => Promise.resolve(hopperAvailable && path.includes("Hopper")), - supportedLinuxHopper: () => Promise.resolve(true), linuxDemoRuntimeCheck: () => Promise.resolve({ name: "hopper-demo-runtime", diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index 40cba4288b..1cef1cf7d2 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -9,6 +9,8 @@ import { HopperStartError, } from "../domain/hopperErrors.js"; import { err, ok, type Result } from "../domain/result.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; import { execFileOutput } from "../process/ExecFileOutput.js"; import { @@ -30,10 +32,12 @@ import { import { acquireHopperTargetLease, acquireLinuxHopperApplicationLease, -} from "./HopperTargetLease.js"; -import type { HopperTargetLease } from "./HopperTargetLease.js"; +} from "../process/HopperLease.js"; +import type { HopperTargetLease } from "../process/HopperLease.js"; import { HOPPER_SHUTDOWN_TIMEOUT_MS } from "./HopperSessionValues.js"; import { verifyMacHopperNativeHelper } from "./HopperMacPublisher.js"; +import { inspectLinuxHopperLauncher } from "../application/LinuxHopper.js"; +import { retryLinuxHopperPublisherCleanup } from "../application/LinuxHopperPublisher.js"; const execFileAsync = promisify(execFile); const HOPPER_BACKGROUND_STARTUP_MS = 5_000; @@ -73,6 +77,8 @@ export type BridgeLaunch = { /** Application-owned capability that starts the in-Hopper bridge. */ export interface BridgeLauncher { + /** Retry owned pre-launch resources even when no application was started. */ + cleanup?(): Promise>; launch( session: BridgeSession, options?: { readonly signal?: AbortSignal }, @@ -110,6 +116,8 @@ export type HopperApplicationLauncherOptions = export interface HopperApplicationLauncherDependencies { readonly verifyNativeHelper?: typeof verifyMacHopperNativeHelper; + readonly verifyLinuxLauncher?: typeof inspectLinuxHopperLauncher; + readonly retryPublisherCleanup?: typeof retryLinuxHopperPublisherCleanup; readonly platform?: NodeJS.Platform; readonly acquireTargetLease?: typeof acquireHopperTargetLease; readonly acquireApplicationLease?: typeof acquireLinuxHopperApplicationLease; @@ -140,6 +148,32 @@ export class HopperApplicationLauncher implements BridgeLauncher { readonly dependencies: HopperApplicationLauncherDependencies = {}, ) {} + async cleanup(): Promise> { + if (!usesLinuxDemo(this.options)) return ok(null); + try { + await ( + this.dependencies.retryPublisherCleanup ?? + retryLinuxHopperPublisherCleanup + )(); + return ok(null); + } catch (cause: unknown) { + const observation = ArtifactReaderFailure.cleanupObservation( + cause, + "hopper-publisher-verification", + ); + return err( + new ProviderCleanupError( + "hopper", + observation.resources, + { + reason: observation.reason, + }, + { cause }, + ), + ); + } + } + async launch( session: BridgeSession, options: { readonly signal?: AbortSignal } = {}, @@ -218,6 +252,26 @@ export class HopperApplicationLauncher implements BridgeLauncher { if (signal?.aborted === true) return err(new HopperCancelledError()); const display = await this.#privateDisplay(signal); if (!display.ok) return err(display.error); + let expectedLinuxSha256: string | undefined; + if (usesLinuxDemo(this.options)) { + const inspected = await ( + this.dependencies.verifyLinuxLauncher ?? inspectLinuxHopperLauncher + )(this.options.launcherPath, this.options.environment, signal); + if (!inspected.ok) + return inspected.error.reason === "cancelled" + ? err(new HopperCancelledError()) + : err( + new HopperStartError({ + userMessage: inspected.error.message, + cause: inspected.error, + launchRejection: { + boundary: "publisher_verification", + reason: inspected.error.reason, + }, + }), + ); + expectedLinuxSha256 = inspected.value.sha256; + } const nativeHelperVerified = (this.dependencies.platform ?? process.platform) === "darwin" && this.options.launchMode === "native" && @@ -236,6 +290,7 @@ export class HopperApplicationLauncher implements BridgeLauncher { session, argumentsForTarget, display.strategy, + expectedLinuxSha256, ); const started = await launchHopperProcess({ options: this.options, @@ -449,6 +504,7 @@ export const linuxDemoLaunch = ( session: BridgeSession, hopperArgs: readonly string[], strategy: LinuxPrivateDisplayRunnableStrategy | undefined, + expectedSha256?: string, ): | { readonly command: string; @@ -458,6 +514,10 @@ export const linuxDemoLaunch = ( | undefined => { if (!usesLinuxDemo(options) || options.demoHelperPath === undefined) return undefined; + if (expectedSha256 === undefined || !/^[a-f0-9]{64}$/u.test(expectedSha256)) + throw new TypeError( + "Linux Hopper launch requires an authenticated launcher SHA-256", + ); const helperArguments = [ options.demoHelperPath, "--owner-stdin", @@ -466,6 +526,8 @@ export const linuxDemoLaunch = ( ...(strategy === "user-mount-namespace" ? ["--mount-private-x11"] : []), "--hopper", options.launcherPath, + "--expected-launcher-sha256", + expectedSha256, "--socket", session.socketPath, "--", @@ -501,7 +563,7 @@ const hopperLaunchFailure = ( ? new HopperCancelledError() : new HopperStartError({ cause }); -/** Select the version-pinned adapter from the caller's explicit launch mode. */ +/** Select the publisher-verified demo adapter from the caller's explicit launch mode. */ export const usesLinuxDemo = ( options: HopperApplicationLauncherOptions, ): options is HopperApplicationLauncherOptions & { diff --git a/src/hopper/HopperClient.ts b/src/hopper/HopperClient.ts index c81a2f2fa7..a0668c3a77 100644 --- a/src/hopper/HopperClient.ts +++ b/src/hopper/HopperClient.ts @@ -29,6 +29,7 @@ import { type ProviderOperationRequest, } from "../domain/providerOperationHealth.js"; import { err, ok, type Result } from "../domain/result.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { ProgressReporter } from "../application/ProgressReporter.js"; import { silentLogger } from "../logger.js"; @@ -328,7 +329,8 @@ export class HopperClient { if ( this.#socket !== undefined || this.#resources.launch !== undefined || - this.#resources.runtimeRoot !== undefined + this.#resources.runtimeRoot !== undefined || + this.#startupFailureResult?.cleanupIncomplete === true ) { return err( this.#startupFailureResult ?? @@ -563,7 +565,9 @@ export class HopperClient { this.#releaseTransport(), ); await this.#reconnectForCleanup(); - return await this.#cleanup(options); + const closed = await this.#cleanup(options); + if (closed.ok) this.#startupFailureResult = undefined; + return closed; } finally { if (this.#startPromise === starting) this.#startPromise = undefined; if (this.#startupController === controller) { @@ -641,7 +645,7 @@ export class HopperClient { this.#closing = true; this.#ready = false; try { - return await cleanupHopperSession({ + const closed = await cleanupHopperSession({ socket: this.#socket, resources: this.#resources, activeRequest: this.#requests.activity(), @@ -653,6 +657,26 @@ export class HopperClient { retainDocument: options.retainDocument === true, releaseTransport: () => this.#releaseTransport(), }); + const launcherClosed = await this.#options.launcher + .cleanup?.() + .catch((cause: unknown) => + err( + new ProviderCleanupError( + "hopper", + ["hopper-launcher"], + { + reason: cause instanceof Error ? cause.message : String(cause), + }, + { cause }, + ), + ), + ); + if (launcherClosed === undefined || launcherClosed.ok) return closed; + return closed.ok + ? launcherClosed + : err( + analysisErrorWithCleanupFailure(closed.error, launcherClosed.error), + ); } finally { if (this.#socket?.destroyed === true) this.#socket = undefined; diff --git a/src/hopper/HopperTargetLease.test.ts b/src/hopper/HopperTargetLease.test.ts index a0c8e02965..87d8355029 100644 --- a/src/hopper/HopperTargetLease.test.ts +++ b/src/hopper/HopperTargetLease.test.ts @@ -20,7 +20,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { acquireHopperTargetLease, acquireLinuxHopperApplicationLease, -} from "./HopperTargetLease.js"; +} from "../process/HopperLease.js"; const directories: string[] = []; diff --git a/src/process/DetachedPgpSignature.test.ts b/src/process/DetachedPgpSignature.test.ts new file mode 100644 index 0000000000..ee240089fa --- /dev/null +++ b/src/process/DetachedPgpSignature.test.ts @@ -0,0 +1,128 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { createMessage, generateKey, readPrivateKey, sign } from "openpgp"; + +import { verifyDetachedPgpSignature } from "./DetachedPgpSignature.js"; + +const bytes = Buffer.from("binary package fixture\0\xff", "latin1"); +let publicKey: string; +let fingerprint: string; +let signatures: Uint8Array[]; +let signingFingerprints: string[]; + +beforeAll(async () => { + const generated = await generateKey({ + type: "ecc", + curve: "curve25519Legacy", + userIDs: [{ name: "Fixture publisher" }], + subkeys: [{ sign: true }, { sign: true }], + format: "armored", + }); + const privateKey = await readPrivateKey({ armoredKey: generated.privateKey }); + publicKey = generated.publicKey; + fingerprint = privateKey.getFingerprint().toUpperCase(); + signingFingerprints = privateKey.subkeys.map((subkey) => + subkey.getFingerprint().toUpperCase(), + ); + signatures = []; + for (const subkey of privateKey.subkeys) + signatures.push( + await sign({ + message: await createMessage({ binary: bytes }), + signingKeys: privateKey, + signingKeyIDs: [subkey.getKeyID()], + detached: true, + format: "binary", + }), + ); +}); + +describe("detached binary PGP publisher verification", () => { + it("accepts independently selected signing subkeys through one pinned primary", async () => { + for (const [index, signature] of signatures.entries()) + expect( + await verifyDetachedPgpSignature( + bytes, + signature, + publicKey, + fingerprint, + ), + ).toEqual({ + ok: true, + value: { + primaryFingerprint: fingerprint, + signingFingerprint: signingFingerprints[index], + }, + }); + }); + + it("rejects a valid signature belonging to another primary", async () => { + expect( + await verifyDetachedPgpSignature( + bytes, + signatures[0]!, + publicKey, + "0".repeat(40), + ), + ).toMatchObject({ ok: false, error: { reason: "wrong_publisher" } }); + }); + + it("rejects tampered payload bytes", async () => { + const tampered = Buffer.from(bytes); + tampered[0] = (tampered[0] ?? 0) ^ 1; + expect( + await verifyDetachedPgpSignature( + tampered, + signatures[0]!, + publicKey, + fingerprint, + ), + ).toMatchObject({ ok: false, error: { reason: "invalid_signature" } }); + }); + + it("rejects malformed signatures and public keys", async () => { + expect( + await verifyDetachedPgpSignature( + bytes, + new Uint8Array([1, 2, 3]), + publicKey, + fingerprint, + ), + ).toMatchObject({ ok: false, error: { reason: "invalid_signature" } }); + expect( + await verifyDetachedPgpSignature( + bytes, + signatures[0]!, + "malformed", + fingerprint, + ), + ).toMatchObject({ ok: false, error: { reason: "invalid_signature" } }); + }); + + it("preserves cancellation", async () => { + expect( + await verifyDetachedPgpSignature( + bytes, + signatures[0]!, + publicKey, + fingerprint, + AbortSignal.abort(), + ), + ).toMatchObject({ ok: false, error: { reason: "cancelled" } }); + }); + + it("preserves cancellation while loading the publisher key, before classifying its identity", async () => { + const controller = new AbortController(); + const verification = verifyDetachedPgpSignature( + bytes, + signatures[0]!, + publicKey, + "0".repeat(40), + controller.signal, + ); + queueMicrotask(() => controller.abort()); + expect(await verification).toMatchObject({ + ok: false, + error: { reason: "cancelled" }, + }); + }); +}); diff --git a/src/process/DetachedPgpSignature.ts b/src/process/DetachedPgpSignature.ts new file mode 100644 index 0000000000..8d1a215cd1 --- /dev/null +++ b/src/process/DetachedPgpSignature.ts @@ -0,0 +1,101 @@ +import { err, ok, type Result } from "../domain/result.js"; + +export class DetachedPgpSignatureError extends Error { + readonly _tag = "DetachedPgpSignatureError"; + constructor( + readonly reason: "invalid_signature" | "wrong_publisher" | "cancelled", + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = this._tag; + } +} + +/** Verify the complete binary payload and its signing subkey's primary identity. */ +export const verifyDetachedPgpSignature = async ( + bytes: Uint8Array, + detachedSignature: Uint8Array, + armoredPublicKey: string, + primaryFingerprint: string, + signal?: AbortSignal, +): Promise< + Result< + { + readonly primaryFingerprint: string; + readonly signingFingerprint: string; + }, + DetachedPgpSignatureError + > +> => { + const cancelled = () => + err( + new DetachedPgpSignatureError( + "cancelled", + "Package verification was cancelled.", + ), + ); + const isAborted = () => signal?.aborted === true; + if (isAborted()) return cancelled(); + try { + // Keep the cryptographic implementation out of unrelated CLI cold starts. + const pgp = await import("openpgp"); + const key = await pgp.readKey({ armoredKey: armoredPublicKey }); + if (isAborted()) return cancelled(); + const fingerprint = key.getFingerprint().toUpperCase(); + if (fingerprint !== primaryFingerprint) + return err( + new DetachedPgpSignatureError( + "wrong_publisher", + "The package signing key does not belong to the pinned publisher.", + ), + ); + const signature = await pgp.readSignature({ + binarySignature: detachedSignature, + }); + if (isAborted()) return cancelled(); + if ( + signature.packets.length !== 1 || + signature.packets[0]?.signatureType !== pgp.enums.signature.binary + ) + return err( + new DetachedPgpSignatureError( + "invalid_signature", + "Expected one detached binary package signature.", + ), + ); + const verified = await pgp.verify({ + message: await pgp.createMessage({ binary: bytes }), + signature, + verificationKeys: key, + format: "binary", + }); + const signed = verified.signatures[0]; + if (signed === undefined || verified.signatures.length !== 1) + return err( + new DetachedPgpSignatureError( + "invalid_signature", + "The package has no unique verified signature.", + ), + ); + await signed.verified; + const signingKey = await key.getSigningKey( + signed.keyID, + signature.packets[0].created, + ); + if (isAborted()) return cancelled(); + return ok({ + primaryFingerprint: fingerprint, + signingFingerprint: signingKey.getFingerprint().toUpperCase(), + }); + } catch (cause: unknown) { + if (isAborted()) return cancelled(); + return err( + new DetachedPgpSignatureError( + "invalid_signature", + "The package signature could not be verified.", + { cause }, + ), + ); + } +}; diff --git a/src/hopper/HopperTargetLease.ts b/src/process/HopperLease.ts similarity index 94% rename from src/hopper/HopperTargetLease.ts rename to src/process/HopperLease.ts index 53d09aba14..51f1b66407 100644 --- a/src/hopper/HopperTargetLease.ts +++ b/src/process/HopperLease.ts @@ -17,6 +17,8 @@ interface HopperTargetLeaseOwner { } export interface HopperTargetLease { + /** Bound socket identity for cleanup diagnostics; omission remains unknown. */ + readonly socketPath?: string; readonly release: () => Promise; } @@ -66,6 +68,21 @@ export const acquireLinuxHopperApplicationLease = (input: { }): Promise => acquireLease({ ...input, identity: { application: "linux-hopper-demo" } }); +/** Serialize approved publication/repair of one canonical signed-package entry. */ +export const acquireHopperReleaseCacheLease = async (input: { + readonly cacheDirectory: string; + readonly launcherSha256: string; + readonly runId: string; +}): Promise => + acquireLease({ + runId: input.runId, + identity: { + cacheDirectory: await realpath(input.cacheDirectory), + launcherSha256: input.launcherSha256, + operation: "signed-package-retention", + }, + }); + const acquireLease = async (input: { readonly identity: Readonly>; readonly runId: string; @@ -153,6 +170,7 @@ const tryAcquireLease = async ( return { acquired: true, lease: { + socketPath, release: async () => { if (released) return; // Node removes a bound Unix socket on close. A second unlink here diff --git a/tests/acceptance/providers/hopperStartupRecovery.test.ts b/tests/acceptance/providers/hopperStartupRecovery.test.ts index bd4a2d0f1e..2cf22c9b90 100644 --- a/tests/acceptance/providers/hopperStartupRecovery.test.ts +++ b/tests/acceptance/providers/hopperStartupRecovery.test.ts @@ -10,7 +10,7 @@ import { z } from "zod"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; -import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js"; +import { acquireHopperTargetLease } from "../../../src/process/HopperLease.js"; const statusSchema = z.object({ result: z.object({ open: z.boolean() }) }); const test = workspaceCliTest.skipIf(process.platform !== "darwin"); diff --git a/tests/boundary/cli/doctorHopperDiscovery.test.ts b/tests/boundary/cli/doctorHopperDiscovery.test.ts index 12df465d77..db4e442600 100644 --- a/tests/boundary/cli/doctorHopperDiscovery.test.ts +++ b/tests/boundary/cli/doctorHopperDiscovery.test.ts @@ -45,6 +45,7 @@ cliTest.skipIf(!["darwin", "linux"].includes(process.platform))( ? { PATH: [join(home, "bin"), process.env.PATH ?? ""].join(delimiter), REA_HOPPER_LDD_TEST_MARKER: loaderMarker, + NODE_OPTIONS: `--import=${new URL("../../fixtures/hopperRejectionFeed.mjs", import.meta.url).href}`, } : {}), HOME: home, diff --git a/tests/boundary/cli/hopperLaunchFailureCli.test.ts b/tests/boundary/cli/hopperLaunchFailureCli.test.ts index 9a4f55b7cb..29e132b8a0 100644 --- a/tests/boundary/cli/hopperLaunchFailureCli.test.ts +++ b/tests/boundary/cli/hopperLaunchFailureCli.test.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; -import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js"; +import { acquireHopperTargetLease } from "../../../src/process/HopperLease.js"; const FAILED_LAUNCHER = `#!/bin/sh printf 'Cannot launch Hopper!\\nDocument state is unknown.\\n' >&2 diff --git a/tests/boundary/process/binarySessionCleanupRetry.test.ts b/tests/boundary/process/binarySessionCleanupRetry.test.ts index 5d58f94203..38b3b1ef7e 100644 --- a/tests/boundary/process/binarySessionCleanupRetry.test.ts +++ b/tests/boundary/process/binarySessionCleanupRetry.test.ts @@ -9,7 +9,7 @@ import type { BridgeSession, } from "../../../src/hopper/BridgeLauncher.js"; import { HopperClient } from "../../../src/hopper/HopperClient.js"; -import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js"; +import { acquireHopperTargetLease } from "../../../src/process/HopperLease.js"; import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { hopperFixturePath } from "../providers/hopper/hopperClient.fixture.js"; diff --git a/tests/boundary/providers/hopper/bridgeLauncher.test.ts b/tests/boundary/providers/hopper/bridgeLauncher.test.ts index 542858a0df..155a6414f0 100644 --- a/tests/boundary/providers/hopper/bridgeLauncher.test.ts +++ b/tests/boundary/providers/hopper/bridgeLauncher.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; @@ -17,8 +17,38 @@ import { } from "../../../../src/hopper/BridgeLauncher.js"; import { ProviderProcessSupervisor } from "../../../../src/process/ProviderProcess.js"; +import { err, ok } from "../../../../src/domain/result.js"; + +import { HopperReleaseError } from "../../../../src/application/HopperReleaseFeed.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js"; +import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js"; +import { HopperClient } from "../../../../src/hopper/HopperClient.js"; + +const readyDirectDisplay = async () => + ({ + ok: true, + strategy: "direct", + diagnostic: { + component: "hopper_private_display", + operation: "probe", + status: "ready", + failure_code: null, + reason: "ready", + socket_directory: "/tmp/.X11-unix", + socket_directory_mode: "1777", + mount_read_only: false, + effective_socket_directory_mode: "1777", + effective_mount_read_only: false, + wsl: false, + strategy: "direct", + fallback_reason: null, + xvfb_stderr_bytes: 0, + }, + }) as const; const execFileAsync = promisify(execFile); +const verifiedSha256 = "0".repeat(64); const demoHelperPath = fileURLToPath( new URL("../../../../scripts/hopper-demo-x11.py", import.meta.url), ); @@ -34,13 +64,93 @@ const options = (launcherPath: string): HopperApplicationLauncherOptions => ({ demoHelperPath: "/rea/hopper-demo-x11.py", }); +it("retains cancelled verification cleanup through client shutdown and retries it", async () => { + const directory = await createTestTempDirectory("rea-publisher-cancel-"); + const targetPath = join(directory, "target"); + await writeFile(targetPath, "source-owned target"); + const root = await PrivateRuntimeRoot.create({ + prefix: "rea-publisher-pending-", + }); + const resources = new ArtifactResourceScope(); + let allowCleanup = false; + expect( + await resources.release({ + kind: "private-runtime-root", + resource: root.path, + root: { + path: root.path, + close: async () => { + if (!allowCleanup) + throw new Error("Fixture publisher cleanup denied"); + await root.close(); + }, + }, + }), + ).toMatchObject({ kind: "failed" }); + const controller = new AbortController(); + const verify = vi.fn(async () => { + controller.abort(); + return err( + new HopperReleaseError( + "cleanup_failed", + "Publisher cleanup remains owned", + ), + ); + }); + const launcher = new HopperApplicationLauncher( + { + ...options(process.execPath), + targetPath, + }, + { + verifyLinuxLauncher: verify, + retryPublisherCleanup: () => resources.close(), + selectPrivateDisplay: readyDirectDisplay, + }, + ); + const client = new HopperClient({ launcher, startupTimeoutMs: 5_000 }); + try { + const started = await client.start(controller.signal); + expect(started).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true, cleanupResources: [root.path] }, + }); + if (started.ok) + throw new Error("Expected cancelled startup with pending cleanup"); + expect(projectAnalysisError(started.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { + diagnostics: { primary_error: { code: "cancelled" } }, + resources: [root.path], + }, + }); + expect(await client.start()).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(verify).toHaveBeenCalledOnce(); + expect(await client.close()).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + await access(root.path); + allowCleanup = true; + expect(await client.close()).toEqual({ ok: true, value: null }); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + allowCleanup = true; + await client.close(); + await resources.close(); + } +}); + describe("Hopper bridge launcher selection", () => { it.each([ "/opt/hopper/bin/Hopper", "/usr/local/bin/hopper", "/workspace/bin/hopper-wrapper", ])( - "routes launcher spelling %s through the explicitly selected pinned adapter", + "routes launcher spelling %s through the explicitly selected demo adapter", (path) => { expect(usesLinuxDemo(options(path))).toBe(true); }, @@ -71,6 +181,7 @@ describe("Hopper bridge launcher selection", () => { }, ["--analysis"], "user-mount-namespace", + verifiedSha256, ); expect(launch).toMatchObject({ @@ -90,6 +201,8 @@ describe("Hopper bridge launcher selection", () => { "--mount-private-x11", "--hopper", "/opt/hopper/bin/Hopper", + "--expected-launcher-sha256", + verifiedSha256, "--socket", "/tmp/rea-fixture/bridge.sock", "--", @@ -113,6 +226,7 @@ describe("Hopper bridge launcher selection", () => { }, [], "direct", + verifiedSha256, ), ).toMatchObject({ command: "/usr/bin/python3", @@ -123,6 +237,8 @@ describe("Hopper bridge launcher selection", () => { "direct", "--hopper", "/opt/hopper/bin/Hopper", + "--expected-launcher-sha256", + verifiedSha256, "--socket", "/tmp/rea-fixture/bridge.sock", "--", @@ -131,7 +247,7 @@ describe("Hopper bridge launcher selection", () => { }); }); - it("rejects an unpinned wrapper before executing it", async () => { + it("rejects launcher bytes changed since publisher verification before executing them", async () => { const directory = await createTestTempDirectory("rea-hopper-wrapper-"); const wrapper = join(directory, "hopper-wrapper"); const marker = join(directory, "executed"); @@ -145,6 +261,8 @@ describe("Hopper bridge launcher selection", () => { demoHelperPath, "--hopper", wrapper, + "--expected-launcher-sha256", + verifiedSha256, "--socket", join(directory, "bridge.sock"), "--", @@ -218,26 +336,15 @@ it.skipIf(process.platform !== "linux")( ), }, { - selectPrivateDisplay: async () => ({ - ok: true, - strategy: "direct", - diagnostic: { - component: "hopper_private_display", - operation: "probe", - status: "ready", - failure_code: null, - reason: "ready", - socket_directory: "/tmp/.X11-unix", - socket_directory_mode: "1777", - mount_read_only: false, - effective_socket_directory_mode: "1777", - effective_mount_read_only: false, - wsl: false, - strategy: "direct", - fallback_reason: null, - xvfb_stderr_bytes: 0, - }, - }), + verifyLinuxLauncher: async () => + ok({ + sha256: verifiedSha256, + version: "fixture", + primaryFingerprint: "0".repeat(40), + signingFingerprint: "1".repeat(40), + source: "signed_package_cache", + }), + selectPrivateDisplay: readyDirectDisplay, }, ); const launched = await launcher.launch({ @@ -260,3 +367,43 @@ it.skipIf(process.platform !== "linux")( } }, ); + +it.skipIf(process.platform !== "linux")( + "preserves a publisher rejection through the public error without inventing a helper exit", + async () => { + const directory = await createTestTempDirectory("rea-publisher-rejection-"); + const targetPath = join(directory, "target"); + await writeFile(targetPath, "fixture target"); + const rejected = new HopperReleaseError( + "unsupported_hopper_build", + "Fixture launcher is outside verified releases", + ); + const launcher = new HopperApplicationLauncher( + { ...options("/unused/Hopper"), targetPath }, + { + selectPrivateDisplay: readyDirectDisplay, + verifyLinuxLauncher: async () => err(rejected), + }, + ); + const result = await launcher.launch({ + directory, + socketPath: join(directory, "bridge.sock"), + token: "fixture-token", + runId: randomUUID(), + }); + if (result.ok) throw new Error("Unverified launcher started"); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "provider_unavailable", + details: { + stage: "launch", + launch_rejection: { + boundary: "publisher_verification", + reason: rejected.reason, + }, + }, + }); + expect(projected.details).not.toHaveProperty("exit_code"); + expect(projected.details).not.toHaveProperty("launcher"); + }, +); diff --git a/tests/boundary/providers/hopper/hopperClient.helperFailure.test.ts b/tests/boundary/providers/hopper/hopperClient.helperFailure.test.ts index b236b21ebe..945a5346d8 100644 --- a/tests/boundary/providers/hopper/hopperClient.helperFailure.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.helperFailure.test.ts @@ -15,7 +15,7 @@ import type { } from "../../../../src/hopper/BridgeLauncher.js"; import { HopperApplicationLauncher } from "../../../../src/hopper/BridgeLauncher.js"; import { HopperClient } from "../../../../src/hopper/HopperClient.js"; -import { acquireHopperTargetLease } from "../../../../src/hopper/HopperTargetLease.js"; +import { acquireHopperTargetLease } from "../../../../src/process/HopperLease.js"; import { HopperFixtureLauncher } from "./hopperClient.fixture.js"; // Failure assertions keep the external document unknown. Teardown then publishes diff --git a/tests/boundary/providers/hopper/hopperVerificationCleanup.test.ts b/tests/boundary/providers/hopper/hopperVerificationCleanup.test.ts index 488ac40db5..2678c57026 100644 --- a/tests/boundary/providers/hopper/hopperVerificationCleanup.test.ts +++ b/tests/boundary/providers/hopper/hopperVerificationCleanup.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { expect, it, onTestFinished } from "vitest"; import { snapshotHopperRuntime } from "../../../../scripts/lib/real-hopper-cleanup.mjs"; -import { acquireHopperTargetLease } from "../../../../src/hopper/HopperTargetLease.js"; +import { acquireHopperTargetLease } from "../../../../src/process/HopperLease.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; it.skipIf(process.platform === "win32")( diff --git a/tests/boundary/setup/linuxHopperPublisherVerification.test.ts b/tests/boundary/setup/linuxHopperPublisherVerification.test.ts new file mode 100644 index 0000000000..527a5946a0 --- /dev/null +++ b/tests/boundary/setup/linuxHopperPublisherVerification.test.ts @@ -0,0 +1,765 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { promises as filesystem } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { + access, + copyFile, + cp, + mkdir, + readFile, + readdir, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +import { createMessage, generateKey, readPrivateKey, sign } from "openpgp"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + LinuxHopperPublisher, + linuxHopperReleaseCacheDirectory, + retryLinuxHopperPublisherCleanup, +} from "../../../src/application/LinuxHopperPublisher.js"; +import { + HOPPER_LINUX_PRIMARY_FINGERPRINT, + type LinuxHopperRelease, +} from "../../../src/application/HopperReleaseFeed.js"; +import * as signatures from "../../../src/process/DetachedPgpSignature.js"; +import { downloadHopperPackage } from "../../../src/application/HopperDownload.js"; +import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import * as leases from "../../../src/process/HopperLease.js"; +import * as nativeCommands from "../../../src/process/ExecFileOutput.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { createTestProcessRegistry } from "../../support/process/processFixture.js"; + +const run = promisify(execFile); +const verifySignature = signatures.verifyDetachedPgpSignature; +const sha256 = (bytes: Uint8Array) => + createHash("sha256").update(bytes).digest("hex"); +const linux = it.skipIf(process.platform !== "linux"); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +/** Real native archives and cryptography; substitute only the test publisher identity. */ +const fixture = async (family: "deb" | "arch" = "deb") => { + const directory = await createTestTempDirectory("rea-linux-publisher-"); + const packageRoot = join(directory, "package-root"); + const launcher = join(packageRoot, "opt/hopper/bin/Hopper"); + await mkdir(join(packageRoot, "opt/hopper/bin"), { recursive: true }); + await copyFile("/usr/bin/true", launcher); + const archive = join(directory, "archive"); + if (family === "deb") { + await mkdir(join(packageRoot, "DEBIAN")); + await writeFile( + join(packageRoot, "DEBIAN/control"), + "Package: hopper-fixture\nVersion: 1.0\nArchitecture: amd64\nMaintainer: Fixture\nDescription: Signature boundary fixture\n", + ); + await run("dpkg-deb", ["--build", packageRoot, archive]); + } else await run("tar", ["-cJf", archive, "-C", packageRoot, "opt"]); + const bytes = await readFile(archive); + const generated = await generateKey({ + type: "ecc", + curve: "curve25519Legacy", + userIDs: [{ name: "Fixture publisher" }], + format: "armored", + }); + const key = await readPrivateKey({ armoredKey: generated.privateKey }); + const signature = await sign({ + message: await createMessage({ binary: bytes }), + signingKeys: key, + detached: true, + format: "binary", + }); + const release: LinuxHopperRelease = { + version: "1.0", + filename: `https://www.hopperapp.com:443/downloader/public/Hopper-1.0-Linux-demo.${family === "deb" ? "deb" : "pkg.tar.xz"}`, + file_length: String(bytes.length), + file_hash: createHash("sha1").update(bytes).digest("hex"), + main_exe_hash: sha256(await readFile(launcher)), + signature: Buffer.from(signature).toString("base64"), + public: "1", + beta: "0", + }; + const publisher = new LinuxHopperPublisher( + { ...process.env, XDG_CACHE_HOME: join(directory, "cache") }, + async () => family, + ); + const cache = join(publisher.cacheRoot, release.main_exe_hash); + return { + publisher, + archive, + launcher, + release, + cache, + publicKey: generated.publicKey, + fingerprint: key.getFingerprint().toUpperCase(), + }; +}; + +const admitFixturePublisher = ( + fingerprint: string, + module: typeof signatures = signatures, +) => + vi + .spyOn(module, "verifyDetachedPgpSignature") + .mockImplementation((bytes, signature, key, primary, signal) => { + expect(primary).toBe(HOPPER_LINUX_PRIMARY_FINGERPRINT); + return verifySignature(bytes, signature, key, fingerprint, signal); + }); + +it.each([undefined, "", "/selected/cache", "relative/cache"])( + "resolves the selected XDG cache setting %j once for every consumer", + (setting) => { + const selected = linuxHopperReleaseCacheDirectory({ + HOME: "/caller/home", + ...(setting === undefined ? {} : { XDG_CACHE_HOME: setting }), + }); + if (setting === "relative/cache") + expect(selected).toMatchObject({ ok: false }); + else + expect(selected).toEqual({ + ok: true, + value: join(setting || "/caller/home/.cache", "rea/hopper-releases"), + }); + }, +); + +describe("Linux signed package retention boundary", () => { + linux( + "rejects a cryptographically valid package signed by another publisher", + async () => { + const f = await fixture(); + try { + expect( + await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ), + ).toMatchObject({ + ok: false, + error: { reason: "publisher_verification_failed" }, + }); + await expect(access(f.publisher.cacheRoot)).rejects.toMatchObject({ + code: "ENOENT", + }); + } finally { + await f.publisher.close(); + } + }, + ); + + linux.each(["deb", "arch"] as const)( + "authenticates a real %s payload and verifies a retained install without network", + async (family) => { + const f = await fixture(family); + admitFixturePublisher(f.fingerprint); + const network = vi.fn(() => { + throw new Error("Offline"); + }); + vi.stubGlobal("fetch", network); + try { + const verified = await f.publisher.verifyArchive( + family, + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + await f.publisher.close(); + // A new instance alone still shares process-local cryptographic proofs. + // Reload the owner and assert actual PGP and native payload inspection. + vi.resetModules(); + const coldSignatures = + await import("../../../src/process/DetachedPgpSignature.js"); + const signature = admitFixturePublisher(f.fingerprint, coldSignatures); + const coldCommands = + await import("../../../src/process/ExecFileOutput.js"); + const extraction = vi.spyOn(coldCommands, "execFileOutput"); + const { LinuxHopperPublisher: ColdPublisher } = + await import("../../../src/application/LinuxHopperPublisher.js"); + const fresh = new ColdPublisher( + f.publisher.environment, + async () => family, + ); + try { + expect(await fresh.inspectLauncher(f.launcher)).toMatchObject({ + ok: true, + value: { + sha256: f.release.main_exe_hash, + primaryFingerprint: f.fingerprint, + source: "signed_package_cache", + }, + }); + expect(network).not.toHaveBeenCalled(); + expect(signature).toHaveBeenCalledOnce(); + expect(extraction).toHaveBeenCalledWith( + family === "deb" ? "dpkg-deb" : "tar", + expect.any(Array), + expect.any(Object), + ); + } finally { + await fresh.close(); + } + } finally { + await f.publisher.close(); + } + }, + ); +}); + +linux.each([ + "archive", + "metadata", + "missing metadata", + "missing key", + "malformed key", + "missing archive", + "malformed JSON", + "empty directory", + "symlink archive", +])("fails closed on retained %s tampering", async (kind) => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + try { + const verified = await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + const retained = await f.publisher.retainVerifiedArchive( + verified.value, + f.archive, + ); + if (!retained.ok) throw retained.error; + if (kind === "archive") { + const tampered = Buffer.from("tampered"); + await writeFile(join(f.cache, "archive"), tampered); + const metadata = JSON.parse( + await readFile(join(f.cache, "release.json"), "utf8"), + ); + metadata.release.file_length = String(tampered.length); + metadata.release.file_hash = createHash("sha1") + .update(tampered) + .digest("hex"); + await writeFile(join(f.cache, "release.json"), JSON.stringify(metadata)); + } + if (kind === "metadata") { + const metadata = JSON.parse( + await readFile(join(f.cache, "release.json"), "utf8"), + ); + metadata.release.main_exe_hash = "a".repeat(64); + await writeFile(join(f.cache, "release.json"), JSON.stringify(metadata)); + } + if (kind === "missing metadata") await rm(join(f.cache, "release.json")); + if (kind === "missing key") await rm(join(f.cache, "public-key.asc")); + if (kind === "malformed key") + await writeFile(join(f.cache, "public-key.asc"), Buffer.from([0xff])); + if (kind === "missing archive") await rm(join(f.cache, "archive")); + if (kind === "malformed JSON") + await writeFile(join(f.cache, "release.json"), "{broken JSON"); + if (kind === "empty directory") + await Promise.all( + ["release.json", "public-key.asc", "archive"].map((name) => + rm(join(f.cache, name)), + ), + ); + if (kind === "symlink archive") { + await rm(join(f.cache, "archive")); + await symlink(f.archive, join(f.cache, "archive")); + } + const network = vi.fn(() => { + throw new Error("Must preserve the cache failure"); + }); + vi.stubGlobal("fetch", network); + expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({ + ok: false, + }); + expect(network).not.toHaveBeenCalled(); + // An explicitly approved fresh package can repair this entry while + // preserving the original evidence and keeping ordinary reads strict. + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + const backups = (await readdir(f.publisher.cacheRoot)).filter((name) => + name.startsWith(`${f.release.main_exe_hash}.backup-`), + ); + expect(backups).toHaveLength(1); + if (kind === "missing metadata" || kind === "empty directory") + await expect( + access(join(f.publisher.cacheRoot, backups[0]!, "release.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + else + expect( + await readFile( + join(f.publisher.cacheRoot, backups[0]!, "release.json"), + "utf8", + ), + ).toBeTruthy(); + expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({ + ok: true, + }); + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + expect( + (await readdir(f.publisher.cacheRoot)).filter((name) => + name.includes(".backup-"), + ), + ).toEqual(backups); + expect(network).not.toHaveBeenCalled(); + } finally { + await f.publisher.close(); + } +}); + +linux.each([false, true])( + "serializes real cross-process repair and cancellation: %s", + async (cancel) => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + const processes = createTestProcessRegistry(); + const controller = new AbortController(); + let retention: + | ReturnType + | undefined; + try { + const verified = await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + const staged = join(f.publisher.cacheRoot, "authenticated-fixture-stage"); + await cp(f.cache, staged, { recursive: true }); + const broken = "{interrupted cache record"; + await writeFile(join(f.cache, "release.json"), broken); + const child = processes.spawn(process.execPath, [ + fileURLToPath( + new URL("../../fixtures/hopperCachePublisher.mjs", import.meta.url), + ), + f.publisher.cacheRoot, + f.release.main_exe_hash, + staged, + ]); + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const exited = once(child, "close"); + const ready = await Promise.race([ + once(child.stdout, "data").then(([bytes]) => { + if (!Buffer.isBuffer(bytes)) + throw new Error("Expected fixture readiness bytes"); + return JSON.parse(bytes.toString()); + }), + exited.then(([code]) => { + throw new Error( + `Fixture exited before acquiring its lease: ${String(code)} ${stderr}`, + ); + }), + ]); + expect(ready).toEqual({ held: true }); + let notifyBusy: (() => void) | undefined; + const busy = new Promise((resolve) => { + notifyBusy = resolve; + }); + const acquire = leases.acquireHopperReleaseCacheLease; + vi.spyOn(leases, "acquireHopperReleaseCacheLease").mockImplementation( + async (input) => { + const observed = await acquire(input); + if (!observed.acquired) notifyBusy?.(); + return observed; + }, + ); + retention = f.publisher.retainVerifiedArchive( + verified.value, + f.archive, + controller.signal, + ); + await Promise.race([ + busy, + retention.then(() => { + throw new Error("Retention bypassed the other process's cache lease"); + }), + ]); + expect(await readFile(join(f.cache, "release.json"), "utf8")).toBe( + broken, + ); + expect( + (await readdir(f.publisher.cacheRoot)).some((name) => + name.includes(".backup-"), + ), + ).toBe(false); + if (cancel) { + controller.abort(); + expect(await retention).toMatchObject({ + ok: false, + error: { reason: "cancelled" }, + }); + child.stdin.end("release\n"); + } else { + child.stdin.end("publish\n"); + expect(await retention).toEqual({ ok: true, value: undefined }); + expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({ + ok: true, + }); + expect( + (await readdir(f.publisher.cacheRoot)).filter((name) => + name.includes(".backup-"), + ), + ).toHaveLength(1); + } + expect(await exited).toEqual([0, null]); + } finally { + controller.abort(); + await processes.shutdown(); + await retention; + await f.publisher.close(); + } + }, +); + +linux("restores old evidence when replacement publication fails", async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + try { + const verified = await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + const broken = "{interrupted cache metadata"; + await writeFile(join(f.cache, "release.json"), broken); + const rename = filesystem.rename; + const denied = Object.assign(new Error("Fixture publication failure"), { + code: "EIO", + }); + let backup: string | undefined; + const publication = vi + .spyOn(filesystem, "rename") + .mockImplementation(async (from, to) => { + if (backup !== undefined && to === f.cache && from !== backup) + throw denied; + await rename(from, to); + if (from === f.cache && typeof to === "string") backup = to; + }); + syncBuiltinESMExports(); + try { + const result = await f.publisher.retainVerifiedArchive( + verified.value, + f.archive, + ); + expect(result).toMatchObject({ + ok: false, + error: { + reason: "verification_unavailable", + message: expect.stringContaining(f.cache), + }, + }); + if (result.ok) throw new Error("Expected publication failure"); + expect(result.error.cause).toBeInstanceOf(AggregateError); + if (result.error.cause instanceof AggregateError) + expect(result.error.cause.errors).toContain(denied); + expect(await readFile(join(f.cache, "release.json"), "utf8")).toBe( + broken, + ); + expect( + (await readdir(f.publisher.cacheRoot)).some((name) => + name.includes(".backup-"), + ), + ).toBe(false); + } finally { + publication.mockRestore(); + syncBuiltinESMExports(); + } + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({ + ok: true, + }); + } finally { + await f.publisher.close(); + } +}); + +linux( + "preserves existing evidence when native cache inspection is unavailable", + async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + try { + const verified = await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + await writeFile(join(f.cache, "public-key.asc"), f.publicKey + "\n"); + const metadata = await readFile(join(f.cache, "release.json")); + const unavailable = vi + .spyOn(nativeCommands, "execFileOutput") + .mockRejectedValue(new Error("Fixture native inspection unavailable")); + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toMatchObject({ + ok: false, + error: { reason: "verification_unavailable" }, + }); + expect(await readFile(join(f.cache, "release.json"))).toEqual(metadata); + expect( + (await readdir(f.publisher.cacheRoot)).some((name) => + name.includes(".backup-"), + ), + ).toBe(false); + unavailable.mockRestore(); + expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({ + ok: true, + }); + } finally { + await f.publisher.close(); + } + }, +); + +linux( + "rejects a feed digest not represented in the signed payload", + async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + try { + expect( + await f.publisher.verifyArchive( + "deb", + { ...f.release, main_exe_hash: "a".repeat(64) }, + f.archive, + undefined, + f.publicKey, + ), + ).toMatchObject({ + ok: false, + error: { + reason: "integrity", + message: expect.stringContaining(f.release.main_exe_hash), + }, + }); + expect( + await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + AbortSignal.abort(), + f.publicKey, + ), + ).toMatchObject({ ok: false, error: { reason: "cancelled" } }); + } finally { + await f.publisher.close(); + } + }, +); + +linux( + "retains a failed publication lease until its real socket owner closes", + async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + const acquire = leases.acquireHopperReleaseCacheLease; + let allowCleanup = false; + vi.spyOn(leases, "acquireHopperReleaseCacheLease").mockImplementation( + async (input) => { + const acquired = await acquire(input); + if (!acquired.acquired) return acquired; + return { + acquired: true as const, + lease: { + ...acquired.lease, + release: async () => { + if (!allowCleanup) + throw new Error("Fixture lease close unavailable"); + await acquired.lease.release(); + }, + }, + }; + }, + ); + try { + const verified = await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toMatchObject({ ok: false, error: { reason: "cleanup_failed" } }); + const input = { + cacheDirectory: f.publisher.cacheRoot, + launcherSha256: f.release.main_exe_hash, + runId: "fixture-competing-writer", + }; + expect(await acquire(input)).toMatchObject({ acquired: false }); + await expect(f.publisher.close()).rejects.toMatchObject({ + cleanup: { resources: [expect.stringContaining(".sock")] }, + }); + allowCleanup = true; + await f.publisher.close(); + const recovered = await acquire(input); + if (!recovered.acquired) + throw new Error("Expected the released real cache lease"); + await recovered.lease.release(); + } finally { + allowCleanup = true; + await f.publisher.close(); + } + }, +); + +it("bounds a stalled metadata fetch and preserves caller cancellation", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + (_url, options: RequestInit) => + new Promise((_resolve, reject) => { + const signal = options.signal; + signal?.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + signal?.throwIfAborted(); + }), + ), + ); + await expect( + downloadHopperPackage("https://www.hopperapp.com/fixture", { + timeoutMs: 10, + }), + ).rejects.toMatchObject({ name: "TimeoutError" }); + const controller = new AbortController(); + const reason = new Error("Caller cancelled metadata inspection"); + const pending = downloadHopperPackage("https://www.hopperapp.com/fixture", { + signal: controller.signal, + }); + controller.abort(reason); + await expect(pending).rejects.toBe(reason); +}); +it("cancels an oversized chunked metadata response before retaining it", async () => { + const cancelled = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(4)); + }, + cancel: cancelled, + }), + ), + ), + ); + expect( + await downloadHopperPackage("https://www.hopperapp.com/fixture", { + maxBytes: 3, + }), + ).toEqual({ ok: false, bytes: new Uint8Array() }); + expect(cancelled).toHaveBeenCalledOnce(); +}); + +linux("retains and retries failed verification cleanup", async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + const cleanup = vi + .spyOn(PrivateRuntimeRoot.prototype, "close") + .mockRejectedValueOnce(new Error("Fixture cleanup unavailable")); + try { + expect( + await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ), + ).toMatchObject({ ok: false, error: { reason: "cleanup_failed" } }); + expect(cleanup).toHaveBeenCalledOnce(); + await f.publisher.close(); + expect(cleanup).toHaveBeenCalledTimes(2); + } finally { + await f.publisher.close(); + } +}); + +linux( + "preserves cleanup ownership when verification is cancelled", + async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + const controller = new AbortController(); + const cleanup = vi + .spyOn(PrivateRuntimeRoot.prototype, "close") + .mockImplementation(async () => { + controller.abort(); + throw new Error("Fixture cleanup unavailable after cancellation"); + }); + try { + expect( + await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + controller.signal, + f.publicKey, + ), + ).toMatchObject({ + ok: false, + error: { reason: "cleanup_failed" }, + }); + await expect(retryLinuxHopperPublisherCleanup()).rejects.toMatchObject({ + cleanup: { + resources: [expect.stringContaining("rea-hopper-publisher-")], + }, + }); + } finally { + cleanup.mockRestore(); + await f.publisher.close(); + await retryLinuxHopperPublisherCleanup(); + } + }, +); diff --git a/tests/evaluation/package/packagePlatformDiagnostics.test.ts b/tests/evaluation/package/packagePlatformDiagnostics.test.ts index d4f88b875b..1f4ea4644f 100644 --- a/tests/evaluation/package/packagePlatformDiagnostics.test.ts +++ b/tests/evaluation/package/packagePlatformDiagnostics.test.ts @@ -72,9 +72,54 @@ describe("packaged Linux failure verification", () => { }), ), ).not.toThrow(); + expect(() => + assertLinuxPackageProviderFailure( + execution({ + code: "provider_unavailable", + details: { + stage: "launch", + launch_rejection: { + boundary: "publisher_verification", + reason: "unsupported_hopper_build", + }, + }, + }), + ), + ).not.toThrow(); }); it.each([ + execution({ + code: "provider_unavailable", + details: { + stage: "launch", + launch_rejection: { + boundary: "publisher_verification", + reason: "download", + }, + }, + }), + execution({ + code: "provider_unavailable", + details: { + stage: "analysis", + launch_rejection: { + boundary: "publisher_verification", + reason: "unsupported_hopper_build", + }, + }, + }), + execution({ + code: "provider_unavailable", + details: { + stage: "launch", + launch_rejection: { + boundary: "publisher_verification", + reason: "unsupported_hopper_build", + }, + exit_code: 72, + }, + }), execution(missingDisplay, 0), execution({ code: "provider_unavailable" }), execution({ diff --git a/tests/fixtures/hopperCachePublisher.mjs b/tests/fixtures/hopperCachePublisher.mjs new file mode 100644 index 0000000000..3a49ed3f9c --- /dev/null +++ b/tests/fixtures/hopperCachePublisher.mjs @@ -0,0 +1,31 @@ +import { once } from "node:events"; +import { randomUUID } from "node:crypto"; +import { rename } from "node:fs/promises"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; + +import { acquireHopperReleaseCacheLease } from "../../dist/process/HopperLease.js"; + +// The parent prepares authenticated fixture evidence. Coordinate a real second +// process publishing it while another publisher is waiting for this lease. +const [cacheDirectory, launcherSha256, staged] = process.argv.slice(2); +const commands = createInterface({ input: process.stdin }); +const command = once(commands, "line"); +const acquired = await acquireHopperReleaseCacheLease({ + cacheDirectory, + launcherSha256, + runId: randomUUID(), +}); +if (!acquired.acquired) throw new Error("Fixture cache lease was already held"); +try { + console.log(JSON.stringify({ held: true })); + const [action] = await command; + if (action === "publish") { + const target = join(cacheDirectory, launcherSha256); + await rename(target, `${target}.backup-${randomUUID()}`); + await rename(staged, target); + } else if (action !== "release") throw new Error("Unknown fixture action"); +} finally { + await acquired.lease.release(); + commands.close(); +} diff --git a/tests/fixtures/hopperOwnerLifetime.py b/tests/fixtures/hopperOwnerLifetime.py index 741bffbd9d..8fcbf9893e 100644 --- a/tests/fixtures/hopperOwnerLifetime.py +++ b/tests/fixtures/hopperOwnerLifetime.py @@ -64,7 +64,7 @@ if len(sys.argv) > 3: ) return child, ":123", directory / "Xauthority", helper.CapturedStderr(child.stderr) - helper.digest = lambda _: next(iter(helper.SUPPORTED_HOPPER_SHA256)) + helper.digest = lambda _: "0" * 64 helper.start_xvfb = start_xvfb helper.ctypes.CDLL = lambda _: X11() helper.configure_x11 = lambda *_: None @@ -75,6 +75,7 @@ if len(sys.argv) > 3: raise SystemExit(helper.run_launch(argparse.Namespace( owner_stdin=True, hopper=sys.executable, + expected_launcher_sha256="0" * 64, socket=str(socket), command=child_command(directory / "hopper.pid"), ))) diff --git a/tests/fixtures/hopperRejectionFeed.mjs b/tests/fixtures/hopperRejectionFeed.mjs new file mode 100644 index 0000000000..04b2b187bb --- /dev/null +++ b/tests/fixtures/hopperRejectionFeed.mjs @@ -0,0 +1,36 @@ +/** Deterministic feed boundary for the package verifier's deliberately foreign launcher. + * This fixture never authorizes a Hopper build or simulates successful cryptography. + */ +import { HOPPER_RELEASES_URL } from "../../dist/application/HopperReleaseFeed.js"; + +const systemFetch = globalThis.fetch; +const row = (suffix) => ({ + version: "fixture-current", + filename: `https://www.hopperapp.com:443/downloader/public/Hopper-fixture-current-Linux-demo.${suffix}`, + file_length: "1", + file_hash: "0".repeat(40), + main_exe_hash: "0".repeat(64), + signature: "AQ==", + public: "1", + beta: "0", +}); + +globalThis.fetch = (input, options) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + if (url !== HOPPER_RELEASES_URL) return systemFetch(input, options); + options?.signal?.throwIfAborted(); + return Promise.resolve( + new Response( + JSON.stringify({ + "Ubuntu / Mint": row("deb"), + Fedora: row("rpm"), + Arch: row("pkg.tar.xz"), + }), + ), + ); +}; diff --git a/tests/process-global/hopperLeaseDescriptorPermissions.test.ts b/tests/process-global/hopperLeaseDescriptorPermissions.test.ts index 174097abe3..92a6e19e63 100644 --- a/tests/process-global/hopperLeaseDescriptorPermissions.test.ts +++ b/tests/process-global/hopperLeaseDescriptorPermissions.test.ts @@ -32,7 +32,7 @@ vi.mock("node:fs/promises", async (importOriginal) => { }; }); -import { acquireLinuxHopperApplicationLease } from "../../src/hopper/HopperTargetLease.js"; +import { acquireLinuxHopperApplicationLease } from "../../src/process/HopperLease.js"; afterEach(() => { replacement.selected = "";