fix(apple): report missing keyed archive hierarchy references (#1104)

* test(apple): qualify malformed native hierarchy references

Freeze actual Foundation/AppKit producer, single-slot positive UID mutation and real optional nil control at filesystem and compiled CLI boundaries. Preserve original raw-byte evidence and existing CI.

Signed-off-by: Rudy Celekli <rudy@gradiahq.com>

* fix(apple): report missing keyed archive hierarchy references

Preserve known hierarchy objects and links while reporting unresolved nonnil object-table references as partial coverage. Retain archived nil semantics and original byte evidence.

Signed-off-by: Rudy Celekli <rudy@gradiahq.com>

---------

Signed-off-by: Rudy Celekli <rudy@gradiahq.com>
This commit is contained in:
Rudy Mizrahi Celekli 2026-10-08 13:31:53 -04:00 committed by GitHub
parent 4bb056bbb5
commit 3b2ec69c54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 475 additions and 17 deletions

View File

@ -258,3 +258,10 @@ GUI authority, and REA never falls back automatically to Hopper.
macOS ARM64 is the real host verified during this implementation. Admission of
macOS Intel does not claim an Intel verification run. Unsupported metadata and
unresolved runtime/value semantics remain visible in results.
## Interface Builder hierarchy coverage
A keyed-archive hierarchy UID without an object-table entry marks the hierarchy
as partial while preserving decoded objects and known links. UID 0 remains
archived nil and does not count as a missing reference. Archive and evidence
digests identify the original serialized bytes.

View File

@ -65,6 +65,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
let omittedObjects = 0;
let omittedConnections = 0;
let omittedGraphNodes = 0;
let omittedHierarchyReferences = 0;
for (const document of input.documents.slice(0, input.limits.max_documents)) {
const parsed = parseInterfaceBuilderRecords(document.raw);
const prefix = `ib:${document.relativePath}:`;
@ -403,6 +404,8 @@ export const buildInterfaceBuilderAnalysis = (input: {
}
const objectsTruncated =
parsed.omittedObjects > 0 || parsed.objects.length > documentObjectCount;
const parserOmittedHierarchyReferences = parsed.omittedHierarchyReferences;
omittedHierarchyReferences += parserOmittedHierarchyReferences;
const parserOmittedConnections = parsed.omittedConnections;
const connectionsTruncated =
parserOmittedConnections > 0 ||
@ -419,7 +422,10 @@ export const buildInterfaceBuilderAnalysis = (input: {
document_kind: document.documentKind,
object_count: parsed.objectCount,
connection_count: parsed.connections.length + parserOmittedConnections,
hierarchy_complete: parsed.hierarchy.length > 0 && !truncated,
hierarchy_complete:
parsed.hierarchy.length > 0 &&
!truncated &&
parserOmittedHierarchyReferences === 0,
});
coverage.push(
{
@ -456,19 +462,24 @@ export const buildInterfaceBuilderAnalysis = (input: {
{
facet: `hierarchy:${document.relativePath}`,
status:
parsed.hierarchy.length === 0
? "unsupported"
: hierarchyTruncated || documentOmittedHierarchyEdges > 0
? "partial"
: "complete",
parserOmittedHierarchyReferences > 0
? "partial"
: parsed.hierarchy.length === 0
? "unsupported"
: hierarchyTruncated || documentOmittedHierarchyEdges > 0
? "partial"
: "complete",
reason:
parsed.hierarchy.length === 0
? "archive_hierarchy_not_decoded"
: hierarchyTruncated || documentOmittedHierarchyEdges > 0
? "graph_limit_reached"
: null,
parserOmittedHierarchyReferences > 0
? "archive_hierarchy_references_missing"
: parsed.hierarchy.length === 0
? "archive_hierarchy_not_decoded"
: hierarchyTruncated || documentOmittedHierarchyEdges > 0
? "graph_limit_reached"
: null,
examined: hierarchyCount,
omitted: documentOmittedHierarchyEdges,
omitted:
documentOmittedHierarchyEdges + parserOmittedHierarchyReferences,
},
{
facet: `classes:${document.relativePath}`,
@ -514,7 +525,11 @@ export const buildInterfaceBuilderAnalysis = (input: {
nodes: [...nodes.values()],
edges,
coverage,
truncated: truncated || omittedObjects > 0 || omittedConnections > 0,
truncated:
truncated ||
omittedObjects > 0 ||
omittedConnections > 0 ||
omittedHierarchyReferences > 0,
},
limitations: [
"The graph reflects recognized Interface Builder archive fields and connections; omitted private or unrecognized archive fields remain unknown.",

View File

@ -45,6 +45,7 @@ export const parseInterfaceBuilderRecords = (
readonly objectCount: number;
readonly omittedObjects: number;
readonly omittedConnections: number;
readonly omittedHierarchyReferences: number;
} => {
const root = record(value);
const keyed = parseKeyedArchive(root);
@ -114,6 +115,7 @@ export const parseInterfaceBuilderRecords = (
objectCount: objectEntries.length,
omittedObjects: Math.max(0, objectEntries.length - 20_000),
omittedConnections,
omittedHierarchyReferences: 0,
};
};
@ -194,7 +196,8 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => {
}
return output;
};
const hierarchy = (start: unknown): unknown[] => {
const hierarchy = (start: unknown) => {
let omittedReferences = 0;
const visited = new Set<number>();
const root: unknown[] = [];
const pending: { readonly value: unknown; readonly output: unknown[] }[] = [
@ -227,9 +230,15 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => {
const { value, output } = entry;
const uid = archiveUid(value);
if (uid !== undefined) {
// UID0 is archived nil, not a missing hierarchy object.
if (uid === 0) continue;
const target = objectTable[uid];
if (target === undefined) {
omittedReferences += 1;
continue;
}
if (visited.has(uid)) continue;
visited.add(uid);
const target = objectTable[uid];
if (Array.isArray(target)) {
for (let index = target.length - 1; index >= 0; index -= 1) {
const child = target[index];
@ -260,7 +269,7 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => {
appendNode(record(value), undefined, output);
}
}
return root;
return { items: root, omittedReferences };
};
const archivedArrayClass = (classReference: unknown): boolean => {
const uid = archiveUid(classReference);
@ -386,11 +395,12 @@ const parseKeyedArchive = (
return {
objects,
connections,
hierarchy,
hierarchy: hierarchy.items,
classes,
objectCount,
omittedObjects: Math.max(0, objectCount - 20_000),
omittedConnections,
omittedHierarchyReferences: hierarchy.omittedReferences,
};
};

View File

@ -9,6 +9,15 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { decodeNibArchive } from "../../../src/artifacts/apple/NibArchive.js";
import { analyzeInterfaceBuilderBundle } from "../../../src/artifacts/apple/InterfaceBuilderAnalysis.js";
import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import {
createInterfaceBuilderUidFixture,
expectInterfaceBuilderUidCase,
interfaceBuilderUidCases,
} from "../../fixtures/interfaceBuilderMalformedUid.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const compile = promisify(execFile);
const compileNestedViews = async (depth: number, withWindow = false) => {
@ -178,3 +187,49 @@ describe.skipIf(process.platform !== "darwin")(
);
},
);
// Selected by the existing native Apple CI job; no workflow change is needed.
describe.skipIf(process.platform !== "darwin")(
"Foundation malformed hierarchy references",
() => {
it.each(interfaceBuilderUidCases)(
"filesystem reports %s hierarchy coverage and original bytes",
async (selectedCase) => {
const fixture = await createInterfaceBuilderUidFixture(selectedCase);
const result = await analyzeInterfaceBuilderBundle({
bundlePath: fixture.app,
targetSha256: "f".repeat(64),
});
expectInterfaceBuilderUidCase(
result,
selectedCase,
fixture.bytes,
fixture.oracle,
);
},
);
cliTest.for(interfaceBuilderUidCases)(
"built CLI reports $0 hierarchy coverage and original bytes",
async (selectedCase, { cli }) => {
const fixture = await createInterfaceBuilderUidFixture(selectedCase);
const output = await cli.run({
arguments: ["decode-interface-builder", fixture.app, "--json"],
environment: {
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "auto",
},
});
expect(output.exitCode).toBe(0);
const result = interfaceBuilderAnalysisSchema.parse(
parseEvidence(output.json).normalized_result,
);
expectInterfaceBuilderUidCase(
result,
selectedCase,
fixture.bytes,
fixture.oracle,
);
},
);
},
);

View File

@ -0,0 +1,170 @@
import AppKit
import CryptoKit
let output = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
let root = NSView(frame: NSRect(x: 0, y: 0, width: 80, height: 40))
root.identifier = NSUserInterfaceItemIdentifier("Root")
for name in ["Kept", "Changed"] {
let child = NSView(frame: .zero)
child.identifier = NSUserInterfaceItemIdentifier(name)
root.addSubview(child)
}
func archiveView(includeNil: Bool) -> Data {
let archiver = NSKeyedArchiver(requiringSecureCoding: false)
archiver.outputFormat = .xml
archiver.encode(root, forKey: NSKeyedArchiveRootObjectKey)
if includeNil { archiver.encode(nil as Any?, forKey: "OptionalNil") }
archiver.finishEncoding()
return archiver.encodedData
}
// Foundation reads UID markers as opaque objects. Preserve XML markers for the oracle.
final class PlistElement {
let kind: String
var text = ""
var children: [PlistElement] = []
init(_ kind: String) { self.kind = kind }
}
final class PlistXMLReader: NSObject, XMLParserDelegate {
let document = PlistElement("document")
var stack: [PlistElement] = []
override init() { super.init(); stack = [document] }
func parser(_ parser: XMLParser, didStartElement name: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) {
let element = PlistElement(name)
stack.last!.children.append(element)
stack.append(element)
}
func parser(_ parser: XMLParser, foundCharacters text: String) { stack.last!.text += text }
func parser(_ parser: XMLParser, didEndElement name: String, namespaceURI: String?, qualifiedName: String?) { stack.removeLast() }
}
func readPlist(_ element: PlistElement) -> Any {
switch element.kind {
case "plist": return readPlist(element.children[0])
case "dict":
guard element.children.count % 2 == 0 else { fatalError("Incomplete dictionary") }
var result: [String: Any] = [:]
for index in stride(from: 0, to: element.children.count, by: 2) {
guard element.children[index].kind == "key" else { fatalError("Missing key") }
result[element.children[index].text] = readPlist(element.children[index + 1])
}
return result
case "array": return element.children.map(readPlist)
case "string": return element.text
case "integer": return Int(element.text)!
case "real": return Double(element.text)!
case "true": return true
case "false": return false
default: fatalError("Unexpected NSView plist element \(element.kind)")
}
}
func readXML(_ bytes: Data) -> [String: Any] {
let reader = PlistXMLReader()
let parser = XMLParser(data: bytes)
parser.shouldResolveExternalEntities = false
parser.delegate = reader
guard parser.parse(), let plist = reader.document.children.first,
let value = readPlist(plist) as? [String: Any] else {
fatalError("Cannot read Foundation XML object table")
}
return value
}
func canonicalFoundationXML(_ bytes: Data) throws -> Data {
let value = try PropertyListSerialization.propertyList(from: bytes, options: [], format: nil)
return try PropertyListSerialization.data(fromPropertyList: value, format: .xml, options: 0)
}
func json(_ value: Any) throws -> Data {
try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
}
func uid(_ value: Any?) -> Int {
guard let marker = value as? [String: Any], marker.count == 1,
let number = marker["CF$UID"] as? NSNumber else {
fatalError("Expected XML UID marker")
}
return number.intValue
}
let original = archiveView(includeNil: false)
let originalFields = readXML(original)
guard let table = originalFields["$objects"] as? [Any],
table.first as? String == "$null",
let top = originalFields["$top"] as? [String: Any] else {
fatalError("Expected NSKeyedArchiver table and nil sentinel")
}
let rootUID = uid(top[NSKeyedArchiveRootObjectKey])
guard let rootFields = table[rootUID] as? [String: Any] else { fatalError("Missing NSView root") }
let collectionUID = uid(rootFields["NSSubviews"])
guard let collection = table[collectionUID] as? [String: Any],
let references = collection["NS.objects"] as? [Any], references.count == 2 else {
fatalError("Missing actual NSView child collection")
}
let children = references.map { uid($0) }
var views: [[String: Any]] = []
for index in [rootUID] + children {
guard let fields = table[index] as? [String: Any],
let descriptor = table[uid(fields["$class"])] as? [String: Any],
descriptor["$classname"] as? String == "NSView",
let identifier = table[uid(fields["NSReuseIdentifierKey"])] as? String else {
fatalError("Expected actual NSView identity")
}
if index != rootUID && uid(fields["NSSuperview"]) != rootUID {
fatalError("Expected reciprocal parent UID")
}
views.append(["uid": index, "identifier": identifier])
}
guard views.compactMap({ $0["identifier"] as? String }) == ["Root", "Kept", "Changed"] else {
fatalError("Unexpected native identifiers")
}
// Check generic XML-marker serialization against Foundation's opaque UID interpretation.
let rewrittenOriginal = try PropertyListSerialization.data(fromPropertyList: originalFields, format: .xml, options: 0)
guard try canonicalFoundationXML(original) == canonicalFoundationXML(rewrittenOriginal) else {
fatalError("Oracle changed the native archived property list")
}
let danglingUID = table.count + 1
var changedReferences = references
changedReferences[1] = ["CF$UID": danglingUID]
var changedCollection = collection
changedCollection["NS.objects"] = changedReferences
var changedTable = table
changedTable[collectionUID] = changedCollection
var mutantFields = originalFields
mutantFields["$objects"] = changedTable
let dangling = try PropertyListSerialization.data(fromPropertyList: mutantFields, format: .xml, options: 0)
_ = try canonicalFoundationXML(dangling) // Plist syntax is valid; its reference is deliberately malformed.
let rereadMutant = readXML(dangling)
guard try json(rereadMutant) == json(mutantFields) else { fatalError("Mutant serialization changed the table") }
// Independent inverse comparison proves exactly one actual serialized UID slot changed.
guard var restoredTable = rereadMutant["$objects"] as? [Any],
var restoredCollection = restoredTable[collectionUID] as? [String: Any],
var restoredReferences = restoredCollection["NS.objects"] as? [Any],
uid(restoredReferences[1]) == danglingUID else { fatalError("Missing mutated child UID") }
restoredReferences[1] = references[1]
restoredCollection["NS.objects"] = restoredReferences
restoredTable[collectionUID] = restoredCollection
var restoredArchive = rereadMutant
restoredArchive["$objects"] = restoredTable
guard try json(restoredArchive) == json(originalFields) else { fatalError("More than one UID slot changed") }
// A real optional top-level nil is not a synthetic nil element in an NSArray.
let nilControl = archiveView(includeNil: true)
var nilFields = readXML(nilControl)
guard var nilTop = nilFields["$top"] as? [String: Any], uid(nilTop["OptionalNil"]) == 0 else {
fatalError("Foundation did not encode optional nil as UID0")
}
nilTop.removeValue(forKey: "OptionalNil")
nilFields["$top"] = nilTop
guard try json(nilFields) == json(originalFields) else { fatalError("Optional nil changed the object graph") }
_ = try canonicalFoundationXML(nilControl)
let files = ["original": original, "optional-nil": nilControl, "dangling-child": dangling]
var digests: [String: String] = [:]
for (name, bytes) in files {
try bytes.write(to: output.appendingPathComponent("\(name).nib"))
digests[name] = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined()
}
let oracle: [String: Any] = [
"rootUID": rootUID, "childUIDs": children, "collectionUID": collectionUID,
"danglingUID": danglingUID, "tableLength": table.count,
"views": views, "nilUID": 0, "nilSentinel": "$null", "digests": digests,
"mutationPath": ["$objects", String(collectionUID), "NS.objects", "1", "CF$UID"],
]
try json(oracle).write(to: output.appendingPathComponent("oracle.json"))

View File

@ -0,0 +1,201 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect } from "vitest";
import { z } from "zod";
import { analyzeInterfaceBuilderBundle } from "../../src/artifacts/apple/InterfaceBuilderAnalysis.js";
import { thinMach } from "../../src/domain/binaryTarget.fixture.js";
import { createTestTempDirectory } from "./temporaryDirectory.js";
export const interfaceBuilderUidCases = [
"original",
"optional-nil",
"dangling-child",
] as const;
type UidCase = (typeof interfaceBuilderUidCases)[number];
type Analysis = Awaited<ReturnType<typeof analyzeInterfaceBuilderBundle>>;
const oracleSchema = z.strictObject({
rootUID: z.number().int().positive(),
childUIDs: z.array(z.number().int().positive()).length(2),
collectionUID: z.number().int().positive(),
danglingUID: z.number().int().positive(),
tableLength: z.number().int().positive(),
views: z
.array(
z.strictObject({
uid: z.number().int().positive(),
identifier: z.string(),
}),
)
.length(3),
nilUID: z.literal(0),
nilSentinel: z.literal("$null"),
digests: z.strictObject({
original: z.string(),
"optional-nil": z.string(),
"dangling-child": z.string(),
}),
mutationPath: z.array(z.string()).length(5),
});
type Oracle = z.infer<typeof oracleSchema>;
const archivePath = "Contents/Resources/Views.nib";
const viewId = (result: Analysis, uid: number) => {
const view = result.graph.nodes.find(
({ attributes }) => attributes.interface_builder_object_id === String(uid),
);
if (view === undefined)
throw new Error(`Missing native NSView UID ${String(uid)}`);
return view.id;
};
const expectView = (result: Analysis, oracle: Oracle, uid: number) => {
const expected = oracle.views.find((view) => view.uid === uid);
expect(expected).toBeDefined();
expect(result.graph.nodes).toContainEqual(
expect.objectContaining({
kind: "view",
name: "NSView",
attributes: expect.objectContaining({
class_name: "NSView",
interface_builder_object_id: String(uid),
NSReuseIdentifierKey: expected?.identifier,
}),
}),
);
};
const childEdges = (result: Analysis, oracle: Oracle) =>
result.graph.edges.filter(
({ id, from, relation }) =>
from === viewId(result, oracle.rootUID) &&
relation === "contains" &&
id.includes(":hierarchy:"),
);
/** Native producer and independent plist oracle precede every consumer assertion. */
export const createInterfaceBuilderUidFixture = async (
selectedCase: UidCase,
) => {
const directory = await createTestTempDirectory("rea-ib-uid-");
await promisify(execFile)("/usr/bin/xcrun", [
"swift",
"tests/conformance/native/interface-builder-malformed-uid.swift",
directory,
]);
const oracle = oracleSchema.parse(
JSON.parse(await readFile(join(directory, "oracle.json"), "utf8")),
);
expect(oracle.views.map(({ identifier }) => identifier)).toEqual([
"Root",
"Kept",
"Changed",
]);
expect(oracle.danglingUID).toBeGreaterThan(oracle.tableLength);
expect(oracle.mutationPath).toEqual([
"$objects",
String(oracle.collectionUID),
"NS.objects",
"1",
"CF$UID",
]);
const makeApp = async (name: string, variant: UidCase) => {
const app = join(directory, `${name}.app`);
const contents = join(app, "Contents");
await mkdir(join(contents, "Resources"), { recursive: true });
await mkdir(join(contents, "MacOS"));
// This known Mach-O selects the artifact provider; target execution is not tested.
await writeFile(
join(contents, "MacOS/App"),
thinMach(0xfeedfacf, 0x0100000c),
);
await writeFile(
join(contents, "Info.plist"),
"<plist><dict><key>CFBundleExecutable</key><string>App</string><key>CFBundleIdentifier</key><string>com.example.uid</string></dict></plist>",
);
const bytes = await readFile(join(directory, `${variant}.nib`));
expect(createHash("sha256").update(bytes).digest("hex")).toBe(
oracle.digests[variant],
);
await writeFile(join(contents, "Resources", "Views.nib"), bytes);
return { app, bytes };
};
const original = await makeApp("Reference", "original");
const reference = await analyzeInterfaceBuilderBundle({
bundlePath: original.app,
targetSha256: "f".repeat(64),
});
expectInterfaceBuilderUidCase(reference, "original", original.bytes, oracle);
return { ...(await makeApp("Selected", selectedCase)), oracle };
};
/** Preserve surviving observed links and original byte provenance, and report lost links truthfully. */
export const expectInterfaceBuilderUidCase = (
result: Analysis,
selectedCase: UidCase,
bytes: Buffer,
oracle: Oracle,
) => {
const digest = createHash("sha256").update(bytes).digest("hex");
expect(result.documents).toEqual([
expect.objectContaining({
relative_path: archivePath,
archive_sha256: digest,
}),
]);
for (const node of result.graph.nodes) {
expect(node.evidence.length).toBeGreaterThan(0);
for (const evidence of node.evidence)
expect(evidence).toMatchObject({
artifact_path: archivePath,
artifact_sha256: digest,
});
}
for (const edge of result.graph.edges)
for (const evidence of edge.evidence)
expect(evidence).toMatchObject({
artifact_path: archivePath,
artifact_sha256: digest,
});
expectView(result, oracle, oracle.rootUID);
const keptUID = oracle.childUIDs[0];
if (keptUID === undefined) throw new Error("Native oracle lacks kept child");
expectView(result, oracle, keptUID);
expect(childEdges(result, oracle)).toContainEqual(
expect.objectContaining({
from: viewId(result, oracle.rootUID),
to: viewId(result, keptUID),
relation: "contains",
resolution: "observed",
}),
);
const coverage = result.graph.coverage.find(
({ facet }) => facet === `hierarchy:${archivePath}`,
);
expect(coverage).toBeDefined();
if (selectedCase !== "dangling-child") {
for (const uid of oracle.childUIDs) expectView(result, oracle, uid);
expect(childEdges(result, oracle).map(({ to }) => to)).toEqual(
oracle.childUIDs.map((uid) => viewId(result, uid)),
);
expect(result.documents[0]?.hierarchy_complete).toBe(true);
expect(coverage).toMatchObject({
status: "complete",
reason: null,
omitted: 0,
});
expect(result.graph.truncated).toBe(false);
return;
}
// A valid root and sibling do not make a silently discarded positive UID complete.
expect(result.documents[0]?.hierarchy_complete).toBe(false);
expect(coverage?.status).toBe("partial");
expect(coverage?.reason).toEqual(expect.any(String));
expect(coverage?.reason?.length).toBeGreaterThan(0);
const explicitUnknown = childEdges(result, oracle).some(
(edge) => edge.resolution === "unresolved" && edge.reason.length > 0,
);
expect((coverage?.omitted ?? 0) > 0 || explicitUnknown).toBe(true);
};