mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
7dd6941393
commit
b231c28cf6
15 files changed
+2341
-329
No files matched your search
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
+793
-203
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user