fix(native): record an empty Objective-C protocol name as a protocol failure (#4078)

* fix(native): record an empty Objective-C protocol name as a protocol failure

A protocol declaration whose name was an empty NUL-terminated string was
accepted as decoded, so whole-result validation of objc_protocols[].name
failed and inspect-native-dispatch-metadata discarded every decoded
class from the same image. The protocol decoder now rejects the empty
name inside its per-protocol boundary, as the class decoder already
does, so the protocol becomes a recorded partial-coverage failure and
independently decoded classes are retained.

Fixes #3873

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(native): decode adopted protocols before rejecting an empty parent name

Review follow-up. The empty-name check ran before the adopted-protocol
list was traversed, so a protocol declared only through an empty-named
parent's list was dropped. Adopted protocols are independent
declarations; traverse the list first, then reject only the parent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-15 05:20:22 -07:00
committed by GitHub
parent a5024a9710
commit 41e4c06397
2 changed files with 50 additions and 0 deletions
+46
View File
@@ -851,4 +851,50 @@ describe("Apple dispatch list scan budget", () => {
);
expect(coverage?.reason).not.toContain("max_records_reached");
});
it.each([
["RetainedProtocol", ["RetainedProtocol"], "complete"],
["", [], "partial"],
])(
"keeps the class when the protocol name is %j",
(protocolName, protocols, status) => {
const bytes = unmappedProtocolImage("neither");
bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x700n, 0x300);
bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x800n, 0x708);
bytes.writeUInt32LE(72, 0x740);
bytes.write(`${protocolName}\0`, 0x800);
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
expect(result.objc_classes).toMatchObject([{ name: "RetainedClass" }]);
expect(result.objc_protocols.map(({ name }) => name)).toEqual(protocols);
const coverage = unmappedProtocolClassCoverage(result);
expect(coverage).toMatchObject({ status });
if (status === "partial")
expect(coverage?.reason).toBe(
"Protocol 0x100000700: Objective-C protocol has an empty name",
);
},
);
it("keeps a protocol adopted only by an empty-named protocol", () => {
const bytes = unmappedProtocolImage("neither");
const at = (offset: bigint) => unmappedProtocolSectionBase + offset;
bytes.writeBigUInt64LE(at(0x700n), 0x300);
bytes.writeBigUInt64LE(at(0x800n), 0x708); // empty parent name
bytes.writeBigUInt64LE(at(0x900n), 0x710); // adopted protocol list
bytes.writeUInt32LE(72, 0x740);
bytes.writeBigUInt64LE(1n, 0x900);
bytes.writeBigUInt64LE(at(0xa00n), 0x908);
bytes.writeBigUInt64LE(at(0xb00n), 0xa08);
bytes.writeUInt32LE(72, 0xa40);
bytes.write("ChildProtocol\0", 0xb00);
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
expect(result.objc_classes).toMatchObject([{ name: "RetainedClass" }]);
expect(result.objc_protocols.map(({ name }) => name)).toEqual([
"ChildProtocol",
]);
expect(unmappedProtocolClassCoverage(result)).toMatchObject({
status: "partial",
reason: "Protocol 0x100000700: Objective-C protocol has an empty name",
});
});
});
+4
View File
@@ -67,7 +67,11 @@ export const createObjcProtocolReader = (input: {
if (read.u32(address + 64n) < 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.
const adopted = list(read.pointer(address + 16n));
if (protocolName.length === 0)
throw new TypeError("Objective-C protocol has an empty name");
const required = [
...methods(read.pointer(address + 24n), true, true),
...methods(read.pointer(address + 32n), true, false),