Files
reverse-engineering-anything/src/hopper/HopperFileOffset.ts
T
7501911f17 fix(lifecycle): retain exact cleanup owners when close or prelaunch fails (#6357)
* fix(hopper): retain file-offset reads when descriptor close fails

A rejected source close was classified as a source or access failure and
dropped the descriptor, so client close reported success. Keep the
completed snapshot or the earlier non-regular-source failure, report the
close as typed cleanup, and retry that exact handle.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(native): retain LLDB prelaunch cleanup ownership

Preparation failures were rewritten as an LLDB exit, and discovery
failures dropped the resolver supervisor. Preserve compiler facts and
the foreign helper marker, and retry only the tracer-owned resolver
before another trace is admitted.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(hopper): bind combined close failures to both owners

A descriptor close and a session close that fail together now stay incomplete until each owner resolves.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(native): retain resolver preparation cleanup ownership

LLDB discovery keeps compiler facts and the foreign helper marker when tool resolution fails during preparation.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-23 01:06:38 +08:00

499 lines
17 KiB
TypeScript

import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { open, type FileHandle } from "node:fs/promises";
import { z } from "zod";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js";
import {
RegularFileCancelled,
RegularFileCleanupFailure,
withOwnedFileHandle,
} from "../filesystem/OwnedRegularFile.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { FileOffsetPartialObservation } from "../domain/native/fileOffsetPartialObservation.js";
import {
readFatSliceDeclarations,
type FatSliceDeclaration,
validateFatSlice,
} from "../domain/apple/machoContainer.js";
import { err, ok, type Result } from "../domain/result.js";
const OPERATION = "address_to_file_offset";
const MAX_FAT_ARCHITECTURES = 128;
const PREFIX_BYTES = 8 + MAX_FAT_ARCHITECTURES * 32;
const observationSchema = z.strictObject({
address: z.string().regex(/^0x[0-9a-f]+$/u),
provider_file_offset: z.int().nonnegative().max(Number.MAX_SAFE_INTEGER),
provider_source_path: z.string().nullable(),
provider_image_header_hex: z
.string()
.regex(/^[a-f0-9]{24}$/u)
.nullable(),
});
type Observation = z.infer<typeof observationSchema>;
/** Exact descriptor owner the caller retries after a rejected source close. */
export interface HopperFileOffsetOwnership {
readonly signal?: AbortSignal;
/** Adopt the handle whose close rejected; the caller owns later retries. */
readonly retainFailedClose?: (owner: OwnedFileHandle) => void;
/** Bind the typed cleanup marker to that same owner before it is returned. */
readonly onCleanupMarker?: (marker: ProviderCleanupError) => void;
}
/** Bind native image-relative offsets to coordinates in the admitted source bytes. */
export async function mapHopperFileOffset(
target: BinaryTarget,
value: JsonValue,
ownership?: HopperFileOffsetOwnership,
): Promise<Result<JsonValue, AnalysisError>> {
const parsed = observationSchema.safeParse(value);
if (!parsed.success)
return err(
new AnalysisOutputError(
OPERATION,
"Hopper's native file mapping observation is malformed",
),
);
const observation = parsed.data;
const partial = (reason: string): FileOffsetPartialObservation => ({
operation: OPERATION,
...observation,
original_file_offset: { available: false, reason },
});
const unavailable = (reason: string) =>
err(
new AnalysisCapabilityUnavailableError("hopper", OPERATION, reason, {
partialObservation: partial(reason),
}),
);
if (target.kind !== "executable")
return unavailable(
"The selected database digest does not identify its original executable bytes. Open that executable to verify original-file coordinates; read_bytes still exposes the database's native bytes.",
);
const signal = ownership?.signal;
if (signal?.aborted) return err(cancelled(partial, signal.reason));
const publish = (marker: ProviderCleanupError): ProviderCleanupError => {
ownership?.onCleanupMarker?.(marker);
return marker;
};
const verification: OffsetVerification = {
target,
observation,
partial,
unavailable,
signal,
publish,
withCleanup: (primary, marker) =>
analysisErrorWithCleanupFailure(
primary,
publish(marker),
OPERATION,
).bindCleanupDependencies([marker]),
};
try {
const captured = await snapshotFromPath(
target.path,
signal,
ownership?.retainFailedClose,
);
return coordinatesForSnapshot(verification, captured).result;
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure)
return cleanupAfterSnapshot(verification, cause);
if (cause instanceof RegularFileCancelled) {
const captured = cause.outcome.value;
if (!isSourceRead(captured)) throw cause;
if (!captured.ok)
return err(
artifactChanged(target.path, captured.error, partial(captured.error)),
);
return err(cancelled(partial, cause.reason));
}
return err(sourceAccessFailure(target.path, cause, partial, signal));
}
}
async function snapshotFromPath(
path: string,
signal: AbortSignal | undefined,
retainFailedClose: HopperFileOffsetOwnership["retainFailedClose"],
): Promise<Result<SourceSnapshot, string>> {
const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
return withOwnedFileHandle(
path,
new OwnedFileHandle(handle),
{
...(signal === undefined ? {} : { signal }),
...(retainFailedClose === undefined ? {} : { retainFailedClose }),
},
async (file) => {
if (!(await file.stat()).isFile())
return err(
`Source ${path} is no longer a regular file. Restore the admitted executable or reopen a regular source.`,
);
return ok(await readSourceSnapshot(file, signal));
},
);
}
const isSourceRead = (
value: unknown,
): value is Result<SourceSnapshot, string> =>
typeof value === "object" &&
value !== null &&
"ok" in value &&
(value.ok === true || value.ok === false);
const cancelled = (
partial: (reason: string) => FileOffsetPartialObservation,
cause?: unknown,
): AnalysisCancelledError =>
new AnalysisCancelledError(OPERATION, {
...(cause === undefined ? {} : { cause }),
partialObservation: partial("Source verification was cancelled"),
});
const artifactChanged = (
path: string,
reason: string,
partialObservation: FileOffsetPartialObservation,
): AnalysisArtifactChangedError =>
new AnalysisArtifactChangedError(OPERATION, path, reason, {
partialObservation,
});
/** Classify a failed read. A rejected close never reaches this helper. */
const sourceAccessFailure = (
path: string,
cause: unknown,
partial: (reason: string) => FileOffsetPartialObservation,
signal: AbortSignal | undefined,
): AnalysisError => {
if (
signal?.aborted === true ||
cause instanceof RegularFileOperationCancelled
)
return cancelled(
partial,
cause instanceof RegularFileOperationCancelled
? cause.reason
: signal?.reason,
);
const reason = `Cannot verify original source bytes: ${cause instanceof Error ? cause.message : String(cause)}`;
const code =
typeof cause === "object" && cause !== null && "code" in cause
? cause.code
: undefined;
if (code === "EACCES" || code === "EPERM")
return new AnalysisAccessDeniedError(OPERATION, path, code, {
cause,
partialObservation: partial(reason),
});
return new BinaryTargetError(path, reason, {
cause,
partialObservation: partial(reason),
});
};
const descriptorCleanup = (
failure: RegularFileCleanupFailure<unknown>,
): ProviderCleanupError => {
const reason =
failure.cleanupCause instanceof Error
? failure.cleanupCause.message
: String(failure.cleanupCause);
return new ProviderCleanupError(
"hopper",
[failure.path],
{ reason },
{ operation: OPERATION, cause: failure },
);
};
interface OffsetVerification {
readonly target: BinaryTarget;
readonly observation: Observation;
readonly partial: (reason: string) => FileOffsetPartialObservation;
readonly unavailable: (reason: string) => Result<JsonValue, AnalysisError>;
readonly signal: AbortSignal | undefined;
readonly withCleanup: (
primary: AnalysisError,
marker: ProviderCleanupError,
) => AnalysisError;
readonly publish: (marker: ProviderCleanupError) => ProviderCleanupError;
}
const cleanupAfterSnapshot = (
verification: OffsetVerification,
failure: RegularFileCleanupFailure<unknown>,
): Result<JsonValue, AnalysisError> => {
const { target, partial, signal, withCleanup, publish } = verification;
const marker = descriptorCleanup(failure);
if (failure.outcome.kind === "failed")
return err(
withCleanup(
sourceAccessFailure(
target.path,
failure.outcome.cause,
partial,
signal,
),
marker,
),
);
if (!isSourceRead(failure.outcome.value)) throw failure;
const captured = failure.outcome.value;
if (!captured.ok)
return err(
withCleanup(
artifactChanged(target.path, captured.error, partial(captured.error)),
marker,
),
);
if (signal?.aborted === true || failure.completionCancellation !== undefined)
return err(
withCleanup(
cancelled(
partial,
failure.completionCancellation?.reason ?? signal?.reason,
),
marker,
),
);
const verified = coordinatesForSnapshot(verification, captured);
if (!verified.result.ok)
return err(withCleanup(verified.result.error, marker));
const published = publish(marker);
published.retainPartialObservation(
partial(
"Source descriptor close failed after the admitted bytes were read",
),
);
published.retainDiagnostics({
source_sha256: captured.value.sha256,
...(verified.fileOffset === undefined
? {}
: { file_offset: verified.fileOffset }),
source_identity: "verified",
});
return err(published);
};
const coordinatesForSnapshot = (
verification: OffsetVerification,
captured: Result<SourceSnapshot, string>,
): {
readonly result: Result<JsonValue, AnalysisError>;
readonly fileOffset?: number;
} => {
const { target, observation, partial, unavailable, signal } = verification;
if (!captured.ok)
return {
result: err(
artifactChanged(target.path, captured.error, partial(captured.error)),
),
};
const snapshot = captured.value;
if (signal?.aborted === true)
return { result: err(cancelled(partial, signal.reason)) };
if (snapshot.sha256 !== target.sha256) {
const reason = `Source ${target.path} differs from the admitted artifact: expected SHA-256 ${target.sha256}, observed ${snapshot.sha256}. Reopen the changed source for a new analysis identity.`;
return {
result: err(artifactChanged(target.path, reason, partial(reason))),
};
}
const mapped = originalOffset(snapshot, observation);
if (!mapped.ok) return { result: unavailable(mapped.error) };
const fileOffset = mapped.value + observation.provider_file_offset;
return {
fileOffset,
result: ok({
address: observation.address,
file_offset: fileOffset,
provider_file_offset: observation.provider_file_offset,
image_base_file_offset: mapped.value,
source_path: target.path,
}),
};
};
interface FatLayout {
readonly slices: readonly FatSliceDeclaration[];
readonly tableEnd: number;
}
function fatLayout(prefix: Buffer): Result<FatLayout | null, string> {
const magic = prefix.length < 4 ? 0 : prefix.readUInt32BE(0);
if (![0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic))
return ok(null);
if (prefix.length < 8) return err("The original FAT header is truncated");
const littleEndian = magic === 0xbebafeca || magic === 0xbfbafeca;
const wide = magic === 0xcafebabf || magic === 0xbfbafeca;
const count = littleEndian ? prefix.readUInt32LE(4) : prefix.readUInt32BE(4);
if (count === 0 || count > MAX_FAT_ARCHITECTURES)
return err("FAT architecture count is outside the supported 1..128 range");
const tableEnd = 8 + count * (wide ? 32 : 20);
const table = prefix.subarray(8, tableEnd);
const parsed = readFatSliceDeclarations(table, { count, wide, littleEndian });
if (parsed.status !== "parsed") return err(parsed.reason);
if (wide) {
for (const slice of parsed.slices)
if (
(littleEndian
? table.readUInt32LE(slice.index * 32 + 28)
: table.readUInt32BE(slice.index * 32 + 28)) !== 0
)
return err(
`FAT64 architecture ${slice.index} has a nonzero reserved field`,
);
}
return ok({ slices: parsed.slices, tableEnd });
}
async function readSourceSnapshot(file: FileHandle, signal?: AbortSignal) {
const prefix = Buffer.alloc(PREFIX_BYTES);
const hash = createHash("sha256");
let size = 0;
let layout: Result<FatLayout | null, string> | undefined;
const headers = new Map<number, Buffer>();
const capture = (bytes: Buffer, position: number) => {
if (layout?.ok !== true || layout.value === null) return;
for (const slice of layout.value.slices) {
const start = Math.max(position, slice.offset);
const end = Math.min(position + bytes.length, slice.offset + 12);
if (end <= start) continue;
let header = headers.get(slice.index);
if (header === undefined) {
header = Buffer.alloc(12);
headers.set(slice.index, header);
}
bytes.copy(
header,
start - slice.offset,
start - position,
end - position,
);
}
};
for await (const chunk of file.createReadStream({
start: 0,
autoClose: false,
...(signal === undefined ? {} : { signal }),
})) {
const bytes = Buffer.from(chunk);
hash.update(bytes);
if (size < prefix.length)
bytes.copy(prefix, size, 0, Math.min(bytes.length, prefix.length - size));
if (layout === undefined && size + bytes.length >= prefix.length) {
layout = fatLayout(prefix);
capture(prefix, 0);
}
capture(bytes, size);
size += bytes.length;
}
if (layout === undefined) {
layout = fatLayout(prefix.subarray(0, size));
capture(prefix.subarray(0, size), 0);
}
return { layout, headers, size, sha256: hash.digest("hex") };
}
type SourceSnapshot = Awaited<ReturnType<typeof readSourceSnapshot>>;
function originalOffset(
snapshot: SourceSnapshot,
observation: Observation,
): Result<number, string> {
const { layout, size, headers } = snapshot;
if (!layout.ok) return layout;
if (layout.value === null)
return observation.provider_file_offset < size
? ok(0)
: err("Provider mapping lies outside the admitted executable bytes");
const header =
observation.provider_image_header_hex === null
? null
: Buffer.from(observation.provider_image_header_hex, "hex");
const loadedMagic = header?.readUInt32BE(0);
if (
header === null ||
loadedMagic === undefined ||
![0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(loadedMagic)
)
return err(
"Hopper did not expose the loaded Mach-O header needed to identify its FAT slice",
);
const little = loadedMagic === 0xcefaedfe || loadedMagic === 0xcffaedfe;
const cpu = little ? header.readUInt32LE(4) : header.readUInt32BE(4);
const subtype = little ? header.readUInt32LE(8) : header.readUInt32BE(8);
const matches = [];
const ranges = [...layout.value.slices].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 err("Original FAT slices overlap");
}
for (const slice of layout.value.slices) {
const reason = validateFatSlice(slice, size, layout.value.tableEnd);
if (reason !== null) return err(reason);
const embedded = headers.get(slice.index);
if (slice.size < 12 || embedded === undefined)
return err(
`FAT architecture ${slice.index} has a truncated Mach-O header`,
);
const embeddedMagic = embedded.readUInt32BE(0);
if (
![0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(embeddedMagic)
)
return err(
`FAT architecture ${slice.index} does not contain a Mach-O image`,
);
const embeddedLittle =
embeddedMagic === 0xcefaedfe || embeddedMagic === 0xcffaedfe;
const identityError = validateFatSlice(slice, size, layout.value.tableEnd, {
cpuType: embeddedLittle
? embedded.readUInt32LE(4)
: embedded.readUInt32BE(4),
cpuSubtype: embeddedLittle
? embedded.readUInt32LE(8)
: embedded.readUInt32BE(8),
});
if (identityError !== null) return err(identityError);
if (slice.cpuType === cpu && slice.cpuSubtype === subtype)
matches.push(slice);
}
const selected = matches.length === 1 ? matches[0] : undefined;
if (selected === undefined)
return err(
`Original FAT slice matching Hopper's loaded header is ${matches.length === 0 ? "missing" : "ambiguous"}`,
);
if (headers.get(selected.index)?.equals(header) !== true)
return err(
"Original FAT slice header disagrees with Hopper's loaded header",
);
if (observation.provider_file_offset >= selected.size)
return err("Provider mapping lies outside the loaded FAT slice");
if (!Number.isSafeInteger(selected.offset + observation.provider_file_offset))
return err("Original file offset exceeds the exact JSON integer range");
return ok(selected.offset);
}