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:
parent
ae0630a619
commit
f8e143a3bc
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue