diff --git a/docs/native-investigation.md b/docs/native-investigation.md index cec860b64..625245a02 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -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. diff --git a/src/domain/apple/interfaceBuilderGraph.ts b/src/domain/apple/interfaceBuilderGraph.ts index 37bc094c7..bf2196031 100644 --- a/src/domain/apple/interfaceBuilderGraph.ts +++ b/src/domain/apple/interfaceBuilderGraph.ts @@ -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.", diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.ts b/src/domain/apple/interfaceBuilderKeyedArchive.ts index f354af8ad..51b446b52 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.ts @@ -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(); 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, }; }; diff --git a/tests/boundary/filesystem/nibNativeHierarchy.test.ts b/tests/boundary/filesystem/nibNativeHierarchy.test.ts index 360b7e190..2d956246a 100644 --- a/tests/boundary/filesystem/nibNativeHierarchy.test.ts +++ b/tests/boundary/filesystem/nibNativeHierarchy.test.ts @@ -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, + ); + }, + ); + }, +); diff --git a/tests/conformance/native/interface-builder-malformed-uid.swift b/tests/conformance/native/interface-builder-malformed-uid.swift new file mode 100644 index 000000000..b441290ca --- /dev/null +++ b/tests/conformance/native/interface-builder-malformed-uid.swift @@ -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")) diff --git a/tests/fixtures/interfaceBuilderMalformedUid.ts b/tests/fixtures/interfaceBuilderMalformedUid.ts new file mode 100644 index 000000000..903efdc11 --- /dev/null +++ b/tests/fixtures/interfaceBuilderMalformedUid.ts @@ -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>; +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; +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"), + "CFBundleExecutableAppCFBundleIdentifiercom.example.uid", + ); + 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); +};