From b231c28cf64c2f799ada99343f8f21e0e6acb15e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:12:09 +0800 Subject: [PATCH] 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 --- scripts/verify-real-hopper-deadlines.mjs | 36 +- src/application/LinuxHopperPublisher.ts | 14 +- src/domain/apple/machoTargetSelection.ts | 79 ++ src/domain/hopperErrors.ts | 12 +- src/hopper/BridgeLauncher.ts | 128 ++- src/hopper/HopperAnalysisProfile.test.ts | 239 ++++- src/hopper/HopperAnalysisProfile.ts | 104 +- .../HopperMachOImage.copyLifecycle.test.ts | 342 ++++++ src/hopper/HopperMachOImage.test.ts | 404 ++++++- src/hopper/HopperMachOImage.ts | 996 ++++++++++++++---- src/hopper/HopperProvider.ts | 43 +- .../directAnalysisPermissions.test.ts | 6 + .../providers/hopper/bridgeLauncher.test.ts | 140 ++- .../hopper/hopperAnalysisProfile.test.ts | 7 + .../linuxHopperPublisherVerification.test.ts | 120 +-- 15 files changed, 2341 insertions(+), 329 deletions(-) create mode 100644 src/domain/apple/machoTargetSelection.ts create mode 100644 src/hopper/HopperMachOImage.copyLifecycle.test.ts diff --git a/scripts/verify-real-hopper-deadlines.mjs b/scripts/verify-real-hopper-deadlines.mjs index 11e0714314..31b2d856fc 100644 --- a/scripts/verify-real-hopper-deadlines.mjs +++ b/scripts/verify-real-hopper-deadlines.mjs @@ -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", + ); } diff --git a/src/application/LinuxHopperPublisher.ts b/src/application/LinuxHopperPublisher.ts index 8acbefec98..eb63d578e1 100644 --- a/src/application/LinuxHopperPublisher.ts +++ b/src/application/LinuxHopperPublisher.ts @@ -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 => { const causes: unknown[] = []; const observations = []; + const cleanupLeases = new Set(); + 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 => { { cause: new AggregateError(causes, "Hopper publisher cleanup failures"), cleanup, + cleanupLeases: cleanupLeaseAuthorityKnown + ? [...cleanupLeases] + : undefined, }, ); } diff --git a/src/domain/apple/machoTargetSelection.ts b/src/domain/apple/machoTargetSelection.ts new file mode 100644 index 0000000000..f492bc2a0a --- /dev/null +++ b/src/domain/apple/machoTargetSelection.ts @@ -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; + +/** 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> = { + 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; diff --git a/src/domain/hopperErrors.ts b/src/domain/hopperErrors.ts index c5bb75c869..935dd1b48a 100644 --- a/src/domain/hopperErrors.ts +++ b/src/domain/hopperErrors.ts @@ -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 = {}, + ) { + 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; diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index 8e48b5e2a2..99431606f8 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -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> { - 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>; + 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; } diff --git a/src/hopper/HopperAnalysisProfile.test.ts b/src/hopper/HopperAnalysisProfile.test.ts index e5b5745049..247ac99d0d 100644 --- a/src/hopper/HopperAnalysisProfile.test.ts +++ b/src/hopper/HopperAnalysisProfile.test.ts @@ -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(); + 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, + ); + 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, + ); + 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( + "node:fs/promises", + ); + const actualNodeFs = + await vi.importActual("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) => { + const handle = await actualFs.open(...args); + const closeHandle = handle.close.bind(handle); + Object.defineProperty(handle, "close", { + configurable: true, + value: async (): Promise => { + 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); +}); diff --git a/src/hopper/HopperAnalysisProfile.ts b/src/hopper/HopperAnalysisProfile.ts index 26fb390f50..d94d5d9cd8 100644 --- a/src/hopper/HopperAnalysisProfile.ts +++ b/src/hopper/HopperAnalysisProfile.ts @@ -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> => { + 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> => { 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> => { 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> => { 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); diff --git a/src/hopper/HopperMachOImage.copyLifecycle.test.ts b/src/hopper/HopperMachOImage.copyLifecycle.test.ts new file mode 100644 index 0000000000..2da4c55d48 --- /dev/null +++ b/src/hopper/HopperMachOImage.copyLifecycle.test.ts @@ -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[0], +) => + resolveHopperMachOImageWithOwner(target, { + resourceScope: inspectionResources.owner, + }); + +type NativeFs = typeof import("node:fs/promises"); +type NativeFilePath = Parameters[0]; + +const createFileSystemWithCloseHook = async ( + onClose: (path: NativeFilePath, close: () => Promise) => Promise, + onRead?: () => Promise, +) => { + const actualFs = await vi.importActual("node:fs/promises"); + return { + open: async (...args: Parameters) => { + 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>, + cause: Error, + options: Omit[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, + }; +}; diff --git a/src/hopper/HopperMachOImage.test.ts b/src/hopper/HopperMachOImage.test.ts index e4f81ec6a2..0e509b5e58 100644 --- a/src/hopper/HopperMachOImage.test.ts +++ b/src/hopper/HopperMachOImage.test.ts @@ -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[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( + "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) => { + const handle = await actualFs.open(...arguments_); + if (arguments_[0] !== path) return handle; + const sourceClose = async (): Promise => { + 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( + "node:fs/promises", + ); + const fileSystem = { + open: async (...arguments_: Parameters) => { + 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 => { + 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((resolve) => { + releaseWrite = resolve; + }); + const firstWrite = new Promise((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(); +}); diff --git a/src/hopper/HopperMachOImage.ts b/src/hopper/HopperMachOImage.ts index 4b0a48cf51..0c746b507c 100644 --- a/src/hopper/HopperMachOImage.ts +++ b/src/hopper/HopperMachOImage.ts @@ -2,9 +2,17 @@ import { createHash } from "node:crypto"; import { open, type FileHandle } from "node:fs/promises"; import { z } from "zod"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { + AnalysisCleanupLease, + AnalysisCleanupLeaseGroup, +} from "../artifacts/AnalysisCleanupLease.js"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { readFatSliceDeclarations, validateFatSlice, @@ -17,13 +25,29 @@ import { HopperStartError, } from "../domain/hopperErrors.js"; import { err, ok, type Result } from "../domain/result.js"; +import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; -/** Stable source coordinates committed before preparing an owned thin image. */ -export const hopperMachOImageSchema = z.strictObject({ - method: z.union([ - z.literal("fat32_thin_slice"), - z.literal("fat64_thin_slice"), - ]), +/** Narrow file-opening dependency for the owned prepared-image copy. */ +export interface HopperMachOImageFileSystem { + readonly open: typeof open; +} + +export interface HopperMachOImageCopyDependencies { + /** Persistent owner that can retry any retained descriptor cleanup. */ + readonly resourceScope: ArtifactResourceScope; + readonly fileSystem?: HopperMachOImageFileSystem; +} + +export interface HopperMachOImagePreparationOptions extends HopperMachOImageCopyDependencies { + readonly signal?: AbortSignal; + readonly observer?: HopperMachOImageCopyObserver; +} + +const defaultHopperMachOImageFileSystem: HopperMachOImageFileSystem = { open }; + +const hopperFatMachOImageSchema = z.strictObject({ + method: z.enum(["fat32_thin_slice", "fat64_thin_slice"]), architecture_count: z.number().int().min(1).max(128), container_table_sha256: z.string().regex(/^[a-f0-9]{64}$/u), source_offset: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), @@ -32,15 +56,50 @@ export const hopperMachOImageSchema = z.strictObject({ cpu_subtype: z.number().int().nonnegative(), }); -export type HopperMachOImage = z.infer; +const hopperThinMachOImageSchema = z.strictObject({ + method: z.literal("thin_verified_copy"), + byte_length: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + cpu_type: z.number().int().nonnegative(), + cpu_subtype: z.number().int().nonnegative(), +}); -/** Validate the selected FAT slice without depending on Apple's lipo tool. */ +/** Stable source coordinates committed before preparing an owned thin image. */ +export const hopperMachOImageSchema = z.discriminatedUnion("method", [ + hopperFatMachOImageSchema, + hopperThinMachOImageSchema, +]); + +export type HopperMachOImage = z.infer; +type HopperMachOImageResolutionError = + | BinaryTargetError + | AnalysisCancelledError; + +/** Controlled checkpoint for deterministic interruption of a real copy. */ +export interface HopperMachOImageCopyObserver { + readonly afterChunkWritten?: (copiedBytes: number) => Promise; +} + +/** Filesystem access used by the admitted Mach-O preflight. */ +export interface HopperMachOImageResolutionFileSystem { + readonly open: typeof open; +} + +export interface HopperMachOImageResolutionOptions { + readonly resourceScope: RetainedResourceScope; + readonly signal?: AbortSignal; + readonly fileSystem?: HopperMachOImageResolutionFileSystem; +} + +const defaultHopperMachOImageResolutionFileSystem: HopperMachOImageResolutionFileSystem = + { + open, + }; + +/** Validate the selected Mach-O image without depending on Apple's lipo tool. */ export const resolveHopperMachOImage = async ( target: BinaryTarget & { readonly kind: "executable" }, - signal?: AbortSignal, -): Promise< - Result -> => { + options: HopperMachOImageResolutionOptions, +): Promise> => { const invalid = (reason: string, cause?: unknown) => err( new BinaryTargetError( @@ -49,129 +108,349 @@ export const resolveHopperMachOImage = async ( cause === undefined ? undefined : { cause }, ), ); - if (signal?.aborted) return err(new AnalysisCancelledError("open_binary")); + if (options.signal?.aborted) + return err( + new AnalysisCancelledError("open_binary", { + cause: options.signal.reason, + }), + ); if (target.format !== "mach-o" && target.format !== "mach-o-universal") - return invalid("Hopper FAT image preparation requires a Mach-O target"); - let file: FileHandle; + return invalid("Hopper image preparation requires a Mach-O target"); + let opened: FileHandle; try { - file = await open(target.path, "r"); + opened = await ( + options.fileSystem ?? defaultHopperMachOImageResolutionFileSystem + ).open(target.path, "r"); } catch (cause: unknown) { // Keep the OS error so a read-permission denial reports access_denied. return invalid( - `Cannot read FAT source for Hopper image preparation: ${cause instanceof Error ? cause.message : String(cause)}`, + `Cannot read Mach-O source for Hopper image preparation: ${cause instanceof Error ? cause.message : String(cause)}`, cause, ); } + return inspectAndFinalizeHopperMachOImage( + target, + new OwnedFileHandle(opened), + options, + ); +}; + +const inspectAndFinalizeHopperMachOImage = async ( + target: BinaryTarget & { readonly kind: "executable" }, + owner: OwnedFileHandle, + options: HopperMachOImageResolutionOptions, +): Promise> => { + let result: Result; try { - const read = async (offset: number, length: number): Promise => { - const bytes = Buffer.alloc(length); - const { bytesRead } = await file.read(bytes, 0, length, offset); - return bytes.subarray(0, bytesRead); - }; - const header = await read(0, 8); - const magic = header.length === 8 ? header.readUInt32BE(0) : 0; - const isFatMagic = [ - 0xcafebabe, 0xcafebabf, 0xbebafeca, 0xbfbafeca, - ].includes(magic); - if (!isFatMagic) - return invalid( - "FAT source header changed before Hopper image preparation", - ); - const wide = magic === 0xcafebabf || magic === 0xbfbafeca; - const littleEndian = magic === 0xbebafeca || magic === 0xbfbafeca; - const entrySize = wide ? 32 : 20; - const count = littleEndian - ? header.readUInt32LE(4) - : header.readUInt32BE(4); - const size = (await file.stat()).size; - const tableEnd = 8 + count * entrySize; - if (count === 0 || count > 128 || tableEnd > size) - return invalid( - "FAT architecture table is truncated or exceeds the supported 128 entries", - ); - const table = await read(8, count * entrySize); - const parsed = readFatSliceDeclarations(table, { - count, - wide, - littleEndian, - }); - if (parsed.status === "malformed") return invalid(parsed.reason); - const cpuTypes = { x86: 7, x86_64: 0x1000007, arm: 12, arm64: 0x100000c }; - const matches = []; - const ranges = []; - for (const slice of parsed.slices) { - if (signal?.aborted) - return err(new AnalysisCancelledError("open_binary")); - const rangeError = validateFatSlice(slice, size, tableEnd); - if (rangeError !== null) return invalid(rangeError); - if (wide) { - const reserved = littleEndian - ? table.readUInt32LE(slice.index * 32 + 28) - : table.readUInt32BE(slice.index * 32 + 28); - if (reserved !== 0) - return invalid( - `FAT64 architecture ${slice.index} has a nonzero reserved field`, - ); - } - if (slice.size < 12) - return invalid( - `FAT architecture ${slice.index} has a truncated Mach-O header`, - ); - const embedded = await read(slice.offset, 12); - if (embedded.length !== 12) - return invalid(`FAT architecture ${slice.index} became truncated`); - const thinMagic = embedded.readUInt32BE(0); - if (![0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(thinMagic)) - return invalid( - `FAT architecture ${slice.index} does not contain a Mach-O image`, - ); - const little = thinMagic === 0xcefaedfe || thinMagic === 0xcffaedfe; - const identity = { - cpuType: little ? embedded.readUInt32LE(4) : embedded.readUInt32BE(4), - cpuSubtype: little - ? embedded.readUInt32LE(8) - : embedded.readUInt32BE(8), - }; - const identityError = validateFatSlice(slice, size, tableEnd, identity); - if (identityError !== null) return invalid(identityError); - ranges.push(slice); - if (slice.cpuType === cpuTypes[target.architecture]) matches.push(slice); - } - ranges.sort((a, b) => a.offset - b.offset); - for (let index = 1; index < ranges.length; index++) { - const previous = ranges[index - 1]; - const current = ranges[index]; - if ( - previous !== undefined && - current !== undefined && - current.offset < previous.offset + previous.size - ) - return invalid("FAT slices overlap"); - } - const selected = selectHopperSlice(matches, target.architecture); - if (selected === undefined) - return invalid( - `FAT selection for ${target.architecture} is ${matches.length === 0 ? "missing" : "ambiguous"}; extract the exact architecture into a thin image before opening it`, - ); - return ok({ - method: wide ? "fat64_thin_slice" : "fat32_thin_slice", - architecture_count: count, - container_table_sha256: createHash("sha256") - .update(header) - .update(table) - .digest("hex"), - source_offset: selected.offset, - byte_length: selected.size, - cpu_type: selected.cpuType, - cpu_subtype: selected.cpuSubtype, - }); - } catch (cause: unknown) { - return invalid( - `Cannot validate FAT image: ${cause instanceof Error ? cause.message : String(cause)}`, + result = await inspectHopperMachOImage( + target, + owner.handle, + options.signal, ); - } finally { - await file.close(); + } catch (cause: unknown) { + result = + cause instanceof BinaryTargetError || + cause instanceof AnalysisCancelledError + ? err(cause) + : err( + new BinaryTargetError( + target.path, + `Cannot validate Mach-O image: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ), + ); } + const cancellationBeforeClose = options.signal?.aborted === true; + let closeOutcome: + | { readonly kind: "closed" } + | { readonly kind: "failed"; readonly cause: unknown }; + try { + await owner.close(); + closeOutcome = { kind: "closed" }; + } catch (cause: unknown) { + closeOutcome = { kind: "failed", cause }; + } + if (closeOutcome.kind === "closed") { + if (result.ok && options.signal?.aborted) + return err( + new AnalysisCancelledError("open_binary", { + cause: options.signal.reason, + }), + ); + return result; + } + return retainHopperPreflightCloseFailure({ + target, + owner, + resourceScope: options.resourceScope, + result, + cancellationBeforeClose, + signal: options.signal, + closeFailure: closeOutcome.cause, + }); +}; + +const retainHopperPreflightCloseFailure = (options: { + readonly target: BinaryTarget & { readonly kind: "executable" }; + readonly owner: OwnedFileHandle; + readonly resourceScope: RetainedResourceScope; + readonly result: Result; + readonly cancellationBeforeClose: boolean; + readonly signal: AbortSignal | undefined; + readonly closeFailure: unknown; +}): Result => { + const resource = `hopper-macho-preflight:${options.target.path}`; + const lease = AnalysisCleanupLease.forCloseOwner(options.owner, resource); + options.resourceScope.retain( + { + close: () => lease.release(), + get cleanupResources() { + return lease.cleanupResources; + }, + }, + resource, + ); + const cleanup = { + reason: + options.closeFailure instanceof Error + ? options.closeFailure.message + : String(options.closeFailure), + resources: [resource], + }; + const cancellationIsPrimary = + (!options.result.ok && + options.result.error instanceof AnalysisCancelledError) || + (options.result.ok && options.cancellationBeforeClose); + let failure: AnalysisError; + let closeIsPrimary = false; + if (!options.result.ok) { + failure = + options.result.error instanceof BinaryTargetError + ? new BinaryTargetError( + options.result.error.path, + options.result.error.reason, + { + ...(options.result.error.cause === undefined + ? {} + : { cause: options.result.error.cause }), + ...(options.result.error.constraint === undefined + ? {} + : { constraint: options.result.error.constraint }), + cleanup, + }, + ) + : new AnalysisCancelledError("open_binary", { + cause: options.result.error.cause, + cleanup, + }); + } else if (options.cancellationBeforeClose) { + failure = new AnalysisCancelledError("open_binary", { + cause: options.signal?.reason, + cleanup, + }); + } else { + failure = new BinaryTargetError( + options.target.path, + "Could not confirm closure of the Mach-O preflight handle", + { cause: options.closeFailure, cleanup }, + ); + closeIsPrimary = true; + } + const cleanupError = new ProviderCleanupError( + "hopper", + [resource], + { reason: cleanup.reason }, + { + cause: options.closeFailure, + operation: "resolve_analysis_profile", + }, + ); + if (!closeIsPrimary) { + lease.bindCleanupError(cleanupError); + failure.retainSecondaryFailure(cleanupError); + } + if (options.signal?.aborted && !cancellationIsPrimary) + failure.retainSecondaryFailure( + new AnalysisCancelledError("open_binary", { + cause: options.signal.reason, + }), + ); + new AnalysisCleanupLeaseGroup(failure, [lease]); + return err(failure); +}; + +const inspectHopperMachOImage = async ( + target: BinaryTarget & { readonly kind: "executable" }, + file: FileHandle, + signal: AbortSignal | undefined, +): Promise> => { + const invalid = (reason: string, cause?: unknown) => + err( + new BinaryTargetError( + target.path, + reason, + cause === undefined ? undefined : { cause }, + ), + ); + const read = async (offset: number, length: number): Promise => { + const bytes = Buffer.alloc(length); + const { bytesRead } = await file.read(bytes, 0, length, offset); + return bytes.subarray(0, bytesRead); + }; + const header = await read(0, 8); + const magic = header.length === 8 ? header.readUInt32BE(0) : 0; + if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic)) { + if (target.format !== "mach-o") + return invalid("Thin Mach-O source disagrees with its admitted format"); + const prefix = Buffer.alloc(12); + const hash = createHash("sha256"); + let prefixLength = 0; + let size = 0; + for await (const chunk of readFileHandleChunks(file, { + ...(signal === undefined ? {} : { signal }), + cancellationFailure: (reason) => + new AnalysisCancelledError("open_binary", { cause: reason }), + })) { + if (signal?.aborted) + return err( + new AnalysisCancelledError("open_binary", { + cause: signal.reason, + }), + ); + const bytes = Buffer.from(chunk); + hash.update(bytes); + size += bytes.length; + if (!Number.isSafeInteger(size)) + return invalid("Thin Mach-O exceeds exact selection coordinates"); + const copied = Math.min(prefix.length - prefixLength, bytes.length); + if (copied > 0) { + bytes.copy(prefix, prefixLength, 0, copied); + prefixLength += copied; + } + } + if (signal?.aborted) + return err( + new AnalysisCancelledError("open_binary", { cause: signal.reason }), + ); + if (prefixLength !== prefix.length) + return invalid("Thin Mach-O header is truncated"); + const observedSha256 = hash.digest("hex"); + if (observedSha256 !== target.sha256) + return invalid( + `Thin Mach-O source changed after target admission: expected SHA-256 ${target.sha256}, observed ${observedSha256}`, + ); + const little = magic === 0xcefaedfe || magic === 0xcffaedfe; + const headerMagic = prefix.readUInt32BE(0); + if (headerMagic !== magic) + return invalid("Thin Mach-O header changed during profile resolution"); + return ok({ + method: "thin_verified_copy", + byte_length: size, + cpu_type: little ? prefix.readUInt32LE(4) : prefix.readUInt32BE(4), + cpu_subtype: little ? prefix.readUInt32LE(8) : prefix.readUInt32BE(8), + }); + } + const isFatMagic = [0xcafebabe, 0xcafebabf, 0xbebafeca, 0xbfbafeca].includes( + magic, + ); + if (!isFatMagic) + return invalid( + "Mach-O source header changed before Hopper image preparation", + ); + const wide = magic === 0xcafebabf || magic === 0xbfbafeca; + const littleEndian = magic === 0xbebafeca || magic === 0xbfbafeca; + const entrySize = wide ? 32 : 20; + const count = littleEndian ? header.readUInt32LE(4) : header.readUInt32BE(4); + const size = (await file.stat()).size; + const tableEnd = 8 + count * entrySize; + if (count === 0 || count > 128 || tableEnd > size) + return invalid( + "FAT architecture table is truncated or exceeds the supported 128 entries", + ); + const table = await read(8, count * entrySize); + const parsed = readFatSliceDeclarations(table, { + count, + wide, + littleEndian, + }); + if (parsed.status === "malformed") return invalid(parsed.reason); + if ( + target.architecture !== "x86" && + target.architecture !== "x86_64" && + target.architecture !== "arm" && + target.architecture !== "arm64" + ) + return invalid("Hopper cannot select the admitted Mach-O architecture"); + const architecture = target.architecture; + const cpuTypes = { x86: 7, x86_64: 0x1000007, arm: 12, arm64: 0x100000c }; + const matches = []; + const ranges = []; + for (const slice of parsed.slices) { + if (signal?.aborted) + return err( + new AnalysisCancelledError("open_binary", { cause: signal.reason }), + ); + const rangeError = validateFatSlice(slice, size, tableEnd); + if (rangeError !== null) return invalid(rangeError); + if (wide) { + const reserved = littleEndian + ? table.readUInt32LE(slice.index * 32 + 28) + : table.readUInt32BE(slice.index * 32 + 28); + if (reserved !== 0) + return invalid( + `FAT64 architecture ${slice.index} has a nonzero reserved field`, + ); + } + if (slice.size < 12) + return invalid( + `FAT architecture ${slice.index} has a truncated Mach-O header`, + ); + const embedded = await read(slice.offset, 12); + if (embedded.length !== 12) + return invalid(`FAT architecture ${slice.index} became truncated`); + const thinMagic = embedded.readUInt32BE(0); + if (![0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(thinMagic)) + return invalid( + `FAT architecture ${slice.index} does not contain a Mach-O image`, + ); + const little = thinMagic === 0xcefaedfe || thinMagic === 0xcffaedfe; + const identity = { + cpuType: little ? embedded.readUInt32LE(4) : embedded.readUInt32BE(4), + cpuSubtype: little ? embedded.readUInt32LE(8) : embedded.readUInt32BE(8), + }; + const identityError = validateFatSlice(slice, size, tableEnd, identity); + if (identityError !== null) return invalid(identityError); + ranges.push(slice); + if (slice.cpuType === cpuTypes[architecture]) matches.push(slice); + } + ranges.sort((a, b) => a.offset - b.offset); + for (let index = 1; index < ranges.length; index++) { + const previous = ranges[index - 1]; + const current = ranges[index]; + if ( + previous !== undefined && + current !== undefined && + current.offset < previous.offset + previous.size + ) + return invalid("FAT slices overlap"); + } + const selected = selectHopperSlice(matches, architecture); + if (selected === undefined) + return invalid( + `FAT selection for ${architecture} is ${matches.length === 0 ? "missing" : "ambiguous"}; extract the exact architecture into a thin image before opening it`, + ); + return ok({ + method: wide ? "fat64_thin_slice" : "fat32_thin_slice", + architecture_count: count, + container_table_sha256: createHash("sha256") + .update(header) + .update(table) + .digest("hex"), + source_offset: selected.offset, + byte_length: selected.size, + cpu_type: selected.cpuType, + cpu_subtype: selected.cpuSubtype, + }); }; /** @@ -202,87 +481,398 @@ export const prepareHopperMachOImage = async ( sourceSha256: string, image: HopperMachOImage, destination: string, - signal?: AbortSignal, + options: HopperMachOImagePreparationOptions, ): Promise> => { - if (signal?.aborted) return err(new HopperCancelledError()); + const copyDependencies = { + resourceScope: options.resourceScope, + fileSystem: options.fileSystem ?? defaultHopperMachOImageFileSystem, + }; + const { signal, observer } = options; + if (signal?.aborted) return err(new HopperCancelledError(signal.reason)); + if (image.method === "thin_verified_copy") { + const prepared = await prepareThinHopperMachOImage( + sourcePath, + sourceSha256, + image, + destination, + { signal, observer, ...copyDependencies }, + ); + return signal?.aborted && prepared.ok + ? err(new HopperCancelledError(signal.reason)) + : prepared; + } const tableEntrySize = image.method === "fat64_thin_slice" ? 32 : 20; - try { - const source = await open(sourcePath, "r"); - try { - const output = await open(destination, "wx", 0o600); - try { - const hash = createHash("sha256"); - const tableHash = createHash("sha256"); - const tableEnd = 8 + image.architecture_count * tableEntrySize; - const thinHeader: Buffer[] = []; - let position = 0; - let copied = 0; - for await (const chunk of source.createReadStream({ - autoClose: false, - })) { - if (signal?.aborted) return err(new HopperCancelledError()); - const bytes = Buffer.from(chunk); - hash.update(bytes); - if (position < tableEnd) - tableHash.update( - bytes.subarray(0, Math.min(bytes.length, tableEnd - position)), - ); - const start = Math.max(0, image.source_offset - position); - const end = Math.min( - bytes.length, - image.source_offset + image.byte_length - position, - ); - if (end > start) { - if (copied < 12) - thinHeader.push( - bytes.subarray(start, Math.min(end, start + 12 - copied)), - ); - await output.writeFile(bytes.subarray(start, end)); - copied += end - start; - } - position += bytes.length; - } - const observed = hash.digest("hex"); - const header = Buffer.concat(thinHeader); - const magic = header.length === 12 ? header.readUInt32BE(0) : 0; - const little = magic === 0xcefaedfe || magic === 0xcffaedfe; - const headerMatches = - header.length === 12 && - [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic) && - (little ? header.readUInt32LE(4) : header.readUInt32BE(4)) === - image.cpu_type && - (little ? header.readUInt32LE(8) : header.readUInt32BE(8)) === - image.cpu_subtype; - if ( - tableHash.digest("hex") !== image.container_table_sha256 || - !headerMatches - ) - return err( - new HopperStartError({ - userMessage: `FAT source ${sourcePath} no longer agrees with its committed architecture table or selected Mach-O header. Open the stable source again.`, - }), - ); - if (observed !== sourceSha256 || copied !== image.byte_length) - return err( - new HopperStartError({ - userMessage: `FAT source ${sourcePath} changed before launch: expected SHA-256 ${sourceSha256}, observed ${observed}; copied ${copied} of ${image.byte_length} selected bytes. Open the stable source again.`, - }), - ); - return ok(destination); - } finally { - await output.close(); - } - } finally { - await source.close(); - } - } catch (cause: unknown) { - return signal?.aborted - ? err(new HopperCancelledError()) - : err( - new HopperStartError({ - cause, - userMessage: `Cannot prepare the selected FAT image from ${sourcePath}: ${cause instanceof Error ? cause.message : String(cause)}`, - }), + const hash = createHash("sha256"); + const tableHash = createHash("sha256"); + const tableEnd = 8 + image.architecture_count * tableEntrySize; + const thinHeader: Buffer[] = []; + let copied = 0; + const copiedResult = await copyHopperSource({ + sourcePath, + destination, + signal, + observer, + ...copyDependencies, + selectBytes: (bytes, position) => { + hash.update(bytes); + if (position < tableEnd) + tableHash.update( + bytes.subarray(0, Math.min(bytes.length, tableEnd - position)), ); + const start = Math.max(0, image.source_offset - position); + const end = Math.min( + bytes.length, + image.source_offset + image.byte_length - position, + ); + if (end <= start) return undefined; + if (copied < 12) + thinHeader.push( + bytes.subarray(start, Math.min(end, start + 12 - copied)), + ); + copied += end - start; + return bytes.subarray(start, end); + }, + failureContext: `Cannot prepare the selected FAT image from ${sourcePath}`, + }); + if (!copiedResult.ok) return copiedResult; + const observed = hash.digest("hex"); + const header = Buffer.concat(thinHeader); + const magic = header.length === 12 ? header.readUInt32BE(0) : 0; + const little = magic === 0xcefaedfe || magic === 0xcffaedfe; + const headerMatches = + header.length === 12 && + [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic) && + (little ? header.readUInt32LE(4) : header.readUInt32BE(4)) === + image.cpu_type && + (little ? header.readUInt32LE(8) : header.readUInt32BE(8)) === + image.cpu_subtype; + if ( + tableHash.digest("hex") !== image.container_table_sha256 || + !headerMatches + ) + return err( + new HopperStartError({ + userMessage: `FAT source ${sourcePath} no longer agrees with its committed architecture table or selected Mach-O header. Open the stable source again.`, + }), + ); + if (observed !== sourceSha256 || copied !== image.byte_length) + return err( + new HopperStartError({ + userMessage: `FAT source ${sourcePath} changed before launch: expected SHA-256 ${sourceSha256}, observed ${observed}; copied ${copied} of ${image.byte_length} selected bytes. Open the stable source again.`, + }), + ); + return signal?.aborted + ? err(new HopperCancelledError(signal.reason)) + : ok(destination); +}; + +const prepareThinHopperMachOImage = async ( + sourcePath: string, + sourceSha256: string, + image: Extract, + destination: string, + options: { + readonly signal: AbortSignal | undefined; + readonly observer: HopperMachOImageCopyObserver | undefined; + readonly resourceScope: ArtifactResourceScope; + readonly fileSystem: HopperMachOImageFileSystem; + }, +): Promise> => { + const { signal, observer, resourceScope, fileSystem } = options; + const hash = createHash("sha256"); + const prefix = Buffer.alloc(12); + let prefixLength = 0; + const copiedResult = await copyHopperSource({ + sourcePath, + destination, + signal, + observer, + resourceScope, + fileSystem, + selectBytes: (bytes) => { + hash.update(bytes); + const prefixBytes = Math.min(prefix.length - prefixLength, bytes.length); + if (prefixBytes > 0) { + bytes.copy(prefix, prefixLength, 0, prefixBytes); + prefixLength += prefixBytes; + } + return bytes; + }, + failureContext: `Cannot prepare the verified thin image from ${sourcePath}`, + }); + if (!copiedResult.ok) return copiedResult; + const copied = copiedResult.value; + if (!Number.isSafeInteger(copied)) + return err( + new HopperStartError({ + userMessage: `Thin Mach-O source ${sourcePath} exceeds exact copy coordinates.`, + }), + ); + const observed = hash.digest("hex"); + const magic = prefixLength === prefix.length ? prefix.readUInt32BE(0) : 0; + const little = magic === 0xcefaedfe || magic === 0xcffaedfe; + const headerMatches = + prefixLength === prefix.length && + [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic) && + (little ? prefix.readUInt32LE(4) : prefix.readUInt32BE(4)) === + image.cpu_type && + (little ? prefix.readUInt32LE(8) : prefix.readUInt32BE(8)) === + image.cpu_subtype; + if ( + observed !== sourceSha256 || + copied !== image.byte_length || + !headerMatches + ) + return err( + new HopperStartError({ + userMessage: `Thin Mach-O source ${sourcePath} no longer agrees with its admitted digest, size, or selected header. Open the stable source again.`, + }), + ); + return signal?.aborted + ? err(new HopperCancelledError(signal.reason)) + : ok(destination); +}; + +type HopperImageHandle = OwnedFileHandle | undefined; +type HopperImageCloseFailure = { + readonly resource: string; + readonly cause: unknown; +}; +type HopperImageCleanupAttempt = Extract< + Awaited>, + { readonly kind: "failed" } +>; + +interface HopperImageCopyOperation { + readonly copied: number; + readonly operationFailure: + | { readonly failed: false } + | { + readonly failed: true; + readonly cause: unknown; + }; + readonly cancelled: + | { readonly cancelled: false } + | { + readonly cancelled: true; + readonly reason: unknown; + }; + readonly source: HopperImageHandle; + readonly output: HopperImageHandle; +} + +interface HopperImageCopyFinalization { + readonly closeFailures: readonly HopperImageCloseFailure[]; + readonly retained: readonly HopperImageCleanupAttempt[]; +} + +/** Own source/output handles and copy finalization for both Mach-O layouts. */ +const copyHopperSource = async (options: { + readonly sourcePath: string; + readonly destination: string; + readonly signal: AbortSignal | undefined; + readonly observer: HopperMachOImageCopyObserver | undefined; + readonly resourceScope: ArtifactResourceScope; + readonly fileSystem: HopperMachOImageFileSystem; + readonly selectBytes: (chunk: Buffer, position: number) => Buffer | undefined; + readonly failureContext: string; +}): Promise> => { + if (options.signal?.aborted) + return err(new HopperCancelledError(options.signal.reason)); + const operation = await performHopperImageCopy(options); + const cancellationBeforeFinalization = operation.cancelled.cancelled + ? operation.cancelled + : options.signal?.aborted === true + ? { cancelled: true as const, reason: options.signal.reason } + : { cancelled: false as const }; + const finalization = await closeHopperImageHandles(operation, options); + const cancelled = + cancellationBeforeFinalization.cancelled || + (finalization.closeFailures.length === 0 && + options.signal?.aborted === true); + const cancelReason = cancellationBeforeFinalization.cancelled + ? cancellationBeforeFinalization.reason + : options.signal?.reason; + if ( + !operation.operationFailure.failed && + !cancelled && + finalization.closeFailures.length === 0 + ) + return ok(operation.copied); + return hopperImageCopyFailure({ + operation, + finalization, + failureContext: options.failureContext, + cancelled, + cancelReason, + }); +}; + +const performHopperImageCopy = async (options: { + readonly sourcePath: string; + readonly destination: string; + readonly signal: AbortSignal | undefined; + readonly observer: HopperMachOImageCopyObserver | undefined; + readonly fileSystem: HopperMachOImageFileSystem; + readonly selectBytes: (chunk: Buffer, position: number) => Buffer | undefined; +}): Promise => { + let copied = 0; + let source: HopperImageHandle; + let output: HopperImageHandle; + try { + source = new OwnedFileHandle( + await options.fileSystem.open(options.sourcePath, "r"), + ); + output = new OwnedFileHandle( + await options.fileSystem.open(options.destination, "wx", 0o600), + ); + } catch (cause: unknown) { + return { + copied, + operationFailure: { failed: true, cause }, + cancelled: { cancelled: false }, + source, + output, + }; + } + + try { + let position = 0; + for await (const bytes of readFileHandleChunks(source.handle)) { + if (options.signal?.aborted) + return { + copied, + operationFailure: { failed: false }, + cancelled: { cancelled: true, reason: options.signal.reason }, + source, + output, + }; + const selected = options.selectBytes(bytes, position); + if (selected !== undefined && selected.length > 0) { + await output.handle.writeFile(selected); + copied += selected.length; + await options.observer?.afterChunkWritten?.(copied); + } + position += bytes.length; + } + return { + copied, + operationFailure: { failed: false }, + cancelled: { cancelled: false }, + source, + output, + }; + } catch (cause: unknown) { + return { + copied, + operationFailure: { failed: true, cause }, + cancelled: { cancelled: false }, + source, + output, + }; } }; + +const closeHopperImageHandles = async ( + operation: HopperImageCopyOperation, + options: { + readonly sourcePath: string; + readonly destination: string; + readonly resourceScope: ArtifactResourceScope; + }, +): Promise => { + const closeFailures: HopperImageCloseFailure[] = []; + const retained: HopperImageCleanupAttempt[] = []; + const close = async ( + owner: HopperImageHandle, + resource: string, + ): Promise => { + if (owner === undefined) return; + try { + await owner.close(); + } catch (cause: unknown) { + closeFailures.push({ resource, cause }); + const attempt = await options.resourceScope.release({ + kind: "file-handle", + resource, + handle: owner, + }); + if (attempt.kind === "failed") retained.push(attempt); + } + }; + await close(operation.output, `hopper-prepared-image:${options.destination}`); + await close(operation.source, `hopper-image-source:${options.sourcePath}`); + return { closeFailures, retained }; +}; + +const hopperImageCopyFailure = (options: { + readonly operation: HopperImageCopyOperation; + readonly finalization: HopperImageCopyFinalization; + readonly failureContext: string; + readonly cancelled: boolean; + readonly cancelReason: unknown; +}): Result => { + const { operation, finalization } = options; + const cleanupErrors = finalization.retained.map((attempt) => { + const cleanupError = new ProviderCleanupError( + "hopper", + [attempt.owner.resource], + { + reason: + attempt.cause instanceof Error + ? attempt.cause.message + : String(attempt.cause), + }, + { cause: attempt.cause }, + ); + attempt.lease.bindCleanupError(cleanupError); + return cleanupError; + }); + const cleanup = + cleanupErrors.length === 0 + ? undefined + : { + reason: cleanupErrors.map((failure) => failure.message).join("; "), + resources: [ + ...new Set( + cleanupErrors.flatMap((failure) => failure.cleanupResources), + ), + ], + }; + const primaryFailure = operation.operationFailure.failed + ? operation.operationFailure.cause + : finalization.closeFailures[0]?.cause; + const resultError = + options.cancelled && !operation.operationFailure.failed + ? new HopperCancelledError( + options.cancelReason, + cleanup === undefined ? {} : { cleanup }, + ) + : new HopperStartError({ + ...(operation.operationFailure.failed || + finalization.closeFailures.length > 0 + ? { cause: primaryFailure } + : {}), + userMessage: `${options.failureContext}: ${primaryFailure instanceof Error ? primaryFailure.message : String(primaryFailure ?? "caller cancellation")}`, + ...(cleanup === undefined ? {} : { cleanup }), + }); + for (const closeFailure of finalization.closeFailures) { + if ( + !operation.operationFailure.failed && + !options.cancelled && + closeFailure === finalization.closeFailures[0] + ) + continue; + resultError.retainSecondaryFailure( + new HopperStartError({ + cause: closeFailure.cause, + userMessage: `Could not confirm closure of ${closeFailure.resource}.`, + }), + ); + } + for (const cleanupError of cleanupErrors) + resultError.retainSecondaryFailure(cleanupError); + if (cleanupErrors.length > 0) + resultError.bindCleanupDependencies(cleanupErrors); + return err(resultError); +}; diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index 6108944405..c9c9299474 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -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; + 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, diff --git a/tests/boundary/filesystem/directAnalysisPermissions.test.ts b/tests/boundary/filesystem/directAnalysisPermissions.test.ts index e9e1838288..20cf0a7b46 100644 --- a/tests/boundary/filesystem/directAnalysisPermissions.test.ts +++ b/tests/boundary/filesystem/directAnalysisPermissions.test.ts @@ -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"); diff --git a/tests/boundary/providers/hopper/bridgeLauncher.test.ts b/tests/boundary/providers/hopper/bridgeLauncher.test.ts index 85a561bfd9..5d2611ecc6 100644 --- a/tests/boundary/providers/hopper/bridgeLauncher.test.ts +++ b/tests/boundary/providers/hopper/bridgeLauncher.test.ts @@ -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[0], + flags: Parameters[1], + mode?: Parameters[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"); diff --git a/tests/boundary/providers/hopper/hopperAnalysisProfile.test.ts b/tests/boundary/providers/hopper/hopperAnalysisProfile.test.ts index 46b71deb77..af087e7b1a 100644 --- a/tests/boundary/providers/hopper/hopperAnalysisProfile.test.ts +++ b/tests/boundary/providers/hopper/hopperAnalysisProfile.test.ts @@ -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, }); diff --git a/tests/boundary/setup/linuxHopperPublisherVerification.test.ts b/tests/boundary/setup/linuxHopperPublisherVerification.test.ts index 28d9791a9f..ecb76d4af1 100644 --- a/tests/boundary/setup/linuxHopperPublisherVerification.test.ts +++ b/tests/boundary/setup/linuxHopperPublisherVerification.test.ts @@ -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>, + 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 | 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();