mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(hopper): keep installed apt dependencies at their versions during Linux setup (#6974)
* fix(hopper): keep installed apt dependencies at their versions during Linux setup apt-get install upgrades every listed package that is already installed, so Linux setup could upgrade the system python3 and X11 libraries while installing Hopper. Runtime dependencies are now installed with --no-upgrade in a separate transaction before the Hopper archive, which may still upgrade. The setup approval plan lists both commands. * fix(hopper): narrow the apt upgrade claim and cover split transaction failures * fix(evidence): preserve JSON ownership through metadata updates --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
1 parent
ce4e48ccae
commit
a5bfd04401
4 files changed
+116
-20
No files matched your search
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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]]
|
||||
: [
|
||||
|
||||
@@ -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 "<libpython matching /usr/bin/python3, if not loadable>"',
|
||||
);
|
||||
expect(linuxHopperInstallDisclosure("rpm", false).commands).toHaveLength(1);
|
||||
|
||||
Reference in new issue
Block a user