Files
reverse-engineering-anything/src/native/AppleObjcProtocols.ts
T
Sahinur 81ada025c0 fix(native): preserve Objective-C records with unreadable protocol lists
Keep independent Objective-C class, metaclass, category and protocol records when an adopted-protocol list is unreadable. The shared list reader retains readable prefixes through tagged Result failures; consumers preserve partial/unknown semantics with unchanged limits. Advance the source-owned reader commitment to v3 and reject stale v2 snapshot bindings.

Validated with 232 focused tests, current-main and original-proposal RED checks, named provider-result/schema/identity controls, and a verified public GLFW Mach-O paired with independent LLVM/Python byte evidence. Ordinary binary results remain identical; a deliberately mutated count preserves ten rows instead of eight. The final head passes the real macOS CI lane, with twelve CLI tests including both new protocol-count cases over CLI and stdio MCP. Snapshot binding and same-version replay checks pass.

The original unchecked human declarations are preserved. The explicit maintainer readiness exception applies after technical CI and automated review, without adding a human-review attestation.

Fixes #8945

Full technical CI 42263548929 passes, including all eight shards and required aggregates. The Author readiness-only exception is explicitly authorized; no human-review attestation is added.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
2026-11-06 19:09:18 +00:00

229 lines
7.8 KiB
TypeScript

import { readObjcPropertyList } from "./AppleObjcProperties.js";
import type { ObjcSwiftMetadata } from "../domain/native/objcSwiftMetadata.js";
import type { SwiftNominalReaders } from "./AppleSwiftNominalTypes.js";
import { err, ok, type Result } from "../domain/result.js";
/** A failed list still owns the names read before its unreadable suffix. */
export type ObjcProtocolListResult = Result<
string[],
{
readonly _tag: "ProtocolListUnreadable";
readonly names: string[];
readonly reason: string;
}
>;
/** Read protocol lists and absolute protocol method declarations with cycle-safe identities. */
export const createObjcProtocolReader = (input: {
readers: Pick<
SwiftNominalReaders,
"u32" | "string" | "location" | "evidence" | "admit"
> & {
pointer(address: bigint): bigint;
/** Plain 64-bit data, never a fixup. */
u64(address: bigint): bigint;
i32(address: bigint): number;
};
result: ObjcSwiftMetadata;
failures: string[];
}) => {
const { readers: read, result, failures } = input;
const visited = new Set<bigint>();
const name = (address: bigint) => read.string(read.pointer(address + 8n));
/** Own the list pointer field as well as the list's count and entries. */
const readList = (
field: bigint,
onTruncated?: () => void,
): ObjcProtocolListResult => {
const names: string[] = [];
try {
const address = read.pointer(field);
if (address === 0n) return ok(names);
// `count` is plain uintptr_t data, not a pointer fixup.
const count = read.u64(address);
if (count > 20000n)
throw new RangeError("Protocol list exceeds 20000 entries");
for (let index = 0n; index < count; index++) {
if (!read.admit()) {
onTruncated?.();
break;
}
const protocol = read.pointer(address + 8n + index * 8n);
names.push(name(protocol));
record(protocol);
}
return ok(names);
} catch (cause: unknown) {
return err({
_tag: "ProtocolListUnreadable",
names,
reason: cause instanceof Error ? cause.message : String(cause),
});
}
};
const methods = (
address: bigint,
required: boolean,
instance: boolean,
/** Entries skipped for an empty selector, reported by the declaring protocol. */
emptySelectors: string[],
onTruncated?: () => void,
) => {
const output: ObjcSwiftMetadata["objc_protocols"][number]["methods"] = [];
if (address === 0n) return output;
const flags = read.u32(address),
count = read.u32(address + 4n);
const relative = (flags & 0x80000000) !== 0;
const stride = flags & 0xfffc;
if (stride !== (relative ? 12 : 24))
throw new TypeError("Unsupported Objective-C protocol method entry size");
for (let index = 0; index < count; index++) {
if (!read.admit()) {
onTruncated?.();
break;
}
const entry = address + 8n + BigInt(index * stride);
let selector = relative
? entry + BigInt(read.i32(entry))
: read.pointer(entry);
if (relative && (flags & 0x40000000) === 0)
selector = read.pointer(selector);
// One empty selector is reported on its own, so the protocol and its
// other method declarations keep their decoded records.
const selectorName = read.string(selector);
if (selectorName.length === 0) {
emptySelectors.push(
`${required ? "required" : "optional"} ${instance ? "instance" : "class"} method ${String(index)} at 0x${entry.toString(16)} has an empty selector`,
);
continue;
}
output.push({
selector: selectorName,
method_type: instance ? "instance" : "class",
address: null,
is_required: required,
is_optional: !required,
});
}
return output;
};
const record = (address: bigint) => {
if (address === 0n || visited.has(address) || !read.admit()) return;
visited.add(address);
try {
const protocolSize = read.u32(address + 64n);
if (protocolSize < 72)
throw new RangeError("Truncated Objective-C protocol declaration");
const protocolName = name(address);
// Adopted protocols are independent declarations; decode them before
// rejecting a parent whose own name is empty.
let budgetTruncated = false;
const onTruncated = () => {
budgetTruncated = true;
};
const adopted = readList(address + 16n, onTruncated);
if (!adopted.ok)
failures.push(
`Protocol 0x${address.toString(16)} adopted protocol list: ${adopted.error.reason}`,
);
if (protocolName.length === 0)
throw new TypeError("Objective-C protocol has an empty name");
const skipped: string[] = [];
const required = [
...methods(
read.pointer(address + 24n),
true,
true,
skipped,
onTruncated,
),
...methods(
read.pointer(address + 32n),
true,
false,
skipped,
onTruncated,
),
];
const optional = [
...methods(
read.pointer(address + 40n),
false,
true,
skipped,
onTruncated,
),
...methods(
read.pointer(address + 48n),
false,
false,
skipped,
onTruncated,
),
];
for (const message of skipped)
failures.push(
`Protocol 0x${address.toString(16)}: Objective-C protocol ${protocolName} ${message}`,
);
let properties: ObjcSwiftMetadata["objc_protocols"][number]["properties"] =
[];
const propertyReasons: string[] = [];
try {
const decoded = readObjcPropertyList(
read,
read.pointer(address + 56n),
() => read.admit(),
onTruncated,
);
properties = decoded.properties;
for (const problem of decoded.issues)
propertyReasons.push(problem.message);
// Older protocol revisions end at size/flags. The later class-property
// list has no class/instance discriminator in the public property shape.
if (protocolSize >= 96 && read.pointer(address + 88n) !== 0n)
propertyReasons.push("protocol class properties are not decoded");
} catch (cause) {
propertyReasons.push(
cause instanceof Error ? cause.message : String(cause),
);
}
for (const message of propertyReasons)
failures.push(
`Protocol 0x${address.toString(16)}: Objective-C protocol ${protocolName} properties: ${message}`,
);
const decodeReasons = [
...propertyReasons,
...(adopted.ok ? [] : ["protocol_list_unreadable"]),
...(skipped.length === 0 ? [] : ["protocol_method_selector_empty"]),
...(budgetTruncated ? ["max_records_reached"] : []),
];
result.objc_protocols.push({
name: protocolName,
methods: required,
optional_methods: optional,
properties,
});
result.objc_protocol_records.push({
name: protocolName,
adopted_protocols: adopted.ok ? adopted.value : adopted.error.names,
methods: required,
optional_methods: optional,
location: read.location(address),
decode:
decodeReasons.length === 0
? { status: "decoded", reason: null }
: { status: "partial", reason: decodeReasons.join("; ") },
evidence: read.evidence(
address,
"Objective-C protocol declaration and encoded method lists; IMP fields are not handler targets",
),
});
} catch (cause) {
failures.push(
`Protocol 0x${address.toString(16)}: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
};
return { readList, record };
};