Files
reverse-engineering-anything/src/native/apple/AppleIpsParser.ts
T
morluto cabd56ea71 refactor(domain): give strict UTF-8 decoding one owner with an explicit BOM policy (#10728)
* refactor(domain): give strict UTF-8 decoding one owner with an explicit BOM policy

* fix(domain): keep the leading-BOM behaviour every migrated UTF-8 decoder had

* refactor(domain): read the domain layer's strict UTF-8 through the BOM-policy owner (#10803)

Twelve domain decoders built their own fatal TextDecoder with ignoreBOM: true. They now use strictUtf8Decoder("preserve"), which keeps that behaviour: each reads bytes where a leading U+FEFF is content (binary-format names and fields, JSON and XML read byte-for-byte, recorded bodies, registry seeds whose kept bytes are re-emitted). The counted baseline drops from 74 to 62.

* refactor(artifacts): read artifact readers' strict UTF-8 through the BOM-policy owner (#10871)

Fourteen artifact reader modules (17 sites) built their own fatal TextDecoder. They now take it from strictUtf8Decoder with the policy each had: every one kept a leading BOM (ignoreBOM: true), so they use "preserve"; JavaScriptArtifactFiles keeps it for JavaScript and HTML entries and strips it otherwise, as before. The strict-utf8-decoder baseline drops those 17 sites.

* refactor(domain): drop TextDecoder imports left by the strict UTF-8 migration

* refactor(application): read MCP registration metadata through the strict UTF-8 owner

* refactor(formats): read binary-format readers' strict UTF-8 through the BOM-policy owner (#10903)

* refactor(formats): read binary-format readers' strict UTF-8 through the BOM-policy owner

* refactor(formats): drop the model readers' unused TextDecoder imports

* refactor(browser): read browser capture and fetch strict UTF-8 through the BOM-policy owner (#10935)

* refactor(browser): read browser capture and fetch strict UTF-8 through the BOM-policy owner

* refactor(native): read LLDB and Mach-O name strict UTF-8 through the BOM-policy owner (#10936)

* refactor(native): read LLDB and Mach-O name strict UTF-8 through the BOM-policy owner

* refactor(process): read MCP and NDJSON transport frames' strict UTF-8 through the BOM-policy owner (#10937)

* refactor(process): read MCP and NDJSON transport frames' strict UTF-8 through the BOM-policy owner

* refactor(application): read JSON and file inputs' strict UTF-8 through the BOM-policy owner (#10938)

* refactor(application): read JSON and file inputs' strict UTF-8 through the BOM-policy owner

* refactor(providers): read IDA, Electron, Ghidra and reference strict UTF-8 through the BOM-policy owner (#10939)

* refactor(providers): read IDA, Electron, Ghidra and reference strict UTF-8 through the BOM-policy owner

* refactor(evm): read the hex bytecode carrier's strict UTF-8 through the owner and refuse new local decoders (#10940)

* refactor(evm): read the hex bytecode carrier's strict UTF-8 through the BOM-policy owner

* chore(architecture): match local strict UTF-8 decoders in any option order
2026-11-15 19:54:39 +08:00

463 lines
16 KiB
TypeScript

import {
APPLE_IPS_IMAGE_SOURCE_MEANING,
APPLE_IPS_IMAGE_SOURCES,
type AppleIps,
type AppleIpsFrame,
type AppleIpsImage,
type AppleIpsImageSource,
type AppleIpsThread,
type InspectAppleIpsInput,
} from "../../domain/native/appleIps.js";
import { APPLE_IPS_FORMAT } from "./AppleIps.errors.js";
import { malformedInput, unsupportedInput } from "../../domain/parseFailure.js";
import { classify } from "./AppleIpsClassification.js";
import { FieldReader } from "./AppleIpsFieldReader.js";
import {
consumeJson,
ipsBodyShape,
IPS_METADATA_SHAPE,
} from "./AppleIpsJsonShape.js";
import type { LosslessJson } from "./losslessJson.js";
import { strictUtf8Decoder } from "../../domain/strictUtf8.js";
type Artifact = AppleIps["artifact"];
const ZERO_UUID = "00000000000000000000000000000000";
const recordedUuidPattern =
/^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/u;
const IPS_LIMITATIONS = [
"Rows reproduce the selected .ips JSON; they do not establish that the process still exists or that a live kernel agrees with the recorded state.",
"Frames join a caller-selected Mach-O or dSYM only by matching slice LC_UUID and architecture; function and symbol resolution at a joined static address stays unobserved.",
"Symbol names printed inside the report are recorded claims and are not verified.",
];
const utf8 = strictUtf8Decoder("strip-signature");
const canonicalUuid = (
raw: string | null,
): { uuid: string | null; unknown: boolean; malformed: boolean } => {
if (raw === null) return { uuid: null, unknown: true, malformed: false };
if (!recordedUuidPattern.test(raw))
return { uuid: null, unknown: true, malformed: true };
const hex = raw.replaceAll("-", "").toUpperCase();
if (hex === ZERO_UUID) return { uuid: null, unknown: true, malformed: false };
return {
uuid: `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`,
unknown: false,
malformed: false,
};
};
const sourceMeaning = (source: string | null): string | null => {
if (source === null) return null;
return (APPLE_IPS_IMAGE_SOURCES as readonly string[]).includes(source)
? APPLE_IPS_IMAGE_SOURCE_MEANING[source as AppleIpsImageSource]
: null;
};
const exceptionOf = (
value: LosslessJson | undefined,
read: FieldReader,
): AppleIps["exception"] => {
const object = read.object(value, "body.exception");
if (object === null) return null;
const { members } = object;
return {
type: read.text(members.type, "body.exception.type"),
signal: read.text(members.signal, "body.exception.signal"),
subtype: read.text(members.subtype, "body.exception.subtype"),
codes: read.text(members.codes, "body.exception.codes"),
message: read.text(members.message, "body.exception.message"),
unlisted_keys: [...object.unlisted],
};
};
const terminationOf = (
value: LosslessJson | undefined,
read: FieldReader,
): AppleIps["termination"] => {
const object = read.object(value, "body.termination");
if (object === null) return null;
const { members } = object;
const reasonItems = read.array(
members.reasons,
"body.termination.reasons",
false,
);
return {
namespace: read.text(members.namespace, "body.termination.namespace"),
code: read.integer(members.code, "body.termination.code"),
indicator: read.text(members.indicator, "body.termination.indicator"),
flags: read.integer(members.flags, "body.termination.flags"),
by_pid: read.integer(members.byPid, "body.termination.byPid"),
by_proc: read.text(members.byProc, "body.termination.byProc"),
reasons:
reasonItems === null
? null
: reasonItems.flatMap((item, index) => {
const reason = read.text(
item,
`body.termination.reasons[${String(index)}]`,
);
return reason === null ? [] : [reason];
}),
unlisted_keys: [...object.unlisted],
};
};
const osVersionOf = (
value: LosslessJson | undefined,
read: FieldReader,
): AppleIps["process"]["os_version"] => {
if (value === undefined || value.kind === "null") return null;
if (value.kind === "string") return { form: "text", text: value.value };
if (value.kind !== "object") {
read.mismatch("body.osVersion", "string or object", value);
return null;
}
const { members } = value;
return {
form: "structured",
train: read.text(members.train, "body.osVersion.train"),
build: read.text(members.build, "body.osVersion.build"),
release_type: read.text(members.releaseType, "body.osVersion.releaseType"),
is_embedded: read.boolean(members.isEmbedded, "body.osVersion.isEmbedded"),
unlisted_keys: [...value.unlisted],
};
};
const registerValue = (
name: string,
value: LosslessJson,
path: string,
read: FieldReader,
): AppleIpsThread["registers"][number] | null => {
if (value.kind === "null") return null;
if (value.kind === "integer")
return {
name,
value: read.recordedInteger(value.decimal),
description: null,
};
if (value.kind !== "object") {
read.mismatch(path, "integer or object with integer value", value);
return null;
}
const integer = read.integer(value.members.value, `${path}.value`);
if (integer === null) {
if (value.members.value === undefined)
read.mismatch(`${path}.value`, "integer", undefined);
return null;
}
return {
name,
value: integer,
description: read.text(value.members.description, `${path}.description`),
};
};
const threadStateOf = (
value: LosslessJson | undefined,
path: string,
read: FieldReader,
): Pick<AppleIpsThread, "state_flavor" | "registers"> => {
const object = read.object(value, path);
if (object === null) return { state_flavor: null, registers: [] };
const { members } = object;
const registers: AppleIpsThread["registers"] = [];
const vector = read.array(members.x, `${path}.x`, false);
vector?.forEach((entry, index) => {
const register = registerValue(
`x${String(index)}`,
entry,
`${path}.x[${String(index)}]`,
read,
);
if (register !== null) registers.push(register);
});
for (const [key, entry] of Object.entries(members)) {
if (key === "x" || key === "flavor") continue;
if (key === "rosetta") {
// A translated process records Rosetta's registers as a nested state.
const rosetta = read.object(entry, `${path}.rosetta`);
for (const [name, nested] of Object.entries(rosetta?.members ?? {})) {
const register = registerValue(
`rosetta.${name}`,
nested,
`${path}.rosetta.${name}`,
read,
);
if (register !== null) registers.push(register);
}
continue;
}
const register = registerValue(key, entry, `${path}.${key}`, read);
if (register !== null) registers.push(register);
}
return {
state_flavor: read.text(members.flavor, `${path}.flavor`),
registers,
};
};
const imagesOf = (
rows: readonly LosslessJson[],
read: FieldReader,
): AppleIpsImage[] =>
rows.map((row, index) => {
const path = `body.usedImages[${String(index)}]`;
const { members, unlisted } = read.row(row, path);
const source = read.text(members.source, `${path}.source`);
const recordedUuid = read.text(members.uuid, `${path}.uuid`);
const uuid = canonicalUuid(recordedUuid);
if (uuid.malformed && recordedUuid !== null)
read.mismatches.push({
path: `${path}.uuid`,
expected: "32 hexadecimal UUID digits with optional canonical hyphens",
observed: "malformed UUID string",
recorded_value: recordedUuid,
});
const arch = read.text(members.arch, `${path}.arch`);
return {
index,
source,
source_meaning: sourceMeaning(source),
arch,
base: read.integer(members.base, `${path}.base`),
size: read.integer(members.size, `${path}.size`),
uuid: uuid.uuid,
uuid_unknown: uuid.unknown || arch === null || arch === "",
name: read.text(members.name, `${path}.name`),
path: read.text(members.path, `${path}.path`),
unlisted_keys: [...unlisted],
};
});
/**
* Frame 0 is the thread's sampled program counter; later frames are unwound
* return addresses. Keep each row's own recorded address, including inline
* rows: the inline flag does not establish a replacement address or row index.
*/
const frameRole = (
frameIndex: number,
threadIndex: number,
faultingThread: number | null,
): AppleIpsFrame["role"] => {
if (frameIndex > 0) return "return_address";
if (faultingThread === null) return "unknown";
return threadIndex === faultingThread
? "faulting_instruction"
: "current_instruction";
};
const threadsOf = (
rows: readonly LosslessJson[],
imageRows: readonly LosslessJson[],
images: readonly AppleIpsImage[],
faultingThread: number | null,
read: FieldReader,
): AppleIpsThread[] =>
rows.map((row, threadIndex) => {
const path = `body.threads[${String(threadIndex)}]`;
const { members, unlisted } = read.row(row, path);
const frames = (
read.array(members.frames, `${path}.frames`, false) ?? []
).map((frame, frameIndex): AppleIpsFrame => {
const framePath = `${path}.frames[${String(frameIndex)}]`;
const { members: frameMembers, unlisted: frameUnlisted } = read.row(
frame,
framePath,
);
const imageIndex = read.index(
frameMembers.imageIndex,
`${framePath}.imageIndex`,
);
const resolvedImage = read.resolveIndex(
imageIndex,
imageRows,
`${framePath}.imageIndex`,
);
const image = resolvedImage === null ? undefined : images[resolvedImage];
const imageOffset = read.integer(
frameMembers.imageOffset,
`${framePath}.imageOffset`,
);
const inline = read.boolean(frameMembers.inline, `${framePath}.inline`);
const role = frameRole(frameIndex, threadIndex, faultingThread);
return {
thread_index: threadIndex,
frame_index: frameIndex,
image_index: imageIndex,
image_offset: imageOffset,
runtime_address: read.add(image?.base ?? null, imageOffset),
role,
inline,
recorded_symbol: read.text(frameMembers.symbol, `${framePath}.symbol`),
symbol_location: read.integer(
frameMembers.symbolLocation,
`${framePath}.symbolLocation`,
),
unlisted_keys: [...frameUnlisted],
};
});
return {
index: threadIndex,
id: read.integer(members.id, `${path}.id`),
triggered: read.boolean(members.triggered, `${path}.triggered`),
name: read.text(members.name, `${path}.name`),
queue: read.text(members.queue, `${path}.queue`),
frames,
...threadStateOf(members.threadState, `${path}.threadState`, read),
unlisted_keys: [...unlisted],
};
});
/** Split the UTF-8 text into the first-line metadata object and the crash body. */
const crashText = (bytes: Buffer): { metadata: string; body: string } => {
let text: string;
try {
text = utf8.decode(bytes);
} catch {
throw malformedInput(
APPLE_IPS_FORMAT,
"IPS crash reports are UTF-8 JSON text; the selected bytes are not valid UTF-8",
);
}
if (text.includes("\u0000"))
throw malformedInput(
APPLE_IPS_FORMAT,
"IPS crash reports are UTF-8 JSON text and cannot contain NUL",
);
const newline = text.search(/\r\n|\n|\r/u);
if (newline <= 0)
throw malformedInput(
APPLE_IPS_FORMAT,
"IPS crash report must start with a metadata JSON object on the first line",
);
const lineEnd = text.startsWith("\r\n", newline) ? newline + 2 : newline + 1;
return { metadata: text.slice(0, newline), body: text.slice(lineEnd) };
};
const metadataOf = (text: string, read: FieldReader): AppleIps["metadata"] => {
const json = consumeJson(text, "IPS metadata", IPS_METADATA_SHAPE);
if (json.kind !== "object")
throw malformedInput(APPLE_IPS_FORMAT, "IPS metadata is not a JSON object");
const { members } = json;
const bugType = members.bug_type;
if (bugType?.kind !== "string" || bugType.value.length === 0)
throw malformedInput(
APPLE_IPS_FORMAT,
"IPS metadata bug_type is missing or not a string",
);
if (bugType.value !== "309")
throw unsupportedInput(
APPLE_IPS_FORMAT,
`IPS bug_type ${JSON.stringify(bugType.value)} is not a crash report (309); this operation does not decode other DiagnosticReports types`,
);
return {
bug_type: bugType.value,
timestamp: read.text(members.timestamp, "metadata.timestamp"),
os_version: read.text(members.os_version, "metadata.os_version"),
bundle_id: read.text(members.bundleID, "metadata.bundleID"),
incident_id: read.text(members.incident_id, "metadata.incident_id"),
name: read.text(members.name, "metadata.name"),
build_version: read.text(members.build_version, "metadata.build_version"),
platform: read.integer(members.platform, "metadata.platform"),
unlisted_keys: [...json.unlisted],
};
};
const coverageOf = (
threads: readonly AppleIpsThread[],
images: readonly AppleIpsImage[],
mismatches: AppleIps["coverage"]["type_mismatches"],
): AppleIps["coverage"] => {
let frames = 0;
let registers = 0;
for (const thread of threads) {
frames += thread.frames.length;
registers += thread.registers.length;
}
return {
status: mismatches.length === 0 ? "complete" : "partial",
reason:
mismatches.length === 0
? null
: `${String(mismatches.length)} recorded value(s) did not satisfy the documented type, format or reference and were not interpreted; see type_mismatches`,
threads: threads.length,
frames,
images: images.length,
registers,
type_mismatches: mismatches,
};
};
/** Read one Apple .ips crash report without executing a target. */
export const parseAppleIps = (
bytes: Buffer,
artifact: Artifact,
input: InspectAppleIpsInput,
read: FieldReader = new FieldReader(input.max_integer_text_bytes),
): AppleIps => {
const text = crashText(bytes);
const metadata = metadataOf(text.metadata, read);
if (text.body.length === 0)
throw malformedInput(
APPLE_IPS_FORMAT,
"IPS crash report body is missing after metadata line",
);
const bodyJson = consumeJson(
text.body,
"IPS crash body",
ipsBodyShape(input),
);
if (bodyJson.kind !== "object")
throw malformedInput(
APPLE_IPS_FORMAT,
"IPS crash body is not a JSON object",
);
const body = bodyJson.members;
const imageRows = read.array(body.usedImages, "body.usedImages", true) ?? [];
const images = imagesOf(imageRows, read);
const threadRows = read.array(body.threads, "body.threads", true) ?? [];
const faultingThread = read.index(body.faultingThread, "body.faultingThread");
const resolvedFaultingThread = read.resolveIndex(
faultingThread,
threadRows,
"body.faultingThread",
);
const threads = threadsOf(
threadRows,
imageRows,
images,
resolvedFaultingThread,
read,
);
const exception = exceptionOf(body.exception, read);
const termination = terminationOf(body.termination, read);
return {
artifact,
metadata,
process: {
name: read.text(body.procName, "body.procName"),
path: read.text(body.procPath, "body.procPath"),
pid: read.integer(body.pid, "body.pid"),
parent: read.text(body.parentProc, "body.parentProc"),
parent_pid: read.integer(body.parentPid, "body.parentPid"),
cpu_type: read.text(body.cpuType, "body.cpuType"),
translated: read.boolean(body.translated, "body.translated"),
os_version: osVersionOf(body.osVersion, read),
},
exception,
termination,
faulting_thread: faultingThread,
threads,
images,
classification: classify(exception, termination),
coverage: coverageOf(threads, images, read.mismatches),
unlisted_body_keys: [...bodyJson.unlisted],
join: null,
limitations: IPS_LIMITATIONS,
};
};