fix(native): retain valid Objective-C properties beside a malformed record (#3833)

* fix(native): retain valid Objective-C properties beside a malformed record

readObjcPropertyList decoded a whole property_list_t in one operation.
When a later entry's name pointer was unmapped, the list-level catch
discarded the properties already decoded. An empty property name passed
decoding and then failed schema validation of the whole result, so
inspect-native-dispatch-metadata exited 1 with no metadata.

Each property_t entry is now decoded on its own. A failing entry, or an
empty name, becomes a property_decode_failed issue at the entry address,
and sibling properties are retained. Incomplete-attribute issues also
report their entry address. List-header failures still fail only that
list.

Fixes #3336

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

* test(native): cover property record isolation on every platform

Move the synthetic Objective-C property Mach-O into a shared fixture and
decode it through decodeAppleDispatchMetadata in a portable test, so
Linux and Windows runs also catch a regression that drops valid sibling
properties. The macOS CLI cases keep exercising the built command.

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:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-14 21:22:40 -07:00
1 parent 70cd4cbfb8
commit f09f454f45
5 files changed
+181 -64

No files matched your search

+4 -1
View File
@@ -380,7 +380,10 @@ complete observation.
missing or incomplete attribute metadata leaves `atomicity` and any
unestablished read-only value null. A type attribute whose quoted class name
never closes is incomplete and marks `objc_properties_categories` partial
with `property_attributes_incomplete`. `is_meta_class`, `is_root_class`, and
with `property_attributes_incomplete`. A property record that cannot be
decoded, such as one with an empty or unmapped name, is reported as
`property_decode_failed` at its entry address while sibling properties are
retained. `is_meta_class`, `is_root_class`, and
`ivar_count` are also nullable when the selected provider exposes only symbol
names. Class implementation methods have null `is_required` and `is_optional`
fields because those facts apply to protocol declarations; decoded protocol
+33
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { machoWithPropertyAttributes } from "../../tests/fixtures/objcPropertyMacho.js";
import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js";
const base = 0x100000000n;
@@ -423,6 +424,38 @@ describe("Apple dispatch coverage ownership", () => {
});
});
describe("malformed Objective-C property records", () => {
it.each([
["an unmapped name", { secondNameOffset: 0x2000 }, "first", "0x100000518"],
["an empty name", { firstName: "" }, "second", "0x100000508"],
] as const)(
"retains the valid sibling of a property with %s",
(_, layout, kept, location) => {
const result = decodeAppleDispatchMetadata(
machoWithPropertyAttributes("Tq,N", layout),
100,
provenance,
);
expect(
result.objc_classes.map(({ name, properties }) => ({
name,
properties: properties.map((property) => property.name),
})),
).toEqual([{ name: "Fixture", properties: [kept] }]);
expect(
result.coverage.find(
({ facet }) => facet === "objc_properties_categories",
),
).toMatchObject({
status: "partial",
reason: expect.stringContaining(
`property_decode_failed at ${location}: Fixture: property`,
),
});
},
);
});
describe("malformed Apple dispatch metadata", () => {
it("retains valid class siblings when one class has an empty name", () => {
const result = decodeAppleDispatchMetadata(
+44 -18
View File
@@ -91,36 +91,62 @@ const splitAttributes = (
};
};
/** Decode `property_list_t { entsize_and_flags, count, property_t[] }`. */
/** One property-level decode problem, relative to the list's owner. */
interface PropertyListIssue {
readonly code: "property_decode_failed" | "property_attributes_incomplete";
readonly message: string;
readonly location: string;
}
/**
* Decode `property_list_t { entsize_and_flags, count, property_t[] }`. Each
* entry is decoded on its own, so one malformed entry is reported without
* discarding the properties decoded before or after it.
*/
export const readObjcPropertyList = (
read: ObjcPropertyReaders,
list: bigint,
admit: () => boolean,
): {
readonly properties: ObjcProperty[];
/** Names of properties whose attribute string is incomplete. */
readonly incomplete: string[];
readonly issues: PropertyListIssue[];
} => {
const incomplete: string[] = [];
if (list === 0n) return { properties: [], incomplete };
const properties: ObjcProperty[] = [];
const issues: PropertyListIssue[] = [];
if (list === 0n) return { properties, issues };
const entrySize = read.u32(list) & ~3;
const count = read.u32(list + 4n);
if (entrySize !== 16)
throw new TypeError(
`Unsupported Objective-C property entry size ${entrySize}`,
);
const properties: ObjcProperty[] = [];
for (let index = 0; index < count; index++) {
if (!admit()) break;
const entry = list + 8n + BigInt(index * entrySize);
const decoded = decodePropertyAttributes(
read.string(read.pointer(entry)),
read.string(read.pointer(entry + 8n)),
);
properties.push(decoded.property);
if (!decoded.complete) incomplete.push(decoded.property.name);
const location = `0x${entry.toString(16)}`;
try {
const name = read.string(read.pointer(entry));
if (name.length === 0) throw new TypeError("the property name is empty");
const decoded = decodePropertyAttributes(
name,
read.string(read.pointer(entry + 8n)),
);
properties.push(decoded.property);
if (!decoded.complete)
issues.push({
code: "property_attributes_incomplete",
message: `property ${name} has an unterminated quoted class name in its type attribute, so atomicity and unestablished read-only state are unknown`,
location,
});
} catch (cause: unknown) {
issues.push({
code: "property_decode_failed",
message: `property ${String(index)} could not be decoded: ${cause instanceof Error ? cause.message : String(cause)}`,
location,
});
}
}
return { properties, incomplete };
return { properties, issues };
};
/** Decode a property list, recording a failure for `owner` instead of throwing. */
@@ -135,18 +161,18 @@ export const readObjcPropertiesOf = (
},
): ObjcProperty[] => {
try {
const { properties, incomplete } = readObjcPropertyList(
const { properties, issues } = readObjcPropertyList(
read,
list,
context.admit,
);
for (const name of incomplete)
for (const item of issues)
context.issues.push(
issue(
"objc_properties_categories",
"property_attributes_incomplete",
`${context.owner}.${name}: the type attribute has an unterminated quoted class name, so atomicity and unestablished read-only state are unknown`,
context.location,
item.code,
`${context.owner}: ${item.message}`,
item.location,
),
);
return properties;
@@ -3,54 +3,10 @@ import { join } from "node:path";
import { describe, expect } from "vitest";
import { machoWithPropertyAttributes } from "../../fixtures/objcPropertyMacho.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
/** arm64 Mach-O whose two class properties share one attribute string. */
const machoWithPropertyAttributes = (attributes: string): Buffer => {
const bytes = Buffer.alloc(4096);
const base = 0x100000000n;
const u32 = (at: number, value: number) => bytes.writeUInt32LE(value, at);
const pointer = (at: number, offset: number) =>
bytes.writeBigUInt64LE(base + BigInt(offset), at);
u32(0, 0xfeedfacf);
u32(4, 0x0100000c);
u32(12, 2);
u32(16, 1);
u32(20, 152);
u32(32, 0x19);
u32(36, 152);
bytes.write("__DATA", 40);
pointer(56, 0);
bytes.writeBigUInt64LE(4096n, 64);
bytes.writeBigUInt64LE(4096n, 80);
u32(88, 5);
u32(92, 5);
u32(96, 1);
bytes.write("__objc_classlist", 104);
bytes.write("__DATA", 120);
pointer(136, 0x180);
bytes.writeBigUInt64LE(8n, 144);
u32(152, 0x180);
pointer(0x180, 0x200);
pointer(0x200 + 32, 0x300);
u32(0x300, 2);
u32(0x308, 16);
pointer(0x300 + 24, 0x400);
bytes.write("Fixture\0", 0x400);
pointer(0x340, 0x500);
u32(0x500, 16);
u32(0x504, 2);
pointer(0x508, 0x600);
pointer(0x510, 0x620);
pointer(0x518, 0x640);
pointer(0x520, 0x620);
bytes.write("first\0", 0x600);
bytes.write(`${attributes}\0`, 0x620);
bytes.write("second\0", 0x640);
return bytes;
};
describe.skipIf(process.platform !== "darwin")(
"native dispatch property attributes",
() => {
@@ -108,3 +64,51 @@ describe.skipIf(process.platform !== "darwin")(
);
},
);
describe.skipIf(process.platform !== "darwin")(
"native dispatch property record isolation",
() => {
cliTest.for([
[
"an unmapped name pointer",
{ secondNameOffset: 0x2000 },
"first",
"0x100000518",
],
["an empty name", { firstName: "" }, "second", "0x100000508"],
] as const)(
"retains the valid sibling of a property with $0",
async ([, layout, kept, location], { cli }) => {
const directory = await createTestTempDirectory("rea-objc-property-");
const path = join(directory, "fixture.bin");
await writeFile(path, machoWithPropertyAttributes("Tq,N", layout));
const output = await cli.run({
arguments: ["inspect-native-dispatch-metadata", path, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(output.exitCode).toBe(0);
expect(output.json).toMatchObject({
normalized_result: {
result: {
objc_classes: [
{
name: "Fixture",
properties: [{ name: kept, atomicity: "nonatomic" }],
},
],
coverage: expect.arrayContaining([
expect.objectContaining({
facet: "objc_properties_categories",
status: "partial",
reason: expect.stringContaining(
`property_decode_failed at ${location}: Fixture: property`,
),
}),
]),
},
},
});
},
);
},
);
+51
View File
@@ -0,0 +1,51 @@
/**
* arm64 Mach-O with one Objective-C class, `Fixture`, whose two properties
* `first` and `second` share one attribute string. The first name may be
* replaced and the second name pointer may target an unmapped offset.
*/
export const machoWithPropertyAttributes = (
attributes: string,
{ firstName = "first", secondNameOffset = 0x640 } = {},
): Buffer => {
const bytes = Buffer.alloc(4096);
const base = 0x100000000n;
const u32 = (at: number, value: number) => bytes.writeUInt32LE(value, at);
const pointer = (at: number, offset: number) =>
bytes.writeBigUInt64LE(base + BigInt(offset), at);
u32(0, 0xfeedfacf);
u32(4, 0x0100000c);
u32(12, 2);
u32(16, 1);
u32(20, 152);
u32(32, 0x19);
u32(36, 152);
bytes.write("__DATA", 40);
pointer(56, 0);
bytes.writeBigUInt64LE(4096n, 64);
bytes.writeBigUInt64LE(4096n, 80);
u32(88, 5);
u32(92, 5);
u32(96, 1);
bytes.write("__objc_classlist", 104);
bytes.write("__DATA", 120);
pointer(136, 0x180);
bytes.writeBigUInt64LE(8n, 144);
u32(152, 0x180);
pointer(0x180, 0x200);
pointer(0x200 + 32, 0x300);
u32(0x300, 2);
u32(0x308, 16);
pointer(0x300 + 24, 0x400);
bytes.write("Fixture\0", 0x400);
pointer(0x340, 0x500);
u32(0x500, 16);
u32(0x504, 2);
pointer(0x508, 0x600);
pointer(0x510, 0x620);
pointer(0x518, secondNameOffset);
pointer(0x520, 0x620);
bytes.write(`${firstName}\0`, 0x600);
bytes.write(`${attributes}\0`, 0x620);
bytes.write("second\0", 0x640);
return bytes;
};