diff --git a/docs/installation.md b/docs/installation.md index 13ce21d257..8535e2cec1 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -681,6 +681,12 @@ 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. +On apt systems, Hopper's runtime dependencies are installed with +`--no-upgrade` in their own transaction, so setup no longer names an +already-installed dependency as an upgrade target. apt can still upgrade a +package that the Hopper package or the Python library repair requires at a +newer version. Arch's `pacman -S --needed` still updates an outdated +dependency. 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 diff --git a/src/application/LinuxHopper.test.ts b/src/application/LinuxHopper.test.ts index b43d048c9c..b9a1f80624 100644 --- a/src/application/LinuxHopper.test.ts +++ b/src/application/LinuxHopper.test.ts @@ -277,6 +277,32 @@ describe("Linux Hopper installation", () => { ); }, ); + + it.each([true, false])( + "keeps installed apt dependencies at their versions while Hopper may upgrade (root: %s)", + (isRoot) => { + const prefix = isRoot ? [] : ["pkexec"]; + expect( + linuxPackageManagerCommands("deb", "/tmp/Hopper package", isRoot).map( + ({ executable, args }) => [executable, ...args], + ), + ).toEqual([ + [ + ...prefix, + "apt-get", + "install", + "-y", + "--no-upgrade", + "xvfb", + "xauth", + "python3", + "libx11-6", + "libxtst6", + ], + [...prefix, "apt-get", "install", "-y", "/tmp/Hopper package"], + ]); + }, + ); }); describe("Linux Hopper package installation", () => { diff --git a/src/application/LinuxHopper.ts b/src/application/LinuxHopper.ts index a401a4c13a..871b5be48f 100644 --- a/src/application/LinuxHopper.ts +++ b/src/application/LinuxHopper.ts @@ -818,7 +818,12 @@ export const linuxPackageManagerCommands = ( : ["xorg-server-xvfb", "xorg-xauth", "python", "libx11", "libxtst"]; const commands = family === "deb" - ? [["apt-get", "install", "-y", archive, ...runtimeDependencies]] + ? [ + // apt-get install upgrades listed packages that are already + // installed, so dependencies are not named as upgrade targets. + ["apt-get", "install", "-y", "--no-upgrade", ...runtimeDependencies], + ["apt-get", "install", "-y", archive], + ] : family === "rpm" ? [["dnf", "install", "-y", archive, ...runtimeDependencies]] : [ diff --git a/src/application/LinuxHopperPackageRuntime.test.ts b/src/application/LinuxHopperPackageRuntime.test.ts index 359c51f81a..b3cda7929f 100644 --- a/src/application/LinuxHopperPackageRuntime.test.ts +++ b/src/application/LinuxHopperPackageRuntime.test.ts @@ -6,9 +6,17 @@ import { err, ok } from "../domain/result.js"; import * as pythonRuntime from "./LinuxHopperPython.js"; import { linuxHopperInstallDisclosure, + linuxPackageManagerCommands, systemLinuxHopperInstallHost, } from "./LinuxHopper.js"; +/** Package transactions that install Hopper before any runtime library repair. */ +const debCommands = linuxPackageManagerCommands( + "deb", + "/tmp/verified-hopper.deb", + false, +).length; + const missingPython = { interpreter: "/usr/bin/python3", version: [3, 14], @@ -37,13 +45,13 @@ describe("Linux Hopper package runtime", () => { { name: "installs and verifies the matching future Python library", inspections: [ok(missingPython), ok(readyPython)], - packageCalls: 2, + packageCalls: debCommands + 1, status: "installed", }, { name: "skips the extra APT command for an already loadable library", inspections: [ok(readyPython)], - packageCalls: 1, + packageCalls: debCommands, status: "installed", }, { @@ -54,13 +62,13 @@ describe("Linux Hopper package runtime", () => { library: { status: "unknown", reason: "No INSTSONAME" }, } satisfies pythonRuntime.LinuxHopperPythonRuntime), ], - packageCalls: 1, + packageCalls: debCommands, status: "runtime_unavailable", }, { name: "rejects APT success when the library still cannot load", inspections: [ok(missingPython), ok(missingPython)], - packageCalls: 2, + packageCalls: debCommands + 1, status: "runtime_unavailable", }, { @@ -73,7 +81,7 @@ describe("Linux Hopper package runtime", () => { ), ), ], - packageCalls: 1, + packageCalls: debCommands, status: "runtime_unavailable", }, ])("$name", async ({ inspections, packageCalls, status }) => { @@ -97,9 +105,9 @@ describe("Linux Hopper package runtime", () => { ).toMatchObject({ status }); expect(command).toHaveBeenCalledTimes(packageCalls); expect(inspect).toHaveBeenCalledTimes(inspections.length); - if (packageCalls === 2) { + if (packageCalls > debCommands) { const isRoot = process.getuid?.() === 0; - expect(command.mock.calls[1]?.slice(0, 2)).toEqual([ + expect(command.mock.calls[debCommands]?.slice(0, 2)).toEqual([ isRoot ? "apt-get" : "pkexec", [...(isRoot ? [] : ["apt-get"]), "install", "-y", "libpython3.14"], ]); @@ -128,10 +136,10 @@ describe("Linux Hopper package runtime", () => { stderr: "original package error", outputTruncated: true, }; - const command = vi - .spyOn(boundedCommands, "execFileBoundedOutput") - .mockResolvedValueOnce(commandSuccess) - .mockResolvedValueOnce(failed); + const command = vi.spyOn(boundedCommands, "execFileBoundedOutput"); + for (let index = 0; index < debCommands; index++) + command.mockResolvedValueOnce(commandSuccess); + command.mockResolvedValueOnce(failed); const inspect = vi .spyOn(pythonRuntime, "inspectLinuxHopperPythonRuntime") .mockResolvedValue(ok(missingPython)); @@ -139,7 +147,7 @@ describe("Linux Hopper package runtime", () => { expect( await host.installPackage("deb", "/tmp/verified-hopper.deb"), ).toMatchObject({ status: "failed", failure: failed }); - expect(command).toHaveBeenCalledTimes(2); + expect(command).toHaveBeenCalledTimes(debCommands + 1); expect(inspect).toHaveBeenCalledTimes(1); } finally { try { @@ -184,7 +192,7 @@ describe("Linux Hopper package mapping", () => { message: expect.stringContaining(path), }, }); - expect(command).toHaveBeenCalledTimes(1); + expect(command).toHaveBeenCalledTimes(debCommands); expect(inspect).toHaveBeenCalledTimes(1); } finally { try { @@ -197,13 +205,62 @@ describe("Linux Hopper package mapping", () => { }); }); +describe("Linux Hopper package transaction failures", () => { + it.each(Array.from({ length: debCommands }, (_, index) => index + 1))( + "stops after failed install transaction %i without inspecting Python", + async (failing) => { + const directory = await createTestTempDirectory( + "rea-linux-install-failure-", + ); + const host = systemLinuxHopperInstallHost( + { XDG_CACHE_HOME: directory }, + async () => undefined, + ); + const failed = { + exitCode: 100, + stdout: "", + stderr: "transaction " + String(failing) + " failed", + outputTruncated: false, + }; + let calls = 0; + const command = vi + .spyOn(boundedCommands, "execFileBoundedOutput") + .mockImplementation(async () => + ++calls === failing ? failed : commandSuccess, + ); + const inspect = vi.spyOn( + pythonRuntime, + "inspectLinuxHopperPythonRuntime", + ); + try { + expect( + await host.installPackage("deb", "/tmp/verified-hopper.deb"), + ).toMatchObject({ + status: "failed", + failure: { exitCode: 100, stderr: failed.stderr }, + }); + expect(command).toHaveBeenCalledTimes(failing); + expect(inspect).not.toHaveBeenCalled(); + } finally { + try { + await host.close?.(); + } finally { + inspect.mockRestore(); + command.mockRestore(); + } + } + }, + ); +}); + describe("Linux Hopper package cancellation and disclosure", () => { it.each([ { phase: "before", commands: 0, inspections: 0 }, { phase: "first transaction", commands: 1, inspections: 0 }, - { phase: "query", commands: 1, inspections: 1 }, - { phase: "runtime transaction", commands: 2, inspections: 1 }, - { phase: "verification", commands: 2, inspections: 2 }, + { phase: "archive transaction", commands: debCommands, inspections: 0 }, + { phase: "query", commands: debCommands, inspections: 1 }, + { phase: "runtime transaction", commands: debCommands + 1, inspections: 1 }, + { phase: "verification", commands: debCommands + 1, inspections: 2 }, ])( "honors cancellation at $phase without starting the next stage", async ({ phase, commands, inspections }) => { @@ -224,7 +281,9 @@ describe("Linux Hopper package cancellation and disclosure", () => { commandCount++; if ( (phase === "first transaction" && commandCount === 1) || - (phase === "runtime transaction" && commandCount === 2) + (phase === "archive transaction" && commandCount === debCommands) || + (phase === "runtime transaction" && + commandCount === debCommands + 1) ) controller.abort(cause); return commandSuccess; @@ -267,8 +326,8 @@ describe("Linux Hopper package cancellation and disclosure", () => { it("discloses the conditional privileged library install without returning probe code", () => { const disclosure = linuxHopperInstallDisclosure("deb", false); - expect(disclosure.commands).toHaveLength(2); - expect(disclosure.commands[1]).toBe( + expect(disclosure.commands).toHaveLength(debCommands + 1); + expect(disclosure.commands[debCommands]).toBe( 'pkexec apt-get install -y ""', ); expect(linuxHopperInstallDisclosure("rpm", false).commands).toHaveLength(1);