From f8e143a3bcd20ddcc0d76d90e73e238d456fe5a3 Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:11:49 -0400 Subject: [PATCH] fix(apple): decode Interface Builder XML byte encodings (#1095) * test(apple): qualify native Interface Builder XML encodings Exercise actual AppKit/Foundation XML archive representations through filesystem and compiled CLI consumers. Preserve independent UID/hierarchy and original-byte provenance oracles. Tests-only candidate qualification on unchanged native Apple CI; no production or workflow changes. Signed-off-by: Rudy Celekli * fix(apple): share BOM-aware Interface Builder XML decoding Reuse the existing fatal BOM-aware XML plist decoder for Interface Builder archives. Preserve original serialized-byte digests, binary decoding, and frozen native producer/consumer tests. Signed-off-by: Rudy Celekli * fix(apple): reject unsupported XML encoding declarations --------- Signed-off-by: Rudy Celekli Co-authored-by: N0zoM1z0 <1705004398@qq.com> --- docs/native-investigation.md | 6 + .../apple/InterfaceBuilderAnalysis.ts | 4 +- src/artifacts/apple/KeyedArchiveReader.ts | 23 +- src/artifacts/apple/XmlPropertyListText.ts | 30 +++ .../filesystem/appleXmlDeclarations.test.ts | 78 +++++++ .../filesystem/interfaceBuilderBundle.test.ts | 89 ++++++++ .../native/interface-builder-encoding.swift | 137 +++++++++++ tests/fixtures/interfaceBuilderEncoding.ts | 213 ++++++++++++++++++ 8 files changed, 558 insertions(+), 22 deletions(-) create mode 100644 src/artifacts/apple/XmlPropertyListText.ts create mode 100644 tests/boundary/filesystem/appleXmlDeclarations.test.ts create mode 100644 tests/conformance/native/interface-builder-encoding.swift create mode 100644 tests/fixtures/interfaceBuilderEncoding.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 7bc105ba4..cec860b64 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -37,6 +37,12 @@ explicit limitation; architecture inventory locations remain available. connections and controller/class names. `verify:interface-builder` compiles a source-owned AppKit XIB with `ibtool`. Storyboards require an installed iOS platform; unsupported archive forms remain explicit. + XML plist archives accept UTF-8 and BOM-marked UTF-16 in either byte order. + The shared keyed-archive and Interface Builder decoder consumes an initial + BOM and rejects malformed byte sequences, unsupported encoding declarations, + and declarations that disagree with the detected encoding. It never substitutes + UTF-8 for a declared encoding it cannot process. Archive and evidence digests + still identify the original serialized bytes. - `inspect_native_dispatch_metadata` / `rea inspect-native-dispatch-metadata ` prefers a validated macOS Mach-O byte reader. It decodes 64-bit little-endian Objective-C class/metaclass records, superclass pointers, diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 6966bbe9c..a61da0f71 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -21,6 +21,8 @@ import { } from "./NibViewHierarchy.js"; import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js"; +import { decodeXmlPlistText } from "./XmlPropertyListText.js"; + const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024; /** Decode compiled Interface Builder archives from a local app bundle. */ @@ -405,6 +407,6 @@ const decodePlist = ( const { value, omittedPrototypeKeys } = bytes.subarray(0, 8).toString("ascii") === "bplist00" ? { value: parseBinary(bytes), omittedPrototypeKeys: 0 } - : parseXmlPropertyList(bytes.toString("utf8")); + : parseXmlPropertyList(decodeXmlPlistText(bytes)); return { value: projectPlistValue(value).value, omittedPrototypeKeys }; }; diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index fafa70fbe..795f5b126 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -1,5 +1,4 @@ import { createHash } from "node:crypto"; -import { TextDecoder } from "node:util"; import { parseBinary } from "plist"; import { z } from "zod"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; @@ -17,6 +16,8 @@ import { import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; import { ArtifactReaderFailure } from "../ArtifactReader.js"; +import { decodeXmlPlistText } from "./XmlPropertyListText.js"; + const MAX_BYTES = 64 * 1024 * 1024; /** Parse inert plist bytes, preserving data and dates with explicit typed values. */ @@ -47,26 +48,6 @@ export const decodeKeyedArchiveBytes = ( }; }; -const decodeXmlPlistText = (bytes: Buffer): string => { - const encoding = - bytes[0] === 0xff && bytes[1] === 0xfe - ? "utf-16le" - : bytes[0] === 0xfe && bytes[1] === 0xff - ? "utf-16be" - : "utf-8"; - const text = new TextDecoder(encoding, { fatal: true }).decode(bytes); - const declared = /^<\?xml\s[^?]*\bencoding\s*=\s*["']([^"']+)["']/u - .exec(text)?.[1] - ?.toLowerCase(); - if ( - declared !== undefined && - ((encoding !== "utf-8" && declared !== encoding && declared !== "utf-16") || - (encoding === "utf-8" && declared.startsWith("utf-16"))) - ) - throw new TypeError("XML encoding declaration disagrees with its bytes"); - return text; -}; - const normalizePlist = (value: unknown): JsonValue => { const projected = projectPlistValue(value); if (projected.unknownRealCount > 0) diff --git a/src/artifacts/apple/XmlPropertyListText.ts b/src/artifacts/apple/XmlPropertyListText.ts new file mode 100644 index 000000000..8fb472d04 --- /dev/null +++ b/src/artifacts/apple/XmlPropertyListText.ts @@ -0,0 +1,30 @@ +import { TextDecoder } from "node:util"; + +/** Decode BOM-aware XML plist text with fatal byte validation. */ +export const decodeXmlPlistText = (bytes: Buffer): string => { + const encoding = + bytes[0] === 0xff && bytes[1] === 0xfe + ? "utf-16le" + : bytes[0] === 0xfe && bytes[1] === 0xff + ? "utf-16be" + : "utf-8"; + const text = new TextDecoder(encoding, { fatal: true }).decode(bytes); + const declaration = /^<\?xml\s[^?]*\bencoding\s*=\s*["']([^"']+)["']/u.exec( + text, + )?.[1]; + const declared = declaration?.toLowerCase(); + if ( + declared !== undefined && + !["utf-8", "utf-16", "utf-16le", "utf-16be"].includes(declared) + ) + throw new TypeError( + `Unsupported XML encoding declaration: ${declaration}. Supported encodings are UTF-8 and BOM-marked UTF-16 in either byte order.`, + ); + if ( + declared !== undefined && + declared !== encoding && + !(encoding !== "utf-8" && declared === "utf-16") + ) + throw new TypeError("XML encoding declaration disagrees with its bytes"); + return text; +}; diff --git a/tests/boundary/filesystem/appleXmlDeclarations.test.ts b/tests/boundary/filesystem/appleXmlDeclarations.test.ts new file mode 100644 index 000000000..e066655eb --- /dev/null +++ b/tests/boundary/filesystem/appleXmlDeclarations.test.ts @@ -0,0 +1,78 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { build } from "plist"; +import { expect, it } from "vitest"; + +import { analyzeInterfaceBuilderBundle } from "../../../src/artifacts/apple/InterfaceBuilderAnalysis.js"; +import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const archive = build({ + $archiver: "NSKeyedArchiver", + $objects: ["$null", "Café"], + $top: { root: { CF$UID: 1 } }, +}); + +const fixture = async (declaration: string) => { + const bundlePath = await createTestTempDirectory("rea-xml-declaration-"); + await writeFile( + join(bundlePath, "Panel.nib"), + Buffer.from(archive.replace("UTF-8", declaration), "utf8"), + ); + return { bundlePath, targetSha256: "a".repeat(64) }; +}; + +it.each(["UTF-8", "utf-8"])( + "retains UTF-8 characters with the supported declaration %s", + async (declaration) => { + const input = await fixture(declaration); + const archiveResult = await inspectBundleKeyedArchive({ + ...input, + parameters: { path: "Panel.nib" }, + }); + expect(archiveResult.objects).toContainEqual( + expect.objectContaining({ value: "Café" }), + ); + const interfaceBuilderResult = await analyzeInterfaceBuilderBundle(input); + expect(interfaceBuilderResult.documents).toHaveLength(1); + expect(interfaceBuilderResult.documents[0]?.relative_path).toBe( + "Panel.nib", + ); + }, +); + +it.each(["REA-UNSUPPORTED", "ISO-8859-1"])( + "reports unsupported keyed archive encoding %s instead of substituting UTF-8 text", + async (declaration) => { + const input = await fixture(declaration); + await expect( + inspectBundleKeyedArchive({ + ...input, + parameters: { path: "Panel.nib" }, + }), + ).rejects.toMatchObject({ + reason: "format", + message: expect.stringContaining(declaration), + }); + }, +); + +it.each(["REA-UNSUPPORTED", "ISO-8859-1"])( + "reports unsupported Interface Builder encoding %s without graph substitution", + async (declaration) => { + const result = await analyzeInterfaceBuilderBundle( + await fixture(declaration), + ); + expect(result.documents).toEqual([]); + expect(result.graph.nodes).toEqual([]); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "archive_decode", + status: "partial", + reason: "one_or_more_archives_invalid", + }), + ); + expect(result.graph.truncated).toBe(true); + }, +); diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index eebf6959d..7caca996c 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -11,6 +11,17 @@ import { analyzeInterfaceBuilderBundle } from "../../../src/artifacts/apple/Inte import { encodeNibArchiveFixture } from "../../../src/artifacts/apple/NibArchive.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { + createInterfaceBuilderEncodingFixture, + expectInterfaceBuilderEncoding, + expectInvalidInterfaceBuilderEncoding, + interfaceBuilderEncodings, + invalidInterfaceBuilderEncodings, +} from "../../fixtures/interfaceBuilderEncoding.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + const compile = promisify(execFile); type PlistValue = @@ -591,3 +602,81 @@ describe("compiled UIKit NIB connections", () => { ); }); }); + +// This exact file is already selected by the unchanged native Apple CI lane. +describe.skipIf(process.platform !== "darwin")( + "Foundation Interface Builder XML encodings", + () => { + it.each(interfaceBuilderEncodings)( + "filesystem preserves native %s graph and original bytes", + async (encoding) => { + const fixture = await createInterfaceBuilderEncodingFixture(encoding); + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: fixture.app, + targetSha256: "e".repeat(64), + }); + expectInterfaceBuilderEncoding( + result, + fixture.expected, + fixture.bytes, + fixture.oracle, + ); + }, + ); + + cliTest.for(interfaceBuilderEncodings)( + "built CLI preserves native $0 graph and original bytes", + async (encoding, { cli }) => { + const fixture = await createInterfaceBuilderEncodingFixture(encoding); + 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, + ); + expectInterfaceBuilderEncoding( + result, + fixture.expected, + fixture.bytes, + fixture.oracle, + ); + }, + ); + + it.each(invalidInterfaceBuilderEncodings)( + "filesystem rejects %s without graph substitution", + async (encoding) => { + const fixture = await createInterfaceBuilderEncodingFixture(encoding); + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: fixture.app, + targetSha256: "e".repeat(64), + }); + expectInvalidInterfaceBuilderEncoding(result); + }, + ); + + cliTest.for(invalidInterfaceBuilderEncodings)( + "built CLI reports $0 as invalid archive bytes", + async (encoding, { cli }) => { + const fixture = await createInterfaceBuilderEncodingFixture(encoding); + 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, + ); + expectInvalidInterfaceBuilderEncoding(result); + }, + ); + }, +); diff --git a/tests/conformance/native/interface-builder-encoding.swift b/tests/conformance/native/interface-builder-encoding.swift new file mode 100644 index 000000000..55ea5905c --- /dev/null +++ b/tests/conformance/native/interface-builder-encoding.swift @@ -0,0 +1,137 @@ +import AppKit + +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("Café") +for name in ["Left", "Right"] { + let child = NSView(frame: .zero) + child.identifier = NSUserInterfaceItemIdentifier(name) + root.addSubview(child) +} +let archiver = NSKeyedArchiver(requiringSecureCoding: false) +archiver.outputFormat = .xml +archiver.encode(root, forKey: NSKeyedArchiveRootObjectKey) +archiver.finishEncoding() +let original = archiver.encodedData +let text = String(data: original, encoding: .utf8)! +let reference = try PropertyListSerialization.propertyList(from: original, options: [], format: nil) +let referenceXML = try PropertyListSerialization.data(fromPropertyList: reference, format: .xml, options: 0) +let utf16Text = text.replacingOccurrences(of: "UTF-8", with: "UTF-16") +let representations: [String: Data] = [ + "utf8": original, + "utf8-bom": Data([0xef, 0xbb, 0xbf]) + original, + "utf16le": Data([0xff, 0xfe]) + utf16Text.data(using: .utf16LittleEndian)!, + "utf16be": Data([0xfe, 0xff]) + utf16Text.data(using: .utf16BigEndian)!, + "binary": try PropertyListSerialization.data(fromPropertyList: reference, format: .binary, options: 0), +] +for (name, bytes) in representations { + let decoded = try PropertyListSerialization.propertyList(from: bytes, options: [], format: nil) + let decodedXML = try PropertyListSerialization.data(fromPropertyList: decoded, format: .xml, options: 0) + guard decodedXML == referenceXML else { + fatalError("Foundation rejected graph parity for \(name)") + } + try bytes.write(to: output.appendingPathComponent("\(name).nib")) +} + +// Foundation materializes opaque UID objects. Read original XML markers separately. +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 generated 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 generated dictionary 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 "true": return true + case "false": return false + default: fatalError("Unsupported generated oracle element \(element.kind)") + } +} +let xmlReader = PlistXMLReader() +let xmlParser = XMLParser(data: original) +xmlParser.shouldResolveExternalEntities = false +xmlParser.delegate = xmlReader +guard xmlParser.parse(), let plist = xmlReader.document.children.first else { + fatalError("Cannot read generated XML object table") +} +let originalXMLTable = readPlist(plist) + +// Extract actual UID/class/identifier/parent facts, without REA or unarchiving. +guard let archive = originalXMLTable as? [String: Any], + let table = archive["$objects"] as? [Any], + let top = archive["$top"] as? [String: Any] else { + fatalError("Expected a Foundation object table") +} +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 Foundation XML UID marker") + } + return number.intValue +} +let rootUID = uid(top[NSKeyedArchiveRootObjectKey]) +guard let rootFields = table[rootUID] as? [String: Any], + let collection = table[uid(rootFields["NSSubviews"])] as? [String: Any], + let childReferences = collection["NS.objects"] as? [Any] else { + fatalError("Expected serialized NSView subviews") +} +let childUIDs = childReferences.map { uid($0) } +var views: [[String: Any]] = [] +for index in [rootUID] + childUIDs { + 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 NSView class and serialized identifier") + } + if index != rootUID && uid(fields["NSSuperview"]) != rootUID { + fatalError("Foundation did not retain the expected parent link") + } + views.append(["uid": index, "identifier": identifier]) +} +guard views.compactMap({ $0["identifier"] as? String }) == ["Café", "Left", "Right"] else { + fatalError("Foundation did not retain the authored identifiers") +} +let oracle: [String: Any] = ["rootUID": rootUID, "childUIDs": childUIDs, "views": views] +try JSONSerialization.data(withJSONObject: oracle, options: [.sortedKeys]) + .write(to: output.appendingPathComponent("oracle.json")) + +// Deliberately invalid controls are separate from the native-validated representations. +try Data([0xff, 0xfe, 0x3c]).write(to: output.appendingPathComponent("incomplete-utf16.nib")) +try Data(utf16Text.utf8).write(to: output.appendingPathComponent("utf8-declared-utf16.nib")) +try (Data([0xff, 0xfe]) + text.data(using: .utf16LittleEndian)!).write(to: output.appendingPathComponent("utf16-declared-utf8.nib")) +try Data(text.replacingOccurrences(of: "UTF-8", with: "REA-UNSUPPORTED").utf8) + .write(to: output.appendingPathComponent("unsupported-declaration.nib")) +try Data(text.replacingOccurrences(of: "UTF-8", with: "ISO-8859-1").utf8) + .write(to: output.appendingPathComponent("latin1-declaration.nib")) + +guard let identifierRange = original.range(of: Data("Café".utf8)) else { + fatalError("Expected literal UTF-8 identifier in generated XML") +} +var invalidUTF8 = original +invalidUTF8[identifierRange.lowerBound] = 0xff +try invalidUTF8.write(to: output.appendingPathComponent("invalid-utf8.nib")) diff --git a/tests/fixtures/interfaceBuilderEncoding.ts b/tests/fixtures/interfaceBuilderEncoding.ts new file mode 100644 index 000000000..0bd26471c --- /dev/null +++ b/tests/fixtures/interfaceBuilderEncoding.ts @@ -0,0 +1,213 @@ +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 interfaceBuilderEncodings = [ + "utf8", + "utf8-bom", + "utf16le", + "utf16be", + "binary", +] as const; +export const invalidInterfaceBuilderEncodings = [ + "incomplete-utf16", + "utf8-declared-utf16", + "utf16-declared-utf8", + "invalid-utf8", + "unsupported-declaration", + "latin1-declaration", +] as const; +type Encoding = + | (typeof interfaceBuilderEncodings)[number] + | (typeof invalidInterfaceBuilderEncodings)[number]; +type Analysis = Awaited>; +const oracleSchema = z.strictObject({ + rootUID: z.number().int().nonnegative(), + childUIDs: z.array(z.number().int().nonnegative()).length(2), + views: z + .array( + z.strictObject({ + uid: z.number().int().nonnegative(), + identifier: z.string(), + }), + ) + .length(3), +}); +type Oracle = z.infer; + +/** Check actual NSView identities and links from the independent Foundation oracle. */ +const expectNativeViewHierarchy = (result: Analysis, oracle: Oracle) => { + for (const { uid, identifier } of oracle.views) { + expect(result.graph.nodes).toContainEqual( + expect.objectContaining({ + name: "NSView", + attributes: expect.objectContaining({ + class_name: "NSView", + interface_builder_object_id: String(uid), + NSReuseIdentifierKey: identifier, + }), + }), + ); + } + const nodeId = (uid: number) => { + const node = result.graph.nodes.find( + ({ attributes }) => + attributes.interface_builder_object_id === String(uid), + ); + if (node === undefined) + throw new Error(`Missing Foundation NSView UID ${String(uid)}`); + return node.id; + }; + const children = result.graph.edges.filter( + ({ id, from, relation }) => + from === nodeId(oracle.rootUID) && + relation === "contains" && + id.includes(":hierarchy:"), + ); + expect(children.map(({ to }) => to)).toEqual(oracle.childUIDs.map(nodeId)); + expect(children).toHaveLength(2); + expect(children.every(({ resolution }) => resolution === "observed")).toBe( + true, + ); +}; + +/** Produce and validate representations natively; archived classes are never decoded. */ +export const createInterfaceBuilderEncodingFixture = async ( + encoding: Encoding, +) => { + const directory = await createTestTempDirectory("rea-ib-encoding-"); + await promisify(execFile)("/usr/bin/xcrun", [ + "swift", + "tests/conformance/native/interface-builder-encoding.swift", + directory, + ]); + const oracle = oracleSchema.parse( + JSON.parse(await readFile(join(directory, "oracle.json"), "utf8")), + ); + expect(oracle.views.map(({ identifier }) => identifier)).toEqual([ + "Café", + "Left", + "Right", + ]); + const createBundle = async (name: string, selected: string) => { + const app = join(directory, `${name}.app`); + const contents = join(app, "Contents"); + await mkdir(join(contents, "Resources"), { recursive: true }); + await mkdir(join(contents, "MacOS")); + // Existing provider-selection fixture; archive bytes are produced by AppKit. + await writeFile( + join(contents, "MacOS/App"), + thinMach(0xfeedfacf, 0x0100000c), + ); + await writeFile( + join(contents, "Info.plist"), + "CFBundleExecutableAppCFBundleIdentifiercom.example.encoding", + ); + const bytes = await readFile(join(directory, `${selected}.nib`)); + await writeFile(join(contents, "Resources", "Panel.nib"), bytes); + return { app, bytes }; + }; + const selected = await createBundle("Selected", encoding); + const reference = await createBundle("Reference", "utf8"); + const expected = await analyzeInterfaceBuilderBundle({ + bundlePath: reference.app, + targetSha256: "e".repeat(64), + }); + expect(expected.documents).toHaveLength(1); + expectNativeViewHierarchy(expected, oracle); + return { ...selected, expected, oracle }; +}; + +const semanticShape = (result: Analysis) => ({ + documents: result.documents.map( + ({ + relative_path, + document_kind, + object_count, + connection_count, + hierarchy_complete, + }) => ({ + relative_path, + document_kind, + object_count, + connection_count, + hierarchy_complete, + }), + ), + nodes: result.graph.nodes.map(({ id, kind, name, location, attributes }) => ({ + id, + kind, + name, + location, + attributes, + })), + edges: result.graph.edges.map( + ({ id, from, to, relation, resolution, limitations }) => ({ + id, + from, + to, + relation, + resolution, + limitations, + }), + ), + coverage: result.graph.coverage, + truncated: result.graph.truncated, +}); + +/** Require complete graph parity and the original byte digest at every evidence boundary. */ +export const expectInterfaceBuilderEncoding = ( + result: Analysis, + expected: Analysis, + bytes: Buffer, + oracle: Oracle, +) => { + expectNativeViewHierarchy(result, oracle); + expect(semanticShape(result)).toEqual(semanticShape(expected)); + const digest = createHash("sha256").update(bytes).digest("hex"); + expect(result.documents).toEqual([ + expect.objectContaining({ + relative_path: "Contents/Resources/Panel.nib", + archive_sha256: digest, + hierarchy_complete: true, + }), + ]); + expect(result.graph.truncated).toBe(false); + for (const node of result.graph.nodes) { + expect(node.evidence.length).toBeGreaterThan(0); + for (const evidence of node.evidence) + expect(evidence).toMatchObject({ + artifact_path: "Contents/Resources/Panel.nib", + artifact_sha256: digest, + }); + } + for (const edge of result.graph.edges) + for (const evidence of edge.evidence) + expect(evidence).toMatchObject({ + artifact_path: "Contents/Resources/Panel.nib", + artifact_sha256: digest, + }); +}; + +export const expectInvalidInterfaceBuilderEncoding = (result: Analysis) => { + expect(result.documents).toEqual([]); + expect(result.graph.nodes).toEqual([]); + expect(result.graph.truncated).toBe(true); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "archive_decode", + status: "partial", + reason: "one_or_more_archives_invalid", + examined: 1, + }), + ); +};