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 <rudy@gradiahq.com>

* 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 <rudy@gradiahq.com>

* fix(apple): reject unsupported XML encoding declarations

---------

Signed-off-by: Rudy Celekli <rudy@gradiahq.com>
Co-authored-by: N0zoM1z0 <1705004398@qq.com>
This commit is contained in:
Rudy Mizrahi Celekli 2026-10-08 13:11:49 -04:00 committed by GitHub
parent ae0630a619
commit f8e143a3bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 558 additions and 22 deletions

View File

@ -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 <app-or-binary>` prefers a validated macOS Mach-O byte reader. It decodes
64-bit little-endian Objective-C class/metaclass records, superclass pointers,

View File

@ -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 };
};

View File

@ -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)

View File

@ -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;
};

View File

@ -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);
},
);

View File

@ -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);
},
);
},
);

View File

@ -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"))

View File

@ -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<ReturnType<typeof analyzeInterfaceBuilderBundle>>;
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<typeof oracleSchema>;
/** 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"),
"<plist><dict><key>CFBundleExecutable</key><string>App</string><key>CFBundleIdentifier</key><string>com.example.encoding</string></dict></plist>",
);
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,
}),
);
};