fix(hopper): own Mach-O preparation through cleanup retry

Preserve exact selected-image identity and descriptor ownership across preparation, final cleanup, and retries. Keep physical failures and failed cleanup leases observable through provider and launcher boundaries.

Closes #5688
This commit is contained in:
morluto committed 2026-10-21 06:12:09 +08:00
1 parent 7dd6941393
commit b231c28cf6
15 files changed
+2341 -329

No files matched your search

+31 -5
View File
@@ -6,6 +6,8 @@ import { fileURLToPath } from "node:url";
import { firstProcedureAddress } from "../dist/application/RealHopperAssertions.js";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js";
import { resolveHopperAnalysisProfile } from "../dist/hopper/HopperAnalysisProfile.js";
import { HopperApplicationLauncher } from "../dist/hopper/BridgeLauncher.js";
import { HopperClient } from "../dist/hopper/HopperClient.js";
@@ -26,6 +28,16 @@ const root = await realpath(
await mkdtemp(join(tmpdir(), "rea-hopper-deadlines-")),
);
const target = join(root, "deadline-fixture");
const profileResources = new ArtifactResourceScope();
const profileResourceScope = {
retain: (owner, resource) =>
profileResources.retain({
kind: "lease",
resource,
lease: AnalysisCleanupLease.forCloseOwner(owner, resource),
}),
run: (operation) => profileResources.run(operation),
};
let client;
let primaryFailure;
try {
@@ -39,6 +51,7 @@ try {
launcherPath,
loaderArgsOverride: [],
provider: HOPPER_PROVIDER_IDENTITY,
resourceScope: profileResourceScope,
});
if (!resolved.ok) throw resolved.error;
const profile = resolved.value.profile;
@@ -138,15 +151,28 @@ try {
primaryFailure = cause;
throw cause;
} finally {
let cleanupFailure;
if (client !== undefined) {
const closed = await client.close();
if (!closed.ok)
throw primaryFailure === undefined
? closed.error
if (!closed.ok) cleanupFailure = closed.error;
}
try {
await profileResources.close();
} catch (cause) {
cleanupFailure =
cleanupFailure === undefined
? cause
: new AggregateError(
[primaryFailure, closed.error],
"Hopper verification and cleanup failed",
[cleanupFailure, cause],
"Hopper profile and client cleanup failed",
);
}
await rm(root, { recursive: true, force: true });
if (cleanupFailure !== undefined)
throw primaryFailure === undefined
? cleanupFailure
: new AggregateError(
[primaryFailure, cleanupFailure],
"Hopper verification and cleanup failed",
);
}
+13 -1
View File
@@ -7,7 +7,11 @@ import {
ArtifactResourceScope,
type ArtifactResourceOwner,
} from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import type { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import {
ArtifactReaderFailure,
artifactPrimaryCleanupLeases,
} from "../artifacts/ArtifactReader.js";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@@ -1061,12 +1065,17 @@ export class LinuxHopperPublisher {
export const retryLinuxHopperPublisherCleanup = async (): Promise<void> => {
const causes: unknown[] = [];
const observations = [];
const cleanupLeases = new Set<AnalysisCleanupLease>();
let cleanupLeaseAuthorityKnown = true;
for (const scope of pendingCleanup) {
try {
await scope.close();
pendingCleanup.delete(scope);
} catch (cause: unknown) {
causes.push(cause);
const leases = artifactPrimaryCleanupLeases(cause);
if (leases === undefined) cleanupLeaseAuthorityKnown = false;
else for (const lease of leases) cleanupLeases.add(lease);
observations.push(
ArtifactReaderFailure.cleanupObservation(
cause,
@@ -1088,6 +1097,9 @@ export const retryLinuxHopperPublisherCleanup = async (): Promise<void> => {
{
cause: new AggregateError(causes, "Hopper publisher cleanup failures"),
cleanup,
cleanupLeases: cleanupLeaseAuthorityKnown
? [...cleanupLeases]
: undefined,
},
);
}
+79
View File
@@ -0,0 +1,79 @@
import { z } from "zod";
import type { AnalysisProfileCommitment } from "../analysisProfile.js";
import type { BinaryArchitecture } from "../binaryTargetTypes.js";
/** Provider-neutral observed identity of one Mach-O image selected from a source. */
export const machoTargetSelectionSchema = z
.strictObject({
kind: z.literal("macho-selected-image-v1"),
container: z.enum(["thin", "fat"]),
offset: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
byte_length: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
cpu_type: z.number().int().nonnegative().max(0xffff_ffff),
/** Keep the complete raw subtype, including capability bits. */
cpu_subtype: z.number().int().nonnegative().max(0xffff_ffff),
container_table_sha256: z
.string()
.regex(/^[a-f0-9]{64}$/u)
.nullable(),
})
.superRefine((selection, context) => {
if (
(selection.container === "thin" &&
(selection.offset !== 0 ||
selection.container_table_sha256 !== null)) ||
(selection.container === "fat" &&
selection.container_table_sha256 === null)
)
context.addIssue({
code: "custom",
message: "Invalid Mach-O container identity",
});
});
export type MachoTargetSelection = z.infer<typeof machoTargetSelectionSchema>;
/** Read only the provider-neutral fact committed by an analysis profile. */
export const profileMachoTargetSelection = (
profile: AnalysisProfileCommitment | undefined,
): MachoTargetSelection | undefined => {
const raw = profile?.parameters.target_selection;
if (raw === undefined) return undefined;
const parsed = machoTargetSelectionSchema.safeParse(raw);
return parsed.success ? parsed.data : undefined;
};
/** Confirm the selected header's CPU family agrees with the caller's request. */
export const machoTargetSelectionMatchesArchitecture = (
selection: MachoTargetSelection,
architecture: BinaryArchitecture,
): boolean => {
const cpuType: Partial<Record<BinaryArchitecture, number>> = {
x86: 7,
x86_64: 0x01000007,
arm: 12,
arm64: 0x0100000c,
};
return cpuType[architecture] === selection.cpu_type;
};
/** Confirm the parser-observed container agrees with the target's declared format. */
export const machoTargetSelectionMatchesFormat = (
selection: MachoTargetSelection,
format: "mach-o" | "mach-o-universal",
): boolean => selection.container === (format === "mach-o" ? "thin" : "fat");
/** Exact range and raw CPU identity equality; null is never treated as a match. */
export const sameMachoTargetSelection = (
left: MachoTargetSelection | undefined,
right: MachoTargetSelection | undefined,
): boolean =>
left !== undefined &&
right !== undefined &&
left.container === right.container &&
left.offset === right.offset &&
left.byte_length === right.byte_length &&
left.cpu_type === right.cpu_type &&
left.cpu_subtype === right.cpu_subtype &&
left.container_table_sha256 === right.container_table_sha256;
+9 -3
View File
@@ -60,8 +60,14 @@ export class HopperTimeoutError extends HopperError {
export class HopperCancelledError extends HopperError {
readonly _tag = "HopperCancelledError";
constructor() {
super("Hopper operation was cancelled");
constructor(
cause?: unknown,
options: Omit<AnalysisErrorOptions, "cause"> = {},
) {
super(
"Hopper operation was cancelled",
cause === undefined ? options : { ...options, cause },
);
}
}
@@ -167,7 +173,7 @@ export class HopperStartError extends HopperError {
readonly launchRejection: HopperLaunchRejection | undefined;
constructor(
options?: ErrorOptions & {
options?: AnalysisErrorOptions & {
readonly userMessage?: string;
readonly ownerRunId?: string;
readonly launcherFailure?: HopperLauncherOutcome;
+105 -23
View File
@@ -10,7 +10,13 @@ import {
} 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 {
ArtifactReaderFailure,
artifactPrimaryCleanupLeases,
} from "../artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLeaseGroup } from "../artifacts/AnalysisCleanupLease.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
import {
@@ -27,6 +33,7 @@ import {
import {
prepareHopperMachOImage,
type HopperMachOImage,
type HopperMachOImageFileSystem,
} from "./HopperMachOImage.js";
import {
@@ -42,6 +49,19 @@ import { retryLinuxHopperPublisherCleanup } from "../application/LinuxHopperPubl
const execFileAsync = promisify(execFile);
const HOPPER_BACKGROUND_STARTUP_MS = 5_000;
const bindRetainedArtifactCleanup = (
error: AnalysisError,
cause: unknown,
): void => {
const leases = artifactPrimaryCleanupLeases(cause);
if (leases !== undefined && leases.length > 0) {
new AnalysisCleanupLeaseGroup(error, leases);
return;
}
if (cause instanceof AnalysisError && cause.cleanupIncomplete)
error.bindCleanupDependencies([cause]);
};
/** Coordinates for one private bridge session. */
export interface BridgeSession {
readonly directory: string;
@@ -115,6 +135,7 @@ export type HopperApplicationLauncherOptions =
});
export interface HopperApplicationLauncherDependencies {
readonly imageFileSystem?: HopperMachOImageFileSystem;
readonly verifyNativeHelper?: typeof verifyMacHopperNativeHelper;
readonly verifyLinuxLauncher?: typeof inspectLinuxHopperLauncher;
readonly retryPublisherCleanup?: typeof retryLinuxHopperPublisherCleanup;
@@ -143,35 +164,71 @@ export interface HopperApplicationLauncherDependencies {
* macOS LaunchServices may create or reuse.
*/
export class HopperApplicationLauncher implements BridgeLauncher {
readonly #preparedImageResources = new ArtifactResourceScope();
constructor(
readonly options: HopperApplicationLauncherOptions,
readonly dependencies: HopperApplicationLauncherDependencies = {},
) {}
async cleanup(): Promise<Result<null, ProviderCleanupError>> {
if (!usesLinuxDemo(this.options)) return ok(null);
const failures: ProviderCleanupError[] = [];
try {
await (
this.dependencies.retryPublisherCleanup ??
retryLinuxHopperPublisherCleanup
)();
return ok(null);
await this.#preparedImageResources.retryCleanup();
} catch (cause: unknown) {
const observation = ArtifactReaderFailure.cleanupObservation(
cause,
"hopper-publisher-verification",
this.#preparedImageResources.cleanupResources,
);
return err(
new ProviderCleanupError(
const failure = new ProviderCleanupError(
"hopper",
observation.resources,
{ reason: observation.reason },
{ cause },
);
bindRetainedArtifactCleanup(failure, cause);
failures.push(failure);
}
if (usesLinuxDemo(this.options)) {
try {
await (
this.dependencies.retryPublisherCleanup ??
retryLinuxHopperPublisherCleanup
)();
} catch (cause: unknown) {
const observation = ArtifactReaderFailure.cleanupObservation(
cause,
"hopper-publisher-verification",
);
const failure = new ProviderCleanupError(
"hopper",
observation.resources,
{
reason: observation.reason,
},
{ reason: observation.reason },
{ cause },
),
);
);
bindRetainedArtifactCleanup(failure, cause);
failures.push(failure);
}
}
if (failures.length === 0) return ok(null);
if (failures.length === 1) return err(failures[0]!);
const resources = [
...new Set(failures.flatMap((failure) => failure.cleanupResources)),
];
const failure = new ProviderCleanupError(
"hopper",
resources,
{
reason: failures.map((failure) => failure.message).join("; "),
},
{
cause: new AggregateError(
failures,
"Hopper image and publisher cleanup remain incomplete",
),
},
);
return err(failure.bindCleanupDependencies(failures));
}
async launch(
@@ -189,14 +246,39 @@ export class HopperApplicationLauncher implements BridgeLauncher {
let leaseTransferred = false;
try {
let loadedPath = this.options.targetPath;
if (this.options.preparedImage !== undefined) {
const prepared = await prepareHopperMachOImage(
this.options.targetPath,
this.options.preparedImage.sourceSha256,
this.options.preparedImage.image,
join(session.directory, "image.macho"),
options.signal,
);
const preparedImage = this.options.preparedImage;
if (preparedImage !== undefined) {
let prepared: Awaited<ReturnType<typeof prepareHopperMachOImage>>;
try {
prepared = await this.#preparedImageResources.run(() =>
prepareHopperMachOImage(
this.options.targetPath,
preparedImage.sourceSha256,
preparedImage.image,
join(session.directory, "image.macho"),
{
...(options.signal === undefined
? {}
: { signal: options.signal }),
resourceScope: this.#preparedImageResources,
...(this.dependencies.imageFileSystem === undefined
? {}
: { fileSystem: this.dependencies.imageFileSystem }),
},
),
);
} catch (cause: unknown) {
const failure = new HopperStartError({
cause,
userMessage: `Previous Hopper image cleanup remains incomplete: ${cause instanceof Error ? cause.message : String(cause)}`,
cleanup: {
reason: cause instanceof Error ? cause.message : String(cause),
resources: this.#preparedImageResources.cleanupResources,
},
});
bindRetainedArtifactCleanup(failure, cause);
return err(failure);
}
if (!prepared.ok) return prepared;
loadedPath = prepared.value;
}
+238 -1
View File
@@ -1,15 +1,33 @@
import { createHash } from "node:crypto";
import { PassThrough } from "node:stream";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { afterEach, expect, it, vi } from "vitest";
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
createReadStream: vi.fn(actual.createReadStream),
};
});
import { createReadStream } from "node:fs";
import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import {
hopperLoaderArgsForTarget,
resolveHopperAnalysisProfile,
} from "./HopperAnalysisProfile.js";
let inspectionResources = createInspectionResources();
afterEach(async () => {
await inspectionResources.close();
inspectionResources = createInspectionResources();
});
it("routes physical universal Mach-O containers through Hopper's FAT loader", () => {
const result = hopperLoaderArgsForTarget(
{
@@ -72,6 +90,7 @@ it("prepares a verified thin arm64e slice for a FAT32 system container", async (
launcherPath: path,
loaderArgsOverride: [],
provider: { id: "hopper", name: "Hopper Disassembler", version: null },
resourceScope: inspectionResources.owner,
},
);
if (!result.ok) throw new Error(result.error.message);
@@ -85,9 +104,227 @@ it("prepares a verified thin arm64e slice for a FAT32 system container", async (
cpu_type: 0x0100000c,
cpu_subtype: 0x80000002,
},
target_selection: {
kind: "macho-selected-image-v1",
container: "fat",
offset: arm64eOffset,
byte_length: 64,
cpu_type: 0x0100000c,
cpu_subtype: 0x80000002,
container_table_sha256: expect.any(String),
},
loader: {
source: "derived",
arguments: ["-l", "Mach-O", "--aarch64"],
},
});
});
it("commits a thin header selection only for derived Hopper loader arguments", async () => {
const directory = await createTestTempDirectory("rea-hopper-thin-profile-");
const thin = Buffer.alloc(64);
thin.writeUInt32LE(0xfeedfacf, 0);
thin.writeUInt32LE(0x0100000c, 4);
thin.writeUInt32LE(0x80000002, 8);
const path = join(directory, "arm64e.macho");
await writeFile(path, thin);
const target = {
path,
sha256: createHash("sha256").update(thin).digest("hex"),
kind: "executable" as const,
format: "mach-o" as const,
architecture: "arm64" as const,
availableArchitectures: ["arm64" as const],
};
const provider = {
id: "hopper",
name: "Hopper Disassembler",
version: null,
};
const resolved = await resolveHopperAnalysisProfile(target, {
launcherPath: path,
loaderArgsOverride: [],
provider,
resourceScope: inspectionResources.owner,
});
if (!resolved.ok) throw new Error(resolved.error.message);
expect(resolved.value.profile?.parameters.target_selection).toEqual({
kind: "macho-selected-image-v1",
container: "thin",
offset: 0,
byte_length: 64,
cpu_type: 0x0100000c,
cpu_subtype: 0x80000002,
container_table_sha256: null,
});
expect(resolved.value.profile?.parameters.prepared_image).toEqual({
method: "thin_verified_copy",
byte_length: thin.length,
cpu_type: 0x0100000c,
cpu_subtype: 0x80000002,
});
const overridden = await resolveHopperAnalysisProfile(target, {
launcherPath: path,
loaderArgsOverride: ["-l", "Mach-O"],
provider,
resourceScope: inspectionResources.owner,
});
if (!overridden.ok) throw new Error(overridden.error.message);
expect(overridden.value.profile?.parameters).not.toHaveProperty(
"target_selection",
);
const changedSameSize = Buffer.from(thin);
changedSameSize.writeUInt8(
changedSameSize.readUInt8(changedSameSize.length - 1) ^ 1,
changedSameSize.length - 1,
);
await writeFile(path, changedSameSize);
const staleAdmission = await resolveHopperAnalysisProfile(target, {
launcherPath: path,
loaderArgsOverride: [],
provider,
resourceScope: inspectionResources.owner,
});
expect(staleAdmission.ok).toBe(false);
if (staleAdmission.ok) throw new Error("stale thin admission was accepted");
expect(staleAdmission.error.message).toContain(
"Thin Mach-O source changed after target admission",
);
});
it("keeps a read failure primary when cancellation arrives with the stream error", async () => {
const path = "/fixture/arm64.macho";
const target = {
path,
sha256: "a".repeat(64),
kind: "executable" as const,
format: "mach-o" as const,
architecture: "arm64" as const,
availableArchitectures: ["arm64" as const],
};
const controller = new AbortController();
const reason = { request: "cancel-after-read-error" };
const physicalFailure = new Error("fixture read failure");
const stream = new PassThrough();
vi.mocked(createReadStream).mockReturnValueOnce(
stream as unknown as ReturnType<typeof createReadStream>,
);
stream.once("error", () => controller.abort(reason));
const resolving = resolveHopperAnalysisProfile(target, {
launcherPath: "/fixture/hopper",
loaderArgsOverride: [],
provider: { id: "hopper", name: "Hopper", version: null },
resourceScope: inspectionResources.owner,
signal: controller.signal,
});
stream.destroy(physicalFailure);
const result = await resolving;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("A failed header read was accepted");
expect(result.error._tag).toBe("BinaryTargetError");
expect(result.error.cause).toBe(physicalFailure);
});
it("retains the exact caller reason when the owned header stream is aborted", async () => {
const controller = new AbortController();
const reason = { request: "cancel-header-read" };
const stream = new PassThrough();
vi.mocked(createReadStream).mockReturnValueOnce(
stream as unknown as ReturnType<typeof createReadStream>,
);
const resolving = resolveHopperAnalysisProfile(
{
path: "/fixture/arm64.macho",
sha256: "a".repeat(64),
kind: "executable",
format: "mach-o",
architecture: "arm64",
availableArchitectures: ["arm64"],
},
{
launcherPath: "/fixture/hopper",
loaderArgsOverride: [],
provider: { id: "hopper", name: "Hopper", version: null },
resourceScope: inspectionResources.owner,
signal: controller.signal,
},
);
controller.abort(reason);
const result = await resolving;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("An aborted header read was accepted");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
});
it("keeps failed Mach-O preflight cleanup attached to the supplied profile owner", async () => {
const directory = await createTestTempDirectory("rea-hopper-profile-owner-");
const sourcePath = join(directory, "source.macho");
const bytes = Buffer.alloc(100_000, 0x5a);
bytes.writeUInt32BE(0xfeedfacf, 0);
bytes.writeUInt32BE(0x0100000c, 4);
bytes.writeUInt32BE(0x80000002, 8);
await writeFile(sourcePath, bytes);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const actualFs =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
const actualNodeFs =
await vi.importActual<typeof import("node:fs")>("node:fs");
vi.mocked(createReadStream).mockReset();
vi.mocked(createReadStream).mockImplementation(actualNodeFs.createReadStream);
const closeFailure = new Error("profile preflight close failure");
let allowClose = false;
const imageFileSystem = {
open: async (...args: Parameters<typeof actualFs.open>) => {
const handle = await actualFs.open(...args);
const closeHandle = handle.close.bind(handle);
Object.defineProperty(handle, "close", {
configurable: true,
value: async (): Promise<void> => {
if (!allowClose) throw closeFailure;
await closeHandle();
},
});
return handle;
},
};
const owner = inspectionResources;
const resolved = await resolveHopperAnalysisProfile(
{
path: sourcePath,
sha256,
kind: "executable",
format: "mach-o",
architecture: "arm64",
availableArchitectures: ["arm64"],
},
{
launcherPath: sourcePath,
loaderArgsOverride: [],
provider: { id: "hopper", name: "Hopper", version: null },
resourceScope: owner.owner,
imageFileSystem,
},
);
expect(resolved.ok).toBe(false);
if (resolved.ok) throw new Error("Failed preflight cleanup was accepted");
expect(resolved.error._tag).toBe("BinaryTargetError");
expect(resolved.error.cause).toBe(closeFailure);
expect(resolved.error.cleanupIncomplete).toBe(true);
expect(resolved.error.cleanupResources).toEqual([
`hopper-macho-preflight:${sourcePath}`,
]);
allowClose = true;
await owner.close();
expect(resolved.error.cleanupIncomplete).toBe(false);
});
+89 -15
View File
@@ -3,6 +3,7 @@ import { createReadStream } from "node:fs";
import type {
AnalysisProfileResolution,
RetainedResourceScope,
ProviderIdentity,
} from "../application/AnalysisProvider.js";
import { createAnalysisProfile } from "../domain/analysisProfile.js";
@@ -19,11 +20,16 @@ import {
} from "../domain/analysisErrorCore.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import {
machoTargetSelectionSchema,
type MachoTargetSelection,
} from "../domain/apple/machoTargetSelection.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../domain/result.js";
import {
resolveHopperMachOImage,
type HopperMachOImageResolutionFileSystem,
type HopperMachOImage,
} from "./HopperMachOImage.js";
@@ -31,6 +37,8 @@ interface HopperProfileOptions {
readonly launcherPath: string;
readonly loaderArgsOverride: readonly string[];
readonly provider: ProviderIdentity;
readonly resourceScope: RetainedResourceScope;
readonly imageFileSystem?: HopperMachOImageResolutionFileSystem;
readonly signal?: AbortSignal;
}
@@ -38,6 +46,25 @@ interface HopperProfileOptions {
export const resolveHopperAnalysisProfile = async (
target: BinaryTarget,
options: HopperProfileOptions,
): Promise<Result<AnalysisProfileResolution, AnalysisError>> => {
try {
return await options.resourceScope.run(() =>
resolveHopperAnalysisProfileInScope(target, options),
);
} catch (cause: unknown) {
return err(
cause instanceof AnalysisError
? cause
: new ProviderAdapterError("hopper", "resolve_analysis_profile", {
cause,
}),
);
}
};
const resolveHopperAnalysisProfileInScope = async (
target: BinaryTarget,
options: HopperProfileOptions,
): Promise<Result<AnalysisProfileResolution, AnalysisError>> => {
if (target.kind !== "executable" && target.kind !== "database")
return ok({ profile: null });
@@ -54,13 +81,41 @@ export const resolveHopperAnalysisProfile = async (
let preparedImage: HopperMachOImage | undefined;
if (
target.kind === "executable" &&
(machoContainer === "fat64" || machoContainer === "fat32") &&
machoContainer !== undefined &&
options.loaderArgsOverride.length === 0
) {
const resolved = await resolveHopperMachOImage(target, options.signal);
const resolved = await resolveHopperMachOImage(target, {
resourceScope: options.resourceScope,
...(options.signal === undefined ? {} : { signal: options.signal }),
...(options.imageFileSystem === undefined
? {}
: { fileSystem: options.imageFileSystem }),
});
if (!resolved.ok) return resolved;
preparedImage = resolved.value;
}
let targetSelection: MachoTargetSelection | undefined;
if (preparedImage?.method === "thin_verified_copy") {
targetSelection = machoTargetSelectionSchema.parse({
kind: "macho-selected-image-v1",
container: "thin",
offset: 0,
byte_length: preparedImage.byte_length,
cpu_type: preparedImage.cpu_type,
cpu_subtype: preparedImage.cpu_subtype,
container_table_sha256: null,
});
} else if (preparedImage !== undefined) {
targetSelection = machoTargetSelectionSchema.parse({
kind: "macho-selected-image-v1",
container: "fat",
offset: preparedImage.source_offset,
byte_length: preparedImage.byte_length,
cpu_type: preparedImage.cpu_type,
cpu_subtype: preparedImage.cpu_subtype,
container_table_sha256: preparedImage.container_table_sha256,
});
}
const derived = hopperLoaderArgsForTarget(
target,
preparedImage === undefined ? machoContainer : "thin",
@@ -93,6 +148,9 @@ export const resolveHopperAnalysisProfile = async (
...(preparedImage === undefined
? {}
: { prepared_image: { ...preparedImage } }),
...(targetSelection === undefined
? {}
: { target_selection: targetSelection }),
loader: {
source:
options.loaderArgsOverride.length === 0
@@ -189,17 +247,23 @@ const readMachoMagic = async (
signal?: AbortSignal,
): Promise<Result<number, AnalysisError>> => {
if (signalIsAborted(signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
);
const stream = createReadStream(path, { start: 0, end: 3 });
const abortError = new Error("Caller cancelled Mach-O header read");
const onAbort = (): void => {
stream.destroy();
stream.destroy(abortError);
};
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) onAbort();
try {
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
if (signalIsAborted(signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
);
const bytes = Buffer.concat(chunks);
return bytes.length === 4
? ok(bytes.readUInt32BE(0))
@@ -210,8 +274,10 @@ const readMachoMagic = async (
),
);
} catch (cause: unknown) {
return signalIsAborted(signal)
? err(new AnalysisCancelledError("open_binary"))
return cause === abortError
? err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
)
: err(
new BinaryTargetError(
path,
@@ -229,23 +295,31 @@ const sha256File = async (
signal: AbortSignal | undefined,
): Promise<Result<string | undefined, AnalysisCancelledError>> => {
if (signalIsAborted(signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
);
const stream = createReadStream(path);
const abortError = new Error("Caller cancelled launcher fingerprint read");
const onAbort = (): void => {
stream.destroy();
stream.destroy(abortError);
};
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) onAbort();
try {
const hash = createHash("sha256");
for await (const chunk of stream) hash.update(chunk);
return signalIsAborted(signal)
? err(new AnalysisCancelledError("open_binary"))
? err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
)
: ok(hash.digest("hex"));
} catch (cause: unknown) {
// best-effort cleanup: unreadable targets mean no digest; abort still cancels.
void cause;
return signalIsAborted(signal)
? err(new AnalysisCancelledError("open_binary"))
// Physical fingerprint failures are best-effort; only our own abort
// sentinel turns this optional observation into caller cancellation.
return cause === abortError
? err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
)
: ok(undefined);
} finally {
signal?.removeEventListener("abort", onAbort);
@@ -0,0 +1,342 @@
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import {
HopperCancelledError,
HopperStartError,
} from "../domain/hopperErrors.js";
import {
prepareHopperMachOImage,
resolveHopperMachOImage as resolveHopperMachOImageWithOwner,
} from "./HopperMachOImage.js";
let inspectionResources = createInspectionResources();
afterEach(async () => {
await inspectionResources.close();
inspectionResources = createInspectionResources();
});
const resolveHopperMachOImage = (
target: Parameters<typeof resolveHopperMachOImageWithOwner>[0],
) =>
resolveHopperMachOImageWithOwner(target, {
resourceScope: inspectionResources.owner,
});
type NativeFs = typeof import("node:fs/promises");
type NativeFilePath = Parameters<NativeFs["open"]>[0];
const createFileSystemWithCloseHook = async (
onClose: (path: NativeFilePath, close: () => Promise<void>) => Promise<void>,
onRead?: () => Promise<unknown>,
) => {
const actualFs = await vi.importActual<NativeFs>("node:fs/promises");
return {
open: async (...args: Parameters<NativeFs["open"]>) => {
const handle = await actualFs.open(...args);
const path = args[0];
const closeHandle = handle.close.bind(handle);
Object.defineProperty(handle, "close", {
configurable: true,
value: () => onClose(path, closeHandle),
});
if (onRead !== undefined) {
Object.defineProperty(handle, "read", {
configurable: true,
value: onRead,
});
}
return handle;
},
};
};
const prepareWithChunkWriteFailure = (
fixture: Awaited<ReturnType<typeof createThinCopyFixture>>,
cause: Error,
options: Omit<Parameters<typeof prepareHopperMachOImage>[4], "observer">,
) =>
prepareHopperMachOImage(
fixture.sourcePath,
fixture.sha256,
fixture.image,
fixture.destination,
{
...options,
observer: {
afterChunkWritten: async () => {
throw cause;
},
},
},
);
it("keeps an observer failure primary and closes real source handles", async () => {
const fixture = await createThinCopyFixture("rea-hopper-copy-primary-");
const writeFailure = new Error("controlled copy observer failure");
const prepared = await prepareWithChunkWriteFailure(fixture, writeFailure, {
resourceScope: new ArtifactResourceScope(),
});
expect(prepared.ok).toBe(false);
if (prepared.ok) throw new Error("A failed copy was accepted");
expect(prepared.error.cause).toBe(writeFailure);
expect(prepared.error.cleanupIncomplete).toBe(false);
});
it("keeps a copy failure primary while retaining and retrying a real source handle", async () => {
const fixture = await createThinCopyFixture("rea-hopper-copy-cleanup-");
const firstCloseFailure = new Error("source close is not confirmed");
let allowSourceClose = false;
let sourceCloseAttempts = 0;
let outputCloseAttempts = 0;
const fileSystem = await createFileSystemWithCloseHook(
async (path, close) => {
if (path === fixture.sourcePath) {
sourceCloseAttempts += 1;
if (!allowSourceClose) throw firstCloseFailure;
} else {
outputCloseAttempts += 1;
}
await close();
},
);
const resourceScope = new ArtifactResourceScope();
const writeFailure = new Error("controlled copy observer failure");
const prepared = await prepareWithChunkWriteFailure(fixture, writeFailure, {
resourceScope,
fileSystem,
});
expect(prepared.ok).toBe(false);
if (prepared.ok) throw new Error("A failed copy was accepted");
expect(prepared.error.cause).toBe(writeFailure);
expect(prepared.error.cleanupIncomplete).toBe(true);
expect(prepared.error.cleanupResources).toEqual([
`hopper-image-source:${fixture.sourcePath}`,
]);
expect(outputCloseAttempts).toBe(1);
expect(sourceCloseAttempts).toBe(2);
allowSourceClose = true;
await resourceScope.retryCleanup();
expect(sourceCloseAttempts).toBe(3);
expect(prepared.error.cause).toBe(writeFailure);
expect(prepared.error.cleanupIncomplete).toBe(false);
});
it("keeps an output-close failure primary when cancellation first arrives during source close", async () => {
const fixture = await createThinCopyFixture("rea-hopper-late-close-abort-");
const controller = new AbortController();
const reason = { request: "abort-during-source-close" };
const outputCloseFailure = new Error("destination close failed first");
let allowOutputClose = false;
const fileSystem = await createFileSystemWithCloseHook(
async (path, close) => {
if (path === fixture.destination && !allowOutputClose)
throw outputCloseFailure;
await close();
if (path === fixture.sourcePath) controller.abort(reason);
},
);
const resourceScope = new ArtifactResourceScope();
const prepared = await prepareHopperMachOImage(
fixture.sourcePath,
fixture.sha256,
fixture.image,
fixture.destination,
{ fileSystem, resourceScope, signal: controller.signal },
);
expect(prepared.ok).toBe(false);
if (prepared.ok) throw new Error("Failed output closure was accepted");
expect(prepared.error).toBeInstanceOf(HopperStartError);
expect(prepared.error.cause).toBe(outputCloseFailure);
expect(prepared.error.cause).not.toBe(reason);
expect(prepared.error.cleanupIncomplete).toBe(true);
expect(prepared.error.cleanupResources).toEqual([
`hopper-prepared-image:${fixture.destination}`,
]);
allowOutputClose = true;
await resourceScope.retryCleanup();
expect(prepared.error.cleanupIncomplete).toBe(false);
});
it("keeps cancellation observed before close primary and retains an output-close failure", async () => {
const fixture = await createThinCopyFixture("rea-hopper-cancel-close-error-");
const controller = new AbortController();
const reason = { request: "abort-before-close" };
const outputCloseFailure = new Error("destination close failed");
let allowOutputClose = false;
const fileSystem = await createFileSystemWithCloseHook(
async (path, close) => {
if (path === fixture.destination && !allowOutputClose)
throw outputCloseFailure;
await close();
},
);
const resourceScope = new ArtifactResourceScope();
const prepared = await prepareHopperMachOImage(
fixture.sourcePath,
fixture.sha256,
fixture.image,
fixture.destination,
{
fileSystem,
resourceScope,
signal: controller.signal,
observer: {
afterChunkWritten: async () => controller.abort(reason),
},
},
);
expect(prepared.ok).toBe(false);
if (prepared.ok) throw new Error("Cancelled copy was accepted");
expect(prepared.error).toBeInstanceOf(HopperCancelledError);
expect(prepared.error.cause).toBe(reason);
expect(prepared.error.cleanupIncomplete).toBe(true);
expect(prepared.error.cleanupResources).toEqual([
`hopper-prepared-image:${fixture.destination}`,
]);
expect(
prepared.error.secondaryFailures.some(
(failure) => failure.cause === outputCloseFailure,
),
).toBe(true);
allowOutputClose = true;
await resourceScope.retryCleanup();
expect(prepared.error.cleanupIncomplete).toBe(false);
});
it("preserves a physical preflight read failure and retains the handle after close fails", async () => {
const fixture = await createThinCopyFixture(
"rea-hopper-preflight-read-close-",
);
const readFailure = new Error("controlled preflight read failure");
const closeFailure = new Error("controlled preflight close failure");
let allowClose = false;
let closeAttempts = 0;
const fileSystem = await createFileSystemWithCloseHook(
async (_path, close) => {
closeAttempts += 1;
if (!allowClose) throw closeFailure;
await close();
},
async () => {
throw readFailure;
},
);
const owner = inspectionResources;
const result = await resolveHopperMachOImageWithOwner(fixture.target, {
resourceScope: owner.owner,
fileSystem,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Read failure was accepted");
expect(result.error).toBeInstanceOf(BinaryTargetError);
expect(result.error.cause).toBe(readFailure);
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual([
`hopper-macho-preflight:${fixture.sourcePath}`,
]);
expect(result.error.secondaryFailures).toHaveLength(1);
expect(result.error.secondaryFailures[0]?.cleanupIncomplete).toBe(true);
expect(closeAttempts).toBe(1);
allowClose = true;
await owner.close();
expect(closeAttempts).toBe(2);
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.secondaryFailures[0]?.cleanupIncomplete).toBe(false);
});
it("retains exact cancellation during preflight close without replacing its close failure", async () => {
const fixture = await createThinCopyFixture(
"rea-hopper-preflight-close-cancel-",
);
const controller = new AbortController();
const reason = { request: "abort-during-preflight-close" };
const closeFailure = new Error("controlled preflight close failure");
let allowClose = false;
let closeAttempts = 0;
const fileSystem = await createFileSystemWithCloseHook(
async (_path, close) => {
closeAttempts += 1;
if (!allowClose) {
controller.abort(reason);
throw closeFailure;
}
await close();
},
);
const owner = inspectionResources;
const result = await resolveHopperMachOImageWithOwner(fixture.target, {
resourceScope: owner.owner,
fileSystem,
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Close failure was accepted");
expect(result.error).toBeInstanceOf(BinaryTargetError);
expect(result.error.cause).toBe(closeFailure);
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.secondaryFailures).toHaveLength(1);
expect(result.error.secondaryFailures[0]).toBeInstanceOf(
AnalysisCancelledError,
);
expect(result.error.secondaryFailures[0]?.cause).toBe(reason);
allowClose = true;
await owner.close();
expect(closeAttempts).toBe(2);
expect(result.error.cleanupIncomplete).toBe(false);
});
const createThinCopyFixture = async (prefix: string) => {
const directory = await createTestTempDirectory(prefix);
const bytes = Buffer.alloc(100_000, 0x5a);
bytes.writeUInt32BE(0xfeedfacf, 0);
bytes.writeUInt32BE(0x0100000c, 4);
bytes.writeUInt32BE(0x80000002, 8);
const sourcePath = join(directory, "source.macho");
const destination = join(directory, "image.macho");
await writeFile(sourcePath, bytes);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const target = {
path: sourcePath,
sha256,
kind: "executable" as const,
format: "mach-o" as const,
architecture: "arm64" as const,
availableArchitectures: ["arm64"] as const,
};
const resolved = await resolveHopperMachOImage({
...target,
});
if (!resolved.ok) throw resolved.error;
return {
sourcePath,
destination,
sha256,
target,
image: resolved.value,
};
};
+399 -5
View File
@@ -1,15 +1,26 @@
import { createHash } from "node:crypto";
import { chmod, readFile, writeFile } from "node:fs/promises";
import {
access,
chmod,
mkdir,
readFile,
readdir,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { afterEach, expect, it, vi } from "vitest";
import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
prepareHopperMachOImage,
resolveHopperMachOImage,
resolveHopperMachOImage as resolveHopperMachOImageWithOwner,
} from "./HopperMachOImage.js";
import { HopperApplicationLauncher } from "./BridgeLauncher.js";
const CPU_TYPES = { x86_64: 0x01000007, arm64: 0x0100000c } as const;
const FAT32_ALIGNMENT = 12;
@@ -105,6 +116,19 @@ const requireResolved = (
return result.value;
};
let inspectionResources = createInspectionResources();
afterEach(async () => {
await inspectionResources.close();
inspectionResources = createInspectionResources();
});
const resolveHopperMachOImage = (
target: Parameters<typeof resolveHopperMachOImageWithOwner>[0],
) =>
resolveHopperMachOImageWithOwner(target, {
resourceScope: inspectionResources.owner,
});
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports a FAT64 source that became unreadable as a host read denial",
async () => {
@@ -152,7 +176,7 @@ it("rejects an ELF target before attempting FAT source preparation", async () =>
expect(result.error).toMatchObject({
_tag: "BinaryTargetError",
path,
reason: "Hopper FAT image preparation requires a Mach-O target",
reason: "Hopper image preparation requires a Mach-O target",
});
});
@@ -214,7 +238,7 @@ it("fails closed when capability variants share the preferred arm64e base", asyn
const result = await resolveHopperMachOImage(
fat32Target(path, fixture, "arm64"),
);
if (result.ok || result.error._tag !== "BinaryTargetError")
if (result.ok || !(result.error instanceof BinaryTargetError))
throw new Error(
"Expected ambiguous FAT selection to return a BinaryTargetError",
);
@@ -351,6 +375,7 @@ it("copies exactly the selected FAT32 slice bytes and verifies the source", asyn
target.sha256,
resolved,
destination,
{ resourceScope: new ArtifactResourceScope() },
);
if (!prepared.ok) throw new Error(prepared.error.userMessage);
const arm64eOffset = fixture.sliceOffsets[1]!;
@@ -361,6 +386,197 @@ it("copies exactly the selected FAT32 slice bytes and verifies the source", asyn
expect(await readFile(destination)).toEqual(copied);
});
it("retains the caller reason when a FAT copy is cancelled after its final write", async () => {
const directory = await createTestTempDirectory("rea-hopper-fat-cancel-");
const fixture = buildFat32Container([
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
]);
const path = await writeFixture(directory, "system.fat", fixture);
const target = fat32Target(path, fixture, "arm64");
const image = requireResolved(await resolveHopperMachOImage(target));
const destination = join(directory, "image.macho");
const controller = new AbortController();
const reason = { request: "cancel-after-fat-write" };
const prepared = await prepareHopperMachOImage(
path,
target.sha256,
image,
destination,
{
resourceScope: new ArtifactResourceScope(),
signal: controller.signal,
observer: {
afterChunkWritten: async (copiedBytes) => {
expect(copiedBytes).toBe(FAT32_SLICE_SIZE);
controller.abort(reason);
},
},
},
);
expect(prepared.ok).toBe(false);
if (prepared.ok) throw new Error("Cancelled FAT copy was accepted");
expect(prepared.error._tag).toBe("HopperCancelledError");
expect(prepared.error.cause).toBe(reason);
// The private session directory remains the owner of partial output.
await expect(readFile(destination)).resolves.toEqual(
fixture.container.subarray(
fixture.sliceOffsets[0],
fixture.sliceOffsets[0]! + FAT32_SLICE_SIZE,
),
);
});
it("retains a failed prepared-image close through launcher cleanup and blocks another launch", async () => {
const directory = await createTestTempDirectory("rea-hopper-copy-close-");
const bytes = thinFixture();
const path = join(directory, "source.macho");
await writeFile(path, bytes);
const target = thinTarget(path, bytes);
const image = requireResolved(await resolveHopperMachOImage(target));
const actualFs =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
const firstCloseFailure = new Error("first close attempt failed");
const retryCloseFailure = new Error("cleanup retry failed");
let allowClose = false;
let sourceCloseAttempts = 0;
const imageFileSystem = {
open: async (...arguments_: Parameters<typeof actualFs.open>) => {
const handle = await actualFs.open(...arguments_);
if (arguments_[0] !== path) return handle;
const sourceClose = async (): Promise<void> => {
sourceCloseAttempts += 1;
if (!allowClose && sourceCloseAttempts === 1) throw firstCloseFailure;
if (!allowClose) throw retryCloseFailure;
await handle.close();
};
return new Proxy(handle, {
get(targetHandle, property) {
if (property === "close") return sourceClose;
const value = Reflect.get(targetHandle, property, targetHandle);
return typeof value === "function" ? value.bind(targetHandle) : value;
},
});
},
};
const sessionDirectory = join(directory, "session");
await mkdir(sessionDirectory);
const launcher = new HopperApplicationLauncher(
{
environment: {},
launcherPath: join(directory, "hopper"),
targetPath: path,
targetKind: "executable",
loaderArgs: ["-l", "Mach-O", "--aarch64"],
bridgeScriptPath: join(directory, "bridge.py"),
preparedImage: { image, sourceSha256: target.sha256 },
launchMode: "native",
},
{ platform: "linux", imageFileSystem },
);
const initial = await launcher.launch({
directory: sessionDirectory,
socketPath: join(sessionDirectory, "bridge.sock"),
token: "fixture-token",
runId: "fixture-run-1",
});
expect(initial.ok).toBe(false);
if (initial.ok) throw new Error("A copy with failed close was accepted");
expect(initial.error._tag).toBe("HopperStartError");
expect(initial.error.cause).toBe(firstCloseFailure);
expect(initial.error.cleanupIncomplete).toBe(true);
expect(initial.error.cleanupResources).toEqual([
`hopper-image-source:${path}`,
]);
const blocked = await launcher.launch({
directory: sessionDirectory,
socketPath: join(sessionDirectory, "bridge.sock"),
token: "fixture-token",
runId: "fixture-run-2",
});
expect(blocked.ok).toBe(false);
if (blocked.ok) throw new Error("A launch bypassed retained file cleanup");
expect(blocked.error.cleanupIncomplete).toBe(true);
expect(blocked.error.cleanupResources).toEqual([
`hopper-image-source:${path}`,
]);
expect(sourceCloseAttempts).toBe(3);
const cleanupPending = await launcher.cleanup();
expect(cleanupPending.ok).toBe(false);
if (cleanupPending.ok)
throw new Error("Cleanup without release was reported successful");
expect(cleanupPending.error.cleanupIncomplete).toBe(true);
expect(cleanupPending.error.cleanupResources).toEqual([
`hopper-image-source:${path}`,
]);
expect(sourceCloseAttempts).toBe(4);
allowClose = true;
await expect(launcher.cleanup()).resolves.toEqual({ ok: true, value: null });
expect(initial.error.cleanupIncomplete).toBe(false);
expect(blocked.error.cleanupIncomplete).toBe(false);
expect(cleanupPending.error.cleanupIncomplete).toBe(false);
await expect(
readFile(join(sessionDirectory, "image.macho")),
).resolves.toEqual(bytes);
});
it("observes caller cancellation that arrives during final source-handle cleanup", async () => {
const directory = await createTestTempDirectory(
"rea-hopper-copy-close-cancel-",
);
const bytes = thinFixture();
const path = join(directory, "source.macho");
await writeFile(path, bytes);
const target = thinTarget(path, bytes);
const image = requireResolved(await resolveHopperMachOImage(target));
const controller = new AbortController();
const reason = { request: "cancel-during-final-close" };
const actualFs =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
const fileSystem = {
open: async (...arguments_: Parameters<typeof actualFs.open>) => {
const handle = await actualFs.open(...arguments_);
if (arguments_[0] !== path) return handle;
const closeHandle = handle.close.bind(handle);
Object.defineProperty(handle, "close", {
configurable: true,
value: async (): Promise<void> => {
controller.abort(reason);
await closeHandle();
},
});
return handle;
},
};
const prepared = await prepareHopperMachOImage(
path,
target.sha256,
image,
join(directory, "image.macho"),
{
resourceScope: new ArtifactResourceScope(),
signal: controller.signal,
fileSystem,
},
);
expect(prepared.ok).toBe(false);
if (prepared.ok) throw new Error("Finalization cancellation was ignored");
expect(prepared.error._tag).toBe("HopperCancelledError");
expect(prepared.error.cause).toBe(reason);
await expect(readFile(join(directory, "image.macho"))).resolves.toEqual(
bytes,
);
});
it("rejects FAT32 preparation when the source table changed after resolution", async () => {
const directory = await createTestTempDirectory("rea-hopper-fat32-race-");
const fixture = buildFat32Container([
@@ -379,8 +595,186 @@ it("rejects FAT32 preparation when the source table changed after resolution", a
target.sha256,
resolved,
destination,
{ resourceScope: new ArtifactResourceScope() },
);
if (prepared.ok)
throw new Error("A mutated FAT32 source passed verification");
expect(prepared.error.userMessage).toMatch(/no longer agrees/u);
});
const thinFixture = (): Buffer => {
const image = Buffer.alloc(64, 0x5a);
image.writeUInt32BE(0xfeedfacf, 0);
image.writeUInt32BE(CPU_TYPES.arm64, 4);
image.writeUInt32BE(ARM64E_WITH_CAPABILITIES, 8);
return image;
};
const thinTarget = (path: string, bytes: Buffer) => ({
path,
sha256: createHash("sha256").update(bytes).digest("hex"),
kind: "executable" as const,
format: "mach-o" as const,
architecture: "arm64" as const,
availableArchitectures: ["arm64"] as const,
});
it("copies an admitted thin Mach-O through the owned verified-image path", async () => {
const directory = await createTestTempDirectory("rea-hopper-thin-copy-");
const bytes = thinFixture();
const path = join(directory, "source.macho");
await writeFile(path, bytes);
const target = thinTarget(path, bytes);
const image = requireResolved(await resolveHopperMachOImage(target));
expect(image).toEqual({
method: "thin_verified_copy",
byte_length: bytes.length,
cpu_type: CPU_TYPES.arm64,
cpu_subtype: ARM64E_WITH_CAPABILITIES,
});
const destination = join(directory, "private", "image.macho");
await mkdir(join(directory, "private"));
const prepared = await prepareHopperMachOImage(
path,
target.sha256,
image,
destination,
{ resourceScope: new ArtifactResourceScope() },
);
if (!prepared.ok) throw new Error(prepared.error.userMessage);
expect(prepared.value).toBe(destination);
expect(await readFile(destination)).toEqual(bytes);
expect(target.path).toBe(path);
});
it("does not launch a thin selection when its admitted source changes before copy", async () => {
const directory = await createTestTempDirectory("rea-hopper-thin-race-");
const bytes = thinFixture();
const path = join(directory, "source.macho");
await writeFile(path, bytes);
const target = thinTarget(path, bytes);
const image = requireResolved(await resolveHopperMachOImage(target));
const changed = Buffer.from(bytes);
changed.writeUInt8(
changed.readUInt8(changed.length - 1) ^ 1,
changed.length - 1,
);
await writeFile(path, changed);
const sessionDirectory = join(directory, "session");
await mkdir(sessionDirectory);
const launcher = new HopperApplicationLauncher(
{
environment: process.env,
launcherPath: join(directory, "hopper"),
targetPath: path,
targetKind: "executable",
loaderArgs: ["-l", "Mach-O", "--aarch64"],
bridgeScriptPath: join(directory, "bridge.py"),
preparedImage: { image, sourceSha256: target.sha256 },
launchMode: "native",
},
{ platform: "linux" },
);
const launched = await launcher.launch({
directory: sessionDirectory,
socketPath: join(sessionDirectory, "bridge.sock"),
token: "fixture-token",
runId: "fixture-run",
});
expect(launched.ok).toBe(false);
if (launched.ok) throw new Error("Changed source reached Hopper launch");
expect(launched.error.userMessage).toContain(
"no longer agrees with its admitted digest, size, or selected header",
);
await expect(
access(join(sessionDirectory, "bootstrap.py")),
).rejects.toMatchObject({
code: "ENOENT",
});
// The session directory remains the cleanup owner for partial private output.
await expect(readdir(sessionDirectory)).resolves.toContain("image.macho");
});
it("keeps caller cancellation primary after verified thin-image preparation", async () => {
const directory = await createTestTempDirectory("rea-hopper-thin-cancel-");
const bytes = thinFixture();
const path = join(directory, "source.macho");
await writeFile(path, bytes);
const target = thinTarget(path, bytes);
const image = requireResolved(await resolveHopperMachOImage(target));
const controller = new AbortController();
controller.abort(new Error("caller cancellation"));
const prepared = await prepareHopperMachOImage(
path,
target.sha256,
image,
join(directory, "image.macho"),
{ resourceScope: new ArtifactResourceScope(), signal: controller.signal },
);
expect(prepared).toMatchObject({
ok: false,
error: { _tag: "HopperCancelledError" },
});
});
it("retains an owned partial thin image and exact cancellation reason after a write", async () => {
const directory = await createTestTempDirectory("rea-hopper-thin-partial-");
const bytes = Buffer.alloc(2 * 1024 * 1024, 0x5a);
bytes.writeUInt32BE(0xfeedfacf, 0);
bytes.writeUInt32BE(CPU_TYPES.arm64, 4);
bytes.writeUInt32BE(ARM64E_WITH_CAPABILITIES, 8);
const path = join(directory, "source.macho");
await writeFile(path, bytes);
const target = thinTarget(path, bytes);
const image = requireResolved(await resolveHopperMachOImage(target));
const sessionDirectory = join(directory, "session");
await mkdir(sessionDirectory);
const destination = join(sessionDirectory, "image.macho");
const controller = new AbortController();
const reason = { owner: "caller", request: 44 };
let releaseWrite: (() => void) | undefined;
let announceWrite: ((copiedBytes: number) => void) | undefined;
const writeGate = new Promise<void>((resolve) => {
releaseWrite = resolve;
});
const firstWrite = new Promise<number>((resolve) => {
announceWrite = resolve;
});
const preparing = prepareHopperMachOImage(
path,
target.sha256,
image,
destination,
{
resourceScope: new ArtifactResourceScope(),
signal: controller.signal,
observer: {
afterChunkWritten: async (copiedBytes) => {
announceWrite?.(copiedBytes);
await writeGate;
},
},
},
);
const copiedBytes = await firstWrite;
const partial = await readFile(destination);
expect(partial.byteLength).toBe(copiedBytes);
expect(copiedBytes).toBeGreaterThan(0);
expect(copiedBytes).toBeLessThan(bytes.length);
controller.abort(reason);
const finishWrite = releaseWrite;
if (finishWrite === undefined)
throw new Error("Copy barrier was not created");
finishWrite();
const prepared = await preparing;
expect(prepared.ok).toBe(false);
if (prepared.ok) throw new Error("Cancelled partial copy was accepted");
expect(prepared.error._tag).toBe("HopperCancelledError");
expect(prepared.error.cause).toBe(reason);
await expect(readFile(destination)).resolves.toEqual(partial);
await expect(access(destination)).resolves.toBeUndefined();
});
File diff suppressed because it is too large. Load diff
+42 -1
View File
@@ -24,6 +24,14 @@ import type { JsonValue } from "../domain/jsonValue.js";
import type { Logger } from "pino";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import { err, ok } from "../domain/result.js";
import type { RetainedResourceScope } from "../application/AnalysisProvider.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { AnalysisCleanupLeaseGroup } from "../artifacts/AnalysisCleanupLease.js";
import {
ArtifactResourceRetryFailure,
ArtifactResourceScope,
} from "../artifacts/ArtifactResourceScope.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { HopperApplicationLauncher } from "./BridgeLauncher.js";
import {
hopperDeclinedArchitectureReason,
@@ -44,6 +52,16 @@ import {
/** Concrete analysis provider backed by REA's private Hopper bridge. */
export class HopperProvider implements AnalysisProviderCandidate {
private readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly #ownedResources = new ArtifactResourceScope();
readonly #inspectionOwner: RetainedResourceScope = {
retain: (owner, resource) =>
this.#ownedResources.retain({
kind: "lease",
resource,
lease: AnalysisCleanupLease.forCloseOwner(owner, resource),
}),
run: (operation) => this.#ownedResources.run(operation),
};
/**
* Host platform, injected so availability and launch-mode decisions can be
@@ -169,14 +187,37 @@ export class HopperProvider implements AnalysisProviderCandidate {
target: BinaryTarget,
options?: AnalysisProfileResolutionOptions,
) {
const resourceScope = options?.resourceScope ?? this.#inspectionOwner;
return resolveHopperAnalysisProfile(target, {
launcherPath: this.config.hopperLauncherPath,
loaderArgsOverride: this.config.hopperLoaderArgs,
provider: HOPPER_PROVIDER_IDENTITY,
resourceScope,
...(options?.signal === undefined ? {} : { signal: options.signal }),
});
}
hasPendingOwnedResources(): boolean {
return this.#ownedResources.hasPending;
}
async closeOwnedResources() {
try {
await this.#ownedResources.close();
return ok(null);
} catch (cause: unknown) {
const failure = new ProviderCleanupError(
HOPPER_PROVIDER_IDENTITY.id,
this.#ownedResources.cleanupResources,
{ reason: cause instanceof Error ? cause.message : String(cause) },
{ cause, operation: "close_owned_resources" },
);
if (cause instanceof ArtifactResourceRetryFailure)
new AnalysisCleanupLeaseGroup(failure, cause.cleanupLeases);
return err(failure);
}
}
createClient(
target: BinaryTarget,
profile?: AnalysisProfileCommitment,
@@ -287,7 +328,7 @@ export class HopperProvider implements AnalysisProviderCandidate {
...(preparedImage === undefined
? []
: [
"REA loaded a verified thin Mach-O slice from the original FAT source. Identity and file offsets refer to the original file. The owned temporary image and its Hopper document close together, including on MCP shutdown.",
"REA loaded a verified thin Mach-O image prepared from the original source. Identity and file offsets refer to the original file. The owned temporary image and its Hopper document close together, including on MCP shutdown.",
]),
...(CAPABILITIES.find(
(descriptor) => descriptor.operation === operation,
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withArtifactResourceScope } from "../../fixtures/artifactInventory.js";
import { createInspectionResources } from "../../fixtures/inspectionResources.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { writeAnalysisSnapshot } from "../../../src/application/binary/AnalysisSnapshotFiles.js";
@@ -21,9 +22,13 @@ import {
workflowAnalysisProfile,
} from "../../../src/application/InvestigationProviders.js";
import { HOPPER_PROVIDER_IDENTITY } from "../../../src/hopper/HopperProviderCapabilities.js";
import { resolveHopperAnalysisProfile } from "../../../src/hopper/HopperAnalysisProfile.js";
let inspectionResources = createInspectionResources();
afterEach(async () => {
await inspectionResources.close();
inspectionResources = createInspectionResources();
vi.unstubAllEnvs();
});
@@ -67,6 +72,7 @@ describe("direct analysis snapshot files", () => {
launcherPath,
loaderArgsOverride: [],
provider: HOPPER_PROVIDER_IDENTITY,
resourceScope: inspectionResources.owner,
});
if (!resolved.ok || resolved.value.profile === null)
throw new Error("fixture Hopper profile did not resolve");
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { access, chmod, rm, writeFile } from "node:fs/promises";
import { access, chmod, open, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
@@ -22,6 +22,8 @@ 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 { AnalysisCleanupLease } from "../../../../src/artifacts/AnalysisCleanupLease.js";
import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js";
import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
import { HopperClient } from "../../../../src/hopper/HopperClient.js";
@@ -64,6 +66,140 @@ const options = (launcherPath: string): HopperApplicationLauncherOptions => ({
demoHelperPath: "/rea/hopper-demo-x11.py",
});
it("resolves mixed image and publisher cleanup only after each owner releases", async () => {
const directory = await createTestTempDirectory("rea-hopper-mixed-cleanup-");
const targetPath = join(directory, "target.macho");
const source = Buffer.alloc(64);
source.writeUInt32BE(0xfeedfacf, 0);
source.writeUInt32BE(0x0100000c, 4);
source.writeUInt32BE(0x80000002, 8);
await writeFile(targetPath, source);
let allowImageClose = false;
let imageCloseAttempts = 0;
const imageFileSystem = {
async open(
path: Parameters<typeof open>[0],
flags: Parameters<typeof open>[1],
mode?: Parameters<typeof open>[2],
) {
const handle = await open(path, flags, mode);
if (String(path) === targetPath && flags === "r") {
const close = handle.close.bind(handle);
handle.close = async () => {
imageCloseAttempts += 1;
if (!allowImageClose)
throw new Error("Fixture image source close unavailable");
await close();
};
}
return handle;
},
};
let allowPublisherClose = false;
let publisherCloseAttempts = 0;
const publisherOwner = {
async close() {
publisherCloseAttempts += 1;
if (!allowPublisherClose)
throw new Error("Fixture publisher close unavailable");
},
};
const publisherLease = AnalysisCleanupLease.forCloseOwner(
publisherOwner,
"hopper-publisher-verification",
);
const retryPublisherCleanup = async () => {
try {
await publisherLease.release();
} catch (cause: unknown) {
const failure = new ArtifactReaderFailure(
"unavailable",
"Publisher verification cleanup remains incomplete",
{
cause,
cleanup: {
reason: cause instanceof Error ? cause.message : String(cause),
resources: ["hopper-publisher-verification"],
},
cleanupLeases: [publisherLease],
},
);
throw failure;
}
};
const launcher = new HopperApplicationLauncher(
{
...options("/missing/hopper"),
targetPath,
preparedImage: {
image: {
method: "thin_verified_copy",
byte_length: source.length,
cpu_type: 0x0100000c,
cpu_subtype: 0x80000002,
},
sourceSha256: createHash("sha256").update(source).digest("hex"),
},
},
{
platform: "win32",
imageFileSystem,
retryPublisherCleanup,
},
);
const session = {
directory,
socketPath: join(directory, "bridge.sock"),
token: "fixture-token",
runId: "fixture-run",
};
try {
const launched = await launcher.launch(session);
expect(launched).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
const first = await launcher.cleanup();
expect(first).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
if (first.ok) throw new Error("Expected mixed cleanup to remain pending");
const originalAggregate = first.error;
expect(originalAggregate.cleanupResources).toEqual(
expect.arrayContaining([
expect.stringContaining("hopper-image-source:"),
"hopper-publisher-verification",
]),
);
allowImageClose = true;
const publisherOnly = await launcher.cleanup();
expect(publisherOnly).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
expect(originalAggregate.cleanupIncomplete).toBe(true);
const closedImageAttempts = imageCloseAttempts;
expect(closedImageAttempts).toBeGreaterThan(1);
allowPublisherClose = true;
expect(await launcher.cleanup()).toEqual({ ok: true, value: null });
expect(originalAggregate.cleanupIncomplete).toBe(false);
expect(imageCloseAttempts).toBe(closedImageAttempts);
expect(publisherCloseAttempts).toBe(3);
} finally {
allowImageClose = true;
allowPublisherClose = true;
await launcher.cleanup();
await rm(directory, { recursive: true, force: true });
}
});
it("retains cancelled verification cleanup through client shutdown and retries it", async () => {
const directory = await createTestTempDirectory("rea-publisher-cancel-");
const targetPath = join(directory, "target");
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { createInspectionResources } from "../../../fixtures/inspectionResources.js";
import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js";
import {
@@ -12,9 +13,12 @@ import {
} from "../../../../src/hopper/HopperAnalysisProfile.js";
import { HOPPER_PROVIDER_IDENTITY } from "../../../../src/hopper/HopperProviderCapabilities.js";
let inspectionResources = createInspectionResources();
let directory: string | undefined;
afterEach(async () => {
await inspectionResources.close();
inspectionResources = createInspectionResources();
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
@@ -70,6 +74,7 @@ describe("Hopper analysis profiles", () => {
launcherPath: "/missing/hopper",
loaderArgsOverride: [],
provider: HOPPER_PROVIDER_IDENTITY,
resourceScope: inspectionResources.owner,
},
);
expect(resolved).toEqual({
@@ -99,6 +104,7 @@ describe("Hopper analysis profiles", () => {
launcherPath,
loaderArgsOverride: scenario.override,
provider: HOPPER_PROVIDER_IDENTITY,
resourceScope: inspectionResources.owner,
},
);
expect(resolved).toMatchObject({
@@ -128,6 +134,7 @@ describe("Hopper analysis profiles", () => {
launcherPath: launcher,
loaderArgsOverride: [],
provider: HOPPER_PROVIDER_IDENTITY,
resourceScope: inspectionResources.owner,
signal: controller.signal,
});
@@ -27,6 +27,7 @@ import {
linuxHopperReleaseCacheDirectory,
retryLinuxHopperPublisherCleanup,
} from "../../../src/application/LinuxHopperPublisher.js";
import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";
import {
HOPPER_LINUX_PRIMARY_FINGERPRINT,
type LinuxHopperRelease,
@@ -108,6 +109,21 @@ const fixture = async (family: "deb" | "arch" = "deb") => {
};
};
const verifyFixtureArchive = async (
f: Awaited<ReturnType<typeof fixture>>,
family: "deb" | "arch" = "deb",
) => {
const result = await f.publisher.verifyArchive(
family,
f.release,
f.archive,
undefined,
f.publicKey,
);
if (!result.ok) throw result.error;
return result.value;
};
const admitFixturePublisher = (
fingerprint: string,
module: typeof signatures = signatures,
@@ -173,16 +189,9 @@ describe("Linux signed package retention boundary", () => {
});
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;
const verifiedArchive = await verifyFixtureArchive(f, family);
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
await f.publisher.retainVerifiedArchive(verifiedArchive, f.archive),
).toEqual({ ok: true, value: undefined });
await f.publisher.close();
// A new instance alone still shares process-local cryptographic proofs.
@@ -240,16 +249,9 @@ linux.each([
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 verifiedArchive = await verifyFixtureArchive(f, "deb");
const retained = await f.publisher.retainVerifiedArchive(
verified.value,
verifiedArchive,
f.archive,
);
if (!retained.ok) throw retained.error;
@@ -300,7 +302,7 @@ linux.each([
// 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),
await f.publisher.retainVerifiedArchive(verifiedArchive, f.archive),
).toEqual({ ok: true, value: undefined });
const backups = (await readdir(f.publisher.cacheRoot)).filter((name) =>
name.startsWith(`${f.release.main_exe_hash}.backup-`),
@@ -321,7 +323,7 @@ linux.each([
ok: true,
});
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
await f.publisher.retainVerifiedArchive(verifiedArchive, f.archive),
).toEqual({ ok: true, value: undefined });
expect(
(await readdir(f.publisher.cacheRoot)).filter((name) =>
@@ -345,16 +347,9 @@ linux.each([false, true])(
| ReturnType<LinuxHopperPublisher["retainVerifiedArchive"]>
| undefined;
try {
const verified = await f.publisher.verifyArchive(
"deb",
f.release,
f.archive,
undefined,
f.publicKey,
);
if (!verified.ok) throw verified.error;
const verifiedArchive = await verifyFixtureArchive(f, "deb");
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
await f.publisher.retainVerifiedArchive(verifiedArchive, f.archive),
).toEqual({ ok: true, value: undefined });
const staged = join(f.publisher.cacheRoot, "authenticated-fixture-stage");
await cp(f.cache, staged, { recursive: true });
@@ -399,7 +394,7 @@ linux.each([false, true])(
},
);
retention = f.publisher.retainVerifiedArchive(
verified.value,
verifiedArchive,
f.archive,
controller.signal,
);
@@ -450,16 +445,9 @@ 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;
const verifiedArchive = await verifyFixtureArchive(f, "deb");
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
await f.publisher.retainVerifiedArchive(verifiedArchive, f.archive),
).toEqual({ ok: true, value: undefined });
const broken = "{interrupted cache metadata";
await writeFile(join(f.cache, "release.json"), broken);
@@ -479,7 +467,7 @@ linux("restores old evidence when replacement publication fails", async () => {
syncBuiltinESMExports();
try {
const result = await f.publisher.retainVerifiedArchive(
verified.value,
verifiedArchive,
f.archive,
);
expect(result).toMatchObject({
@@ -506,7 +494,7 @@ linux("restores old evidence when replacement publication fails", async () => {
syncBuiltinESMExports();
}
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
await f.publisher.retainVerifiedArchive(verifiedArchive, f.archive),
).toEqual({ ok: true, value: undefined });
expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({
ok: true,
@@ -522,16 +510,9 @@ linux(
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 verifiedArchive = await verifyFixtureArchive(f, "deb");
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
await f.publisher.retainVerifiedArchive(verifiedArchive, 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"));
@@ -539,7 +520,7 @@ linux(
.spyOn(nativeCommands, "execFileOutput")
.mockRejectedValue(new Error("Fixture native inspection unavailable"));
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
await f.publisher.retainVerifiedArchive(verifiedArchive, f.archive),
).toMatchObject({
ok: false,
error: { reason: "verification_unavailable" },
@@ -621,16 +602,9 @@ linux(
},
);
try {
const verified = await f.publisher.verifyArchive(
"deb",
f.release,
f.archive,
undefined,
f.publicKey,
);
if (!verified.ok) throw verified.error;
const verifiedArchive = await verifyFixtureArchive(f, "deb");
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
await f.publisher.retainVerifiedArchive(verifiedArchive, f.archive),
).toMatchObject({ ok: false, error: { reason: "cleanup_failed" } });
const input = {
cacheDirectory: f.publisher.cacheRoot,
@@ -889,16 +863,9 @@ portableTar(
async () => {
const f = await fixture("arch");
admitFixturePublisher(f.fingerprint);
const verified = await f.publisher.verifyArchive(
"arch",
f.release,
f.archive,
undefined,
f.publicKey,
);
if (!verified.ok) throw verified.error;
const verifiedArchive = await verifyFixtureArchive(f, "arch");
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
await f.publisher.retainVerifiedArchive(verifiedArchive, f.archive),
).toMatchObject({ ok: true });
let reachedMetadataClose!: () => void;
@@ -964,6 +931,7 @@ linux(
controller.abort();
throw new Error("Fixture cleanup unavailable after cancellation");
});
let retryFailure: unknown;
try {
expect(
await f.publisher.verifyArchive(
@@ -977,10 +945,22 @@ linux(
ok: false,
error: { reason: "cleanup_failed" },
});
await expect(retryLinuxHopperPublisherCleanup()).rejects.toMatchObject({
try {
await retryLinuxHopperPublisherCleanup();
} catch (cause: unknown) {
retryFailure = cause;
}
expect(retryFailure).toBeInstanceOf(ArtifactReaderFailure);
expect(retryFailure).toMatchObject({
cleanup: {
resources: [expect.stringContaining("rea-hopper-publisher-")],
},
cleanupLeases: [expect.objectContaining({ released: false })],
});
cleanup.mockRestore();
await retryLinuxHopperPublisherCleanup();
expect(retryFailure).toMatchObject({
cleanupLeases: [expect.objectContaining({ released: true })],
});
} finally {
cleanup.mockRestore();