fix(native)!: preserve unknown Objective-C metadata

BREAKING CHANGE: Objective-C attribute tokens now contain only name and value. Properties report nullable atomicity and readonly facts; class flags, ivar counts, and method requirement flags are nullable when unobserved.
This commit is contained in:
morluto committed 2026-10-10 00:35:16 +08:00
1 parent e10f1f0901
commit b112d44a39
10 files changed
+225 -45

No files matched your search

+12 -2
View File
@@ -96,8 +96,18 @@ complete observation.
So an external superclass resolves from its `_OBJC_CLASS_$_` bind (for
example `NSObject`). The `pointer_fixups` coverage facet names the mechanism.
Class properties, with parsed attributes, and `__objc_catlist` categories are
also decoded. Categories record the extended class, local or external, plus
Class properties preserve each encoded attribute as a raw `{name, value}`
pair, for example `{ name: "N", value: "" }`, and report atomicity on the
property: `N` means `nonatomic`; a complete typed attribute string without
`N` means the Objective-C default `atomic`;
missing or incomplete attribute metadata leaves `atomicity` and any
unestablished read-only value null. `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
method lists retain their observed required/optional values. Class properties
and `__objc_catlist` categories are also decoded. Categories record the
extended class, local or external, plus
their methods, protocols and properties; category methods appear as
implementations with a `category`. Swift field-offset globals that are only
initialized at runtime stay unresolved. Generic, resilient, async and
+50
View File
@@ -1,3 +1,4 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -6,6 +7,12 @@ import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { parseBinaryTarget } from "../../../dist/application/BinaryTargetResolver.js";
import { inspectAppleDispatchMetadata } from "../../../dist/native/AppleDispatchMetadata.js";
import {
artifactCliEvidence,
artifactMcpResult,
withArtifactMcp,
} from "../../lib/artifact-e2e.mjs";
import { nativeDispatchMetadataResultSchema } from "../../../dist/domain/native/objcSwiftMetadata.js";
if (process.platform !== "darwin")
throw new Error(
@@ -61,6 +68,30 @@ const checkObjc = (metadata, mode) => {
const objcCoverage = metadata.coverage.find(
({ facet }) => facet === "objc_class_method_ivar_metadata",
);
assert.equal(fixture?.is_meta_class, false);
assert.equal(fixture?.is_root_class, false);
assert.equal(
fixture?.properties.find(({ name }) => name === "fixtureName")?.atomicity,
"nonatomic",
);
assert.equal(
fixture?.properties.find(({ name }) => name === "atomicValue")?.atomicity,
"atomic",
);
for (const method of fixture.methods) {
assert.equal(method.is_required, null);
assert.equal(method.is_optional, null);
}
for (const property of fixture.properties)
for (const attribute of property.attributes)
assert.deepEqual(Object.keys(attribute).sort(), ["name", "value"]);
const protocol = metadata.objc_protocols.find(
({ name }) => name === "ReaDispatchProtocol",
);
assert.equal(protocol?.methods[0]?.is_required, true);
assert.equal(protocol?.methods[0]?.is_optional, false);
assert.equal(protocol?.optional_methods[0]?.is_required, false);
assert.equal(protocol?.optional_methods[0]?.is_optional, true);
if (
!metadata.objc_dispatch_implementations.some(
(item) =>
@@ -162,6 +193,25 @@ try {
await exec("/usr/bin/strip", ["-x", objcPath]);
const { target, metadata } = await inspect(objcPath);
checkObjc(metadata, mode);
if (mode.mode === "chained" && variant === "stripped") {
const cli = await artifactCliEvidence(
"inspect-native-dispatch-metadata",
objcPath,
);
const cliResult = nativeDispatchMetadataResultSchema.parse(
cli.normalized_result,
);
assert.equal(cliResult.target_sha256, target.sha256);
checkObjc(cliResult.result, mode);
await withArtifactMcp(objcPath, async (client) => {
const mcpResult = nativeDispatchMetadataResultSchema.parse(
await artifactMcpResult(client, "inspect_native_dispatch_metadata"),
);
assert.equal(mcpResult.target_sha256, target.sha256);
checkObjc(mcpResult.result, mode);
assert.deepEqual(mcpResult.result, cliResult.result);
});
}
results.push({
variant: `objc-${mode.mode}-${variant}`,
target_sha256: target.sha256,
@@ -60,6 +60,15 @@ use inclusive ends and can contain gaps. Do not treat the enclosing span as code
For packed targets, retain the original and separately derived artifact identities;
decompiling an unpacking stub does not recover the unpacked program.
When reading Objective-C dispatch metadata, distinguish class implementation
methods from protocol requirements: class methods report null requirement
flags, while decoded protocol declarations retain required and optional facts.
Property attributes remain raw name/value tokens; use the property's
`atomicity` field for the decoded `N` marker or the default atomic behavior.
Null class/meta flags, ivar counts, atomicity, or read-only values mean the
selected provider did not establish that fact. Symbol names alone do not prove
that a class is a root class or has no ivars.
## Managed PE/CLI
Start with `inspect_managed_artifact`. REA's canonical managed inspection is
@@ -14,6 +14,15 @@ describe("ObjC/Swift metadata", () => {
);
expect(result.objc_classes).toHaveLength(1);
expect(result.objc_dispatch_implementations).toHaveLength(1);
expect(result.objc_classes[0]).toMatchObject({
is_meta_class: null,
is_root_class: null,
ivar_count: null,
});
expect(result.objc_classes[0]?.methods[0]).toMatchObject({
is_required: null,
is_optional: null,
});
expect(result.swift_symbols).toHaveLength(0);
expect(result.coverage).toContainEqual(
expect.objectContaining({
+13 -16
View File
@@ -12,10 +12,6 @@ export const objcMethodTypeSchema = z.enum([
export const objcPropertyAttributeSchema = z.strictObject({
name: z.string().min(1),
value: z.string(),
is_weak: z.boolean().default(false),
is_atomic: z.boolean().default(false),
is_copy: z.boolean().default(false),
is_strong: z.boolean().default(false),
});
/** Objective-C method metadata. */
@@ -23,8 +19,8 @@ export const objcMethodSchema = z.strictObject({
selector: z.string().min(1),
method_type: objcMethodTypeSchema,
address: z.number().int().nullable(),
is_required: z.boolean().default(false),
is_optional: z.boolean().default(false),
is_required: z.boolean().nullable(),
is_optional: z.boolean().nullable(),
});
type ObjcMethod = z.infer<typeof objcMethodSchema>;
@@ -32,8 +28,9 @@ type ObjcMethod = z.infer<typeof objcMethodSchema>;
export const objcPropertySchema = z.strictObject({
name: z.string().min(1),
type_encoding: z.string().nullable(),
attributes: z.array(objcPropertyAttributeSchema).default([]),
is_readonly: z.boolean().default(false),
attributes: z.array(objcPropertyAttributeSchema),
atomicity: z.enum(["atomic", "nonatomic"]).nullable(),
is_readonly: z.boolean().nullable(),
getter: z.string().nullable(),
setter: z.string().nullable(),
});
@@ -50,12 +47,12 @@ export const objcProtocolSchema = z.strictObject({
export const objcClassSchema = z.strictObject({
name: z.string().min(1),
super_class: z.string().nullable(),
is_meta_class: z.boolean().default(false),
is_root_class: z.boolean().default(false),
is_meta_class: z.boolean().nullable(),
is_root_class: z.boolean().nullable(),
methods: z.array(objcMethodSchema).default([]),
properties: z.array(objcPropertySchema).default([]),
protocols: z.array(z.string()).default([]),
ivar_count: z.number().int().nonnegative().default(0),
ivar_count: z.number().int().nonnegative().nullable(),
instance_size: z.number().int().nonnegative().nullable(),
location: z
.object({
@@ -380,20 +377,20 @@ export const inspectNativeDispatchMetadata = (
selector: implementation.selector,
method_type: implementation.method_type,
address: parseMetadataAddress(implementation.implementation_address),
is_required: false,
is_optional: false,
is_required: null,
is_optional: null,
});
methodsByClass.set(implementation.class_name, methods);
}
const classes: ObjcClass[] = [...classNames].sort().map((name) => ({
name,
super_class: null,
is_meta_class: false,
is_root_class: false,
is_meta_class: null,
is_root_class: null,
methods: methodsByClass.get(name) ?? [],
properties: [],
protocols: [],
ivar_count: 0,
ivar_count: null,
instance_size: null,
}));
const decodedImplementations = [...implementations.values()].sort(
+96 -3
View File
@@ -3,7 +3,7 @@ import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js";
const base = 0x100000000n;
const fixture = (relative = false) => {
const bytes = Buffer.alloc(2048);
const bytes = Buffer.alloc(4096);
const u32 = (offset: number, value: number) =>
bytes.writeUInt32LE(value, offset);
const ptr = (offset: number, target: number) =>
@@ -16,8 +16,8 @@ const fixture = (relative = false) => {
u32(36, 152);
bytes.write("__DATA", 40);
ptr(56, 0);
bytes.writeBigUInt64LE(2048n, 64);
bytes.writeBigUInt64LE(2048n, 80);
bytes.writeBigUInt64LE(4096n, 64);
bytes.writeBigUInt64LE(4096n, 80);
u32(92, 5);
u32(96, 1);
bytes.write("__objc_classlist", 104);
@@ -33,6 +33,8 @@ const fixture = (relative = false) => {
ptr(0x318, 0x450);
ptr(0x320, 0x480);
ptr(0x330, 0x500);
ptr(0x328, 0x700);
ptr(0x340, 0x600);
u32(0x380, 1);
ptr(0x398, 0x450);
bytes.write("Fixture\0", 0x450);
@@ -56,6 +58,34 @@ const fixture = (relative = false) => {
u32(0x580, 8);
bytes.write("state\0", 0x5a0);
bytes.write("i\0", 0x5c0);
u32(0x600, 16);
u32(0x604, 3);
ptr(0x608, 0x640);
ptr(0x610, 0x650);
ptr(0x618, 0x660);
ptr(0x620, 0x670);
ptr(0x628, 0x680);
ptr(0x630, 0x690);
bytes.write("atomicValue\0", 0x640);
bytes.write("Tq\0", 0x650);
bytes.write("nonatomicValue\0", 0x660);
bytes.write("Tq,N\0", 0x670);
bytes.write("unknownValue\0", 0x680);
bytes.writeBigUInt64LE(1n, 0x700);
ptr(0x708, 0x740);
ptr(0x748, 0x900);
ptr(0x758, 0x800);
ptr(0x768, 0x840);
u32(0x780, 72);
u32(0x800, 24);
u32(0x804, 1);
ptr(0x808, 0x920);
u32(0x840, 24);
u32(0x844, 1);
ptr(0x848, 0x940);
bytes.write("FixtureProtocol\0", 0x900);
bytes.write("requiredAction:\0", 0x920);
bytes.write("optionalAction:\0", 0x940);
return bytes;
};
const provenance = { path: "/fixture/app", sha256: "a".repeat(64) };
@@ -92,6 +122,69 @@ describe("Apple dispatch binary metadata", () => {
location: { file_offset: 0x508 },
},
]);
expect(result.objc_classes[0]?.properties).toEqual([
{
name: "atomicValue",
type_encoding: "q",
attributes: [{ name: "T", value: "q" }],
atomicity: "atomic",
is_readonly: false,
getter: null,
setter: null,
},
{
name: "nonatomicValue",
type_encoding: "q",
attributes: [
{ name: "T", value: "q" },
{ name: "N", value: "" },
],
atomicity: "nonatomic",
is_readonly: false,
getter: null,
setter: null,
},
{
name: "unknownValue",
type_encoding: null,
attributes: [],
atomicity: null,
is_readonly: null,
getter: null,
setter: null,
},
]);
expect(result.objc_classes[0]).toMatchObject({
is_meta_class: false,
is_root_class: false,
ivar_count: 1,
});
expect(result.objc_classes[1]).toMatchObject({
is_meta_class: true,
is_root_class: false,
ivar_count: null,
});
expect(result.objc_classes[0]?.methods[0]).toMatchObject({
is_required: null,
is_optional: null,
});
expect(result.objc_protocols[0]).toMatchObject({
name: "FixtureProtocol",
methods: [
{
selector: "requiredAction:",
is_required: true,
is_optional: false,
},
],
optional_methods: [
{
selector: "optionalAction:",
is_required: false,
is_optional: true,
},
],
});
},
);
it("keeps unsupported pointers and record truncation explicit", () => {
+13 -14
View File
@@ -275,28 +275,27 @@ describe("Objective-C property attributes", () => {
).toEqual({
name: "title",
type_encoding: '@"NSString<NSCopying, NSSecureCoding>"',
atomicity: "nonatomic",
attributes: [
expect.objectContaining({
name: "T",
value: '@"NSString<NSCopying, NSSecureCoding>"',
}),
expect.objectContaining({ name: "C", is_copy: true }),
expect.objectContaining({ name: "N" }),
expect.objectContaining({ name: "G", value: "customTitle" }),
expect.objectContaining({ name: "V", value: "_title" }),
{ name: "T", value: '@"NSString<NSCopying, NSSecureCoding>"' },
{ name: "C", value: "" },
{ name: "N", value: "" },
{ name: "G", value: "customTitle" },
{ name: "V", value: "_title" },
],
is_readonly: false,
getter: "customTitle",
setter: null,
});
expect(parsePropertyAttributes("count", "Tq,R,N")).toMatchObject({
expect(parsePropertyAttributes("count", "Tq,R")).toMatchObject({
type_encoding: "q",
is_readonly: true,
atomicity: "atomic",
});
expect(
parsePropertyAttributes("delegate", "T@,W,N,V_delegate").attributes[1],
).toMatchObject({
is_weak: true,
});
expect(parsePropertyAttributes("count", "Tq,R,N").atomicity).toBe(
"nonatomic",
);
expect(parsePropertyAttributes("count", "").atomicity).toBeNull();
expect(parsePropertyAttributes("count", "R").atomicity).toBeNull();
});
});
+3 -3
View File
@@ -216,7 +216,7 @@ export const decodeObjcDispatchFacets = (input: {
`Superclass of ${name} requires external binding resolution`,
);
const ivarList = read.pointer(ro + 48n);
let ivarCount = 0;
let ivarCount: number | null = meta ? null : 0;
if (ivarList !== 0n && !meta) {
const stride = read.u32(ivarList),
count = read.u32(ivarList + 4n);
@@ -276,8 +276,8 @@ export const decodeObjcDispatchFacets = (input: {
BigInt(Number.MAX_SAFE_INTEGER)
? Number(BigInt(item.implementation_address))
: null,
is_required: false,
is_optional: false,
is_required: null,
is_optional: null,
})),
properties: readObjcPropertiesOf(read, read.pointer(ro + 64n), {
owner: name,
+19 -7
View File
@@ -21,20 +21,32 @@ export const parsePropertyAttributes = (
attributes: string,
): ObjcProperty => {
const tokens = attributes.length === 0 ? [] : splitAttributes(attributes);
const typeAttributes = tokens.filter((token) => token[0] === "T");
const typeAttribute =
typeAttributes.length === 1 ? typeAttributes[0] : undefined;
const typeEncoding =
typeAttribute !== undefined && typeAttribute.length > 1
? typeAttribute.slice(1)
: null;
const value = (code: string): string | null =>
tokens.find((token) => token.startsWith(code))?.slice(1) ?? null;
tokens.find((token) => token[0] === code)?.slice(1) ?? null;
return {
name,
type_encoding: value("T"),
type_encoding: typeEncoding,
attributes: tokens.map((token) => ({
name: token.slice(0, 1),
value: token.slice(1),
is_weak: token === "W",
is_atomic: false,
is_copy: token === "C",
is_strong: token === "&",
})),
is_readonly: tokens.includes("R"),
atomicity: tokens.includes("N")
? "nonatomic"
: typeEncoding === null
? null
: "atomic",
is_readonly: tokens.includes("R")
? true
: typeEncoding === null
? null
: false,
getter: value("G"),
setter: value("S"),
};
+1
View File
@@ -10,6 +10,7 @@
int state;
}
@property(nonatomic, copy) NSString *fixtureName;
@property NSInteger atomicValue;
- (void)performAction:(id)sender;
+ (int)fixtureVersion;
@end