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)
This commit is contained in:
Sahinur authored and GitHub committed 2026-11-06 19:09:18 +00:00
1 parent f8356f3d71
commit 81ada025c0
10 files changed
+344 -32

No files matched your search

+7
View File
@@ -927,6 +927,13 @@ complete observation.
section lengths and unsupported non-PowerPC big-endian CPU declarations return
`unsupported_target` with the selected path and precise reason. Recovery
selects a well-formed or supported artifact and reopens that target.
An unreadable adopted-protocol sublist leaves its class, category or
`objc_protocol_records` entry available with partial decode status and
`protocol_list_unreadable`. Independently decoded metadata and any readable
protocol-name prefix remain available; an empty partial list does not
establish that its owner adopts no protocols. Existing list and record
limits remain in effect. Reader v3 uses a new analysis profile; snapshots
bound to reader v2 are incompatible and must be regenerated for this recovery.
The byte reader holds the whole selected file in memory, so it admits files up
to 64 MiB / 67108864 bytes by default. Select a larger budget with
`max_input_bytes` (CLI `--max-input-bytes`), at most 1 GiB / 1073741824
+141
View File
@@ -2478,3 +2478,144 @@ it.each([false, true])(
).toBe(unreadParent ? "partial" : "complete");
},
);
describe("Objective-C adopted protocol-list count limit", () => {
// Root class and metaclass that adopt the fixture protocol through the
// class_ro_t base protocol list at 0x700.
const rootClass = (protocolCount: bigint, category?: "partial-method") => {
const bytes = fixture(false, category);
bytes.writeUInt32LE(2, 12);
bytes.writeUInt32LE(5, 88);
bytes.writeUInt32LE(2, 0x300);
bytes.writeUInt32LE(3, 0x380);
bytes.writeBigUInt64LE(protocolCount, 0x700);
return bytes;
};
const classCoverage = (
result: ReturnType<typeof decodeAppleDispatchMetadata>,
) =>
result.coverage.find(
({ facet }) => facet === "objc_class_method_ivar_metadata",
);
it("retains the class and metaclass rows when the list exceeds its count limit", () => {
const result = decodeAppleDispatchMetadata(
rootClass(20001n),
100,
provenance,
);
expect(
result.objc_classes.map(({ name, is_meta_class, protocols, decode }) => ({
name,
is_meta_class,
protocols,
decode,
})),
).toEqual([
{
name: "Fixture",
is_meta_class: false,
protocols: [],
decode: { status: "partial", reason: "protocol_list_unreadable" },
},
// The metaclass has no adopted-protocol list of its own.
{
name: "Fixture",
is_meta_class: true,
protocols: [],
decode: { status: "decoded", reason: null },
},
]);
expect(result.objc_classes[0]?.properties).toHaveLength(3);
expect(result.objc_ivars).toHaveLength(1);
expect(result.objc_dispatch_implementations).toMatchObject([
{ class_name: "Fixture", selector: "performAction:" },
]);
expect(classCoverage(result)).toMatchObject({
status: "partial",
// The class row is retained as partial, so it is examined but not
// counted as fully decoded; the metaclass is fully decoded.
examined: 2,
decoded: 1,
});
});
it("retains the readable protocol prefix when a later list slot is unmapped", () => {
const bytes = rootClass(2n);
bytes.writeBigUInt64LE(base + 0x5000n, 0x710);
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
expect(result.objc_classes).toMatchObject([
{
name: "Fixture",
protocols: ["FixtureProtocol"],
decode: { status: "partial", reason: "protocol_list_unreadable" },
},
{ name: "Fixture", is_meta_class: true },
]);
expect(result.objc_protocol_records).toMatchObject([
{ name: "FixtureProtocol", decode: { status: "decoded" } },
]);
});
it("retains a category's own methods and properties after a protocol-list refusal", () => {
const bytes = rootClass(0n, "partial-method");
bytes.writeBigUInt64LE(base + 0x700n, 0xb98);
bytes.writeBigUInt64LE(base + 0x600n, 0xb68);
bytes.writeBigUInt64LE(base + 0xa00n, 0xb60);
bytes.writeBigUInt64LE(20001n, 0xa00);
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
expect(result.objc_categories).toMatchObject([
{
name: "FixtureCategory",
class_name: "Fixture",
instance_methods: ["performAction:"],
protocols: [],
decode: { status: "partial", reason: "protocol_list_unreadable" },
},
]);
expect(result.objc_categories[0]?.properties).toHaveLength(3);
expect(classCoverage(result)?.status).toBe("complete");
expect(
result.coverage.find(
({ facet }) => facet === "objc_properties_categories",
),
).toMatchObject({ status: "partial" });
});
it("retains an adopting protocol's own method declarations after a list refusal", () => {
const bytes = rootClass(1n);
bytes.writeBigUInt64LE(base + 0xa00n, 0x750);
bytes.writeBigUInt64LE(20001n, 0xa00);
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
expect(result.objc_protocol_records).toMatchObject([
{
name: "FixtureProtocol",
adopted_protocols: [],
methods: [{ selector: "requiredAction:" }],
optional_methods: [{ selector: "optionalAction:" }],
decode: { status: "partial", reason: "protocol_list_unreadable" },
},
]);
expect(result.objc_protocols).toMatchObject([
{ name: "FixtureProtocol", methods: [{ selector: "requiredAction:" }] },
]);
expect(result.objc_classes).toHaveLength(2);
expect(classCoverage(result)?.status).toBe("partial");
});
it.each([0n, 1n])(
"keeps complete class coverage for an in-limit count of %s",
(count) => {
const bytes = rootClass(count);
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
const reason = classCoverage(result)?.reason ?? "";
expect(reason).not.toContain("Protocol list of Fixture");
expect(result.objc_classes).toHaveLength(2);
for (const row of result.objc_classes)
expect(row.decode?.reason ?? "").not.toContain(
"protocol_list_unreadable",
);
expect(classCoverage(result)?.status).toBe("complete");
},
);
});
+18 -6
View File
@@ -4,6 +4,7 @@ import type { ObjcDispatchReaders } from "./AppleObjcDispatchFacets.js";
import { readObjcPropertiesOf } from "./AppleObjcProperties.js";
import { issue, type DecodeIssue } from "./AppleDispatchDecodeFacts.js";
import { BinaryFormatFailure } from "./metadata/BinaryContainer.js";
import type { ObjcProtocolListResult } from "./AppleObjcProtocols.js";
const CLASS_SYMBOL = /^_OBJC_(?:META)?CLASS_\$_(.+)$/u;
@@ -50,7 +51,11 @@ export const decodeObjcCategories = (input: {
category: string,
onTruncated?: () => void,
) => string[];
readonly protocols: (list: bigint, onTruncated?: () => void) => string[];
/** Read the protocol-list pointer field, retaining known names on failure. */
readonly protocolList: (
field: bigint,
onTruncated?: () => void,
) => ObjcProtocolListResult;
/** Name of a class defined in this image, from its class_ro_t. */
readonly localClassName: (address: bigint) => string;
}): number => {
@@ -119,10 +124,16 @@ export const decodeObjcCategories = (input: {
name,
onTruncated,
);
const protocols = input.protocols(
read.pointer(category + 32n),
onTruncated,
);
const protocols = input.protocolList(category + 32n, onTruncated);
if (!protocols.ok)
input.issues.push(
issue(
"objc_properties_categories",
"protocol_list_unreadable",
`Category ${name} adopted protocol list: ${protocols.error.reason}`,
`0x${(category + 32n).toString(16)}`,
),
);
const properties = readObjcPropertiesOf(
read,
read.pointer(category + 40n),
@@ -136,6 +147,7 @@ export const decodeObjcCategories = (input: {
);
const decodeReasons = [
...(className === null ? ["category_class_unresolved"] : []),
...(protocols.ok ? [] : ["protocol_list_unreadable"]),
...(budgetTruncated ? ["max_records_reached"] : []),
];
result.objc_categories.push({
@@ -149,7 +161,7 @@ export const decodeObjcCategories = (input: {
: "local",
instance_methods: instanceMethods,
class_methods: classMethods,
protocols,
protocols: protocols.ok ? protocols.value : protocols.error.names,
properties,
location: read.location(category),
decode:
+10 -2
View File
@@ -361,12 +361,20 @@ export const decodeObjcDispatchFacets = (input: {
location: hex(read.pointer(ro + 64n)),
onTruncated,
});
const protocols = protocolReader.list(read.pointer(ro + 40n), onTruncated);
const adoptedProtocols = protocolReader.readList(ro + 40n, onTruncated);
if (!adoptedProtocols.ok)
failures.push(
`Protocol list of ${name}: ${adoptedProtocols.error.reason}`,
);
const protocols = adoptedProtocols.ok
? adoptedProtocols.value
: adoptedProtocols.error.names;
const classDecodeReasons = [
...(superclass.name === null && !root
? ["superclass_binding_unresolved"]
: []),
...(classListTruncated ? ["max_records_reached"] : []),
...(adoptedProtocols.ok ? [] : ["protocol_list_unreadable"]),
];
result.objc_classes.push({
name,
@@ -449,7 +457,7 @@ export const decodeObjcDispatchFacets = (input: {
issues: categoryIssues,
admit: () => budget.admit(),
methods,
protocols: (list, onTruncated) => protocolReader.list(list, onTruncated),
protocolList: protocolReader.readList,
localClassName: (address) => readLocalClass(read, address).name,
});
return {
+46 -17
View File
@@ -1,6 +1,17 @@
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: {
@@ -19,23 +30,36 @@ export const createObjcProtocolReader = (input: {
const { readers: read, result, failures } = input;
const visited = new Set<bigint>();
const name = (address: bigint) => read.string(read.pointer(address + 8n));
const list = (address: bigint, onTruncated?: () => void): string[] => {
if (address === 0n) return [];
// `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");
/** Own the list pointer field as well as the list's count and entries. */
const readList = (
field: bigint,
onTruncated?: () => void,
): ObjcProtocolListResult => {
const names: string[] = [];
for (let index = 0n; index < count; index++) {
if (!read.admit()) {
onTruncated?.();
break;
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);
}
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),
});
}
return names;
};
const methods = (
address: bigint,
@@ -97,7 +121,11 @@ export const createObjcProtocolReader = (input: {
const onTruncated = () => {
budgetTruncated = true;
};
const adopted = list(read.pointer(address + 16n), onTruncated);
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[] = [];
@@ -165,6 +193,7 @@ export const createObjcProtocolReader = (input: {
);
const decodeReasons = [
...propertyReasons,
...(adopted.ok ? [] : ["protocol_list_unreadable"]),
...(skipped.length === 0 ? [] : ["protocol_method_selector_empty"]),
...(budgetTruncated ? ["max_records_reached"] : []),
];
@@ -176,7 +205,7 @@ export const createObjcProtocolReader = (input: {
});
result.objc_protocol_records.push({
name: protocolName,
adopted_protocols: adopted,
adopted_protocols: adopted.ok ? adopted.value : adopted.error.names,
methods: required,
optional_methods: optional,
location: read.location(address),
@@ -195,5 +224,5 @@ export const createObjcProtocolReader = (input: {
);
}
};
return { list, record };
return { readList, record };
};
+2 -2
View File
@@ -9,12 +9,12 @@ export const APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE = createAnalysisProfile(
{
id: NATIVE_MACOS_PROVIDER_IDENTITY.id,
name: NATIVE_MACOS_PROVIDER_IDENTITY.name,
version: "apple-metadata-reader-2",
version: "apple-metadata-reader-3",
},
{
operation: "inspect_native_dispatch_metadata",
reader: "apple-dispatch-metadata",
revision: 2,
revision: 3,
},
);
/** Operations whose target process can show UI, use the network and write files. */
@@ -1,4 +1,5 @@
import { writeFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { describe, expect } from "vitest";
@@ -6,6 +7,70 @@ import { describe, expect } from "vitest";
import { machoWithPropertyAttributes } from "../../fixtures/objcPropertyMacho.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { objcAdoptedProtocolCountMacho } from "../../fixtures/objcRecordBudgetMacho.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { nativeDispatchMetadataResultSchema } from "../../../src/domain/native/objcSwiftMetadata.js";
describe.skipIf(process.platform !== "darwin")(
"native adopted-protocol list recovery",
() => {
cliTest.for([1n, 20001n])(
"preserves class facts and partial-list semantics over CLI and stdio MCP for count %s",
async (count, { cli }) => {
const root = await createTestTempDirectory("rea-protocol-list-");
const path = join(root, "fixture.macho");
const bytes = objcAdoptedProtocolCountMacho(count);
await writeFile(path, bytes);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const cliReply = await cli.run({
arguments: ["inspect-native-dispatch-metadata", path, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(cliReply.exitCode, cliReply.stderr).toBe(0);
const cliResult = nativeDispatchMetadataResultSchema.parse(
parseEvidence(cliReply.json).normalized_result,
);
const call = await cli.sharedMcpCall!();
const opened = await call("open_binary", { path });
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
try {
const reply = await call("inspect_native_dispatch_metadata", {});
expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
const mcpResult = nativeDispatchMetadataResultSchema.parse(
parseEvidence(reply.structuredContent).normalized_result,
);
for (const result of [cliResult, mcpResult]) {
expect(result.target_sha256).toBe(sha256);
expect(result.input_admission?.retained_bytes).toBe(bytes.length);
expect(result.result.objc_classes).toMatchObject([
{
name: "FixtureClass",
methods: [{ selector: "classMethod" }],
protocols: count === 1n ? ["FixtureProtocol"] : [],
decode:
count === 1n
? { status: "decoded", reason: null }
: { status: "partial", reason: "protocol_list_unreadable" },
},
]);
expect(
result.result.coverage.find(
({ facet }) => facet === "objc_class_method_ivar_metadata",
)?.status,
).toBe(count === 1n ? "complete" : "partial");
}
expect(mcpResult.result.objc_classes).toEqual(
cliResult.result.objc_classes,
);
} finally {
const closed = await call("close_binary", {});
expect(closed.isError).not.toBe(true);
}
expect((await readFile(path)).equals(bytes)).toBe(true);
},
);
},
);
describe.skipIf(process.platform !== "darwin")(
"native dispatch property attributes",
@@ -1572,7 +1572,7 @@ const expectReaderBindingMismatch = (
expect(projectAnalysisError(error).details).toMatchObject({
binding_mismatch: {
provider: {
snapshot: { version: "apple-metadata-reader-older" },
snapshot: { version: "apple-metadata-reader-2" },
selected: { version: selectedVersion },
},
profile_digest: { snapshot: snapshotDigest },
@@ -1610,12 +1610,13 @@ const expectProfilelessWorkflowSnapshotRejections = async (
if (!opened.ok) throw opened.error;
const readerIdentity = APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider;
const wrongReader = structuredClone(snapshot);
// Reader v2 could discard owners when their adopted-protocol list was unreadable.
const otherReaderProfile = createAnalysisProfile(
{ ...readerIdentity, version: "apple-metadata-reader-older" },
{ ...readerIdentity, version: "apple-metadata-reader-2" },
{
operation: "inspect_native_dispatch_metadata",
reader: "apple-dispatch-metadata",
revision: 0,
revision: 2,
},
);
wrongReader.binding = {
@@ -1,14 +1,19 @@
import { writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { expect, it } from "vitest";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
import { nativeDispatchMetadataResultSchema } from "../../../../src/domain/native/objcSwiftMetadata.js";
import {
NativeFixtureRunner,
nativeMachoTargetForFile,
} from "../../../fixtures/nativeCommands.js";
import { objcRecordBudgetMacho } from "../../../fixtures/objcRecordBudgetMacho.js";
import {
objcAdoptedProtocolCountMacho,
objcRecordBudgetMacho,
} from "../../../fixtures/objcRecordBudgetMacho.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
const sectionCases = [
@@ -101,3 +106,39 @@ it("continues decoding the well-formed eight-byte class-list control", async ()
},
});
});
it.each([1n, 20001n])(
"retains class facts and source identity through provider delivery for protocol count %s",
async (count) => {
const bytes = objcAdoptedProtocolCountMacho(count);
const { result } = await inspect(bytes);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
const report = nativeDispatchMetadataResultSchema.parse(
result.value.result,
);
expect(report.target_sha256).toBe(
createHash("sha256").update(bytes).digest("hex"),
);
expect(report.input_admission?.retained_bytes).toBe(bytes.length);
expect(report.result.objc_classes).toMatchObject([
{
name: "FixtureClass",
methods: [{ selector: "classMethod", method_type: "instance" }],
protocols: count === 1n ? ["FixtureProtocol"] : [],
decode:
count === 1n
? { status: "decoded", reason: null }
: { status: "partial", reason: "protocol_list_unreadable" },
},
]);
expect(report.result.objc_dispatch_implementations).toMatchObject([
{ class_name: "FixtureClass", selector: "classMethod" },
]);
expect(
report.result.coverage.find(
({ facet }) => facet === "objc_class_method_ivar_metadata",
)?.status,
).toBe(count === 1n ? "complete" : "partial");
},
);
+8
View File
@@ -76,3 +76,11 @@ export const objcRecordBudgetMacho = (
bytes.write("protocolMethod\0", 0x9a0);
return bytes;
};
/** A class adopts the fixture protocol through a separately counted list. */
export const objcAdoptedProtocolCountMacho = (count: bigint): Buffer => {
const bytes = objcRecordBudgetMacho("class");
bytes.writeBigUInt64LE(base + 0x6b8n, 0x328);
bytes.writeBigUInt64LE(count, 0x6b8);
return bytes;
};