fix(apple): report missing keyed archive hierarchy references (#1104)
* test(apple): qualify malformed native hierarchy references Freeze actual Foundation/AppKit producer, single-slot positive UID mutation and real optional nil control at filesystem and compiled CLI boundaries. Preserve original raw-byte evidence and existing CI. Signed-off-by: Rudy Celekli <rudy@gradiahq.com> * fix(apple): report missing keyed archive hierarchy references Preserve known hierarchy objects and links while reporting unresolved nonnil object-table references as partial coverage. Retain archived nil semantics and original byte evidence. Signed-off-by: Rudy Celekli <rudy@gradiahq.com> --------- Signed-off-by: Rudy Celekli <rudy@gradiahq.com>
This commit is contained in:
parent
4bb056bbb5
commit
3b2ec69c54
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export const parseInterfaceBuilderRecords = (
|
|||
readonly objectCount: number;
|
||||
readonly omittedObjects: number;
|
||||
readonly omittedConnections: number;
|
||||
readonly omittedHierarchyReferences: number;
|
||||
} => {
|
||||
const root = record(value);
|
||||
const keyed = parseKeyedArchive(root);
|
||||
|
|
@ -114,6 +115,7 @@ export const parseInterfaceBuilderRecords = (
|
|||
objectCount: objectEntries.length,
|
||||
omittedObjects: Math.max(0, objectEntries.length - 20_000),
|
||||
omittedConnections,
|
||||
omittedHierarchyReferences: 0,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -194,7 +196,8 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => {
|
|||
}
|
||||
return output;
|
||||
};
|
||||
const hierarchy = (start: unknown): unknown[] => {
|
||||
const hierarchy = (start: unknown) => {
|
||||
let omittedReferences = 0;
|
||||
const visited = new Set<number>();
|
||||
const root: unknown[] = [];
|
||||
const pending: { readonly value: unknown; readonly output: unknown[] }[] = [
|
||||
|
|
@ -227,9 +230,15 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => {
|
|||
const { value, output } = entry;
|
||||
const uid = archiveUid(value);
|
||||
if (uid !== undefined) {
|
||||
// UID0 is archived nil, not a missing hierarchy object.
|
||||
if (uid === 0) continue;
|
||||
const target = objectTable[uid];
|
||||
if (target === undefined) {
|
||||
omittedReferences += 1;
|
||||
continue;
|
||||
}
|
||||
if (visited.has(uid)) continue;
|
||||
visited.add(uid);
|
||||
const target = objectTable[uid];
|
||||
if (Array.isArray(target)) {
|
||||
for (let index = target.length - 1; index >= 0; index -= 1) {
|
||||
const child = target[index];
|
||||
|
|
@ -260,7 +269,7 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => {
|
|||
appendNode(record(value), undefined, output);
|
||||
}
|
||||
}
|
||||
return root;
|
||||
return { items: root, omittedReferences };
|
||||
};
|
||||
const archivedArrayClass = (classReference: unknown): boolean => {
|
||||
const uid = archiveUid(classReference);
|
||||
|
|
@ -386,11 +395,12 @@ const parseKeyedArchive = (
|
|||
return {
|
||||
objects,
|
||||
connections,
|
||||
hierarchy,
|
||||
hierarchy: hierarchy.items,
|
||||
classes,
|
||||
objectCount,
|
||||
omittedObjects: Math.max(0, objectCount - 20_000),
|
||||
omittedConnections,
|
||||
omittedHierarchyReferences: hierarchy.omittedReferences,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { analyzeInterfaceBuilderBundle } from "../../src/artifacts/apple/InterfaceBuilderAnalysis.js";
|
||||
import { thinMach } from "../../src/domain/binaryTarget.fixture.js";
|
||||
import { createTestTempDirectory } from "./temporaryDirectory.js";
|
||||
|
||||
export const interfaceBuilderUidCases = [
|
||||
"original",
|
||||
"optional-nil",
|
||||
"dangling-child",
|
||||
] as const;
|
||||
type UidCase = (typeof interfaceBuilderUidCases)[number];
|
||||
type Analysis = Awaited<ReturnType<typeof analyzeInterfaceBuilderBundle>>;
|
||||
const oracleSchema = z.strictObject({
|
||||
rootUID: z.number().int().positive(),
|
||||
childUIDs: z.array(z.number().int().positive()).length(2),
|
||||
collectionUID: z.number().int().positive(),
|
||||
danglingUID: z.number().int().positive(),
|
||||
tableLength: z.number().int().positive(),
|
||||
views: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
uid: z.number().int().positive(),
|
||||
identifier: z.string(),
|
||||
}),
|
||||
)
|
||||
.length(3),
|
||||
nilUID: z.literal(0),
|
||||
nilSentinel: z.literal("$null"),
|
||||
digests: z.strictObject({
|
||||
original: z.string(),
|
||||
"optional-nil": z.string(),
|
||||
"dangling-child": z.string(),
|
||||
}),
|
||||
mutationPath: z.array(z.string()).length(5),
|
||||
});
|
||||
type Oracle = z.infer<typeof oracleSchema>;
|
||||
const archivePath = "Contents/Resources/Views.nib";
|
||||
|
||||
const viewId = (result: Analysis, uid: number) => {
|
||||
const view = result.graph.nodes.find(
|
||||
({ attributes }) => attributes.interface_builder_object_id === String(uid),
|
||||
);
|
||||
if (view === undefined)
|
||||
throw new Error(`Missing native NSView UID ${String(uid)}`);
|
||||
return view.id;
|
||||
};
|
||||
const expectView = (result: Analysis, oracle: Oracle, uid: number) => {
|
||||
const expected = oracle.views.find((view) => view.uid === uid);
|
||||
expect(expected).toBeDefined();
|
||||
expect(result.graph.nodes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "view",
|
||||
name: "NSView",
|
||||
attributes: expect.objectContaining({
|
||||
class_name: "NSView",
|
||||
interface_builder_object_id: String(uid),
|
||||
NSReuseIdentifierKey: expected?.identifier,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
};
|
||||
const childEdges = (result: Analysis, oracle: Oracle) =>
|
||||
result.graph.edges.filter(
|
||||
({ id, from, relation }) =>
|
||||
from === viewId(result, oracle.rootUID) &&
|
||||
relation === "contains" &&
|
||||
id.includes(":hierarchy:"),
|
||||
);
|
||||
|
||||
/** Native producer and independent plist oracle precede every consumer assertion. */
|
||||
export const createInterfaceBuilderUidFixture = async (
|
||||
selectedCase: UidCase,
|
||||
) => {
|
||||
const directory = await createTestTempDirectory("rea-ib-uid-");
|
||||
await promisify(execFile)("/usr/bin/xcrun", [
|
||||
"swift",
|
||||
"tests/conformance/native/interface-builder-malformed-uid.swift",
|
||||
directory,
|
||||
]);
|
||||
const oracle = oracleSchema.parse(
|
||||
JSON.parse(await readFile(join(directory, "oracle.json"), "utf8")),
|
||||
);
|
||||
expect(oracle.views.map(({ identifier }) => identifier)).toEqual([
|
||||
"Root",
|
||||
"Kept",
|
||||
"Changed",
|
||||
]);
|
||||
expect(oracle.danglingUID).toBeGreaterThan(oracle.tableLength);
|
||||
expect(oracle.mutationPath).toEqual([
|
||||
"$objects",
|
||||
String(oracle.collectionUID),
|
||||
"NS.objects",
|
||||
"1",
|
||||
"CF$UID",
|
||||
]);
|
||||
const makeApp = async (name: string, variant: UidCase) => {
|
||||
const app = join(directory, `${name}.app`);
|
||||
const contents = join(app, "Contents");
|
||||
await mkdir(join(contents, "Resources"), { recursive: true });
|
||||
await mkdir(join(contents, "MacOS"));
|
||||
// This known Mach-O selects the artifact provider; target execution is not tested.
|
||||
await writeFile(
|
||||
join(contents, "MacOS/App"),
|
||||
thinMach(0xfeedfacf, 0x0100000c),
|
||||
);
|
||||
await writeFile(
|
||||
join(contents, "Info.plist"),
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>App</string><key>CFBundleIdentifier</key><string>com.example.uid</string></dict></plist>",
|
||||
);
|
||||
const bytes = await readFile(join(directory, `${variant}.nib`));
|
||||
expect(createHash("sha256").update(bytes).digest("hex")).toBe(
|
||||
oracle.digests[variant],
|
||||
);
|
||||
await writeFile(join(contents, "Resources", "Views.nib"), bytes);
|
||||
return { app, bytes };
|
||||
};
|
||||
const original = await makeApp("Reference", "original");
|
||||
const reference = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: original.app,
|
||||
targetSha256: "f".repeat(64),
|
||||
});
|
||||
expectInterfaceBuilderUidCase(reference, "original", original.bytes, oracle);
|
||||
return { ...(await makeApp("Selected", selectedCase)), oracle };
|
||||
};
|
||||
|
||||
/** Preserve surviving observed links and original byte provenance, and report lost links truthfully. */
|
||||
export const expectInterfaceBuilderUidCase = (
|
||||
result: Analysis,
|
||||
selectedCase: UidCase,
|
||||
bytes: Buffer,
|
||||
oracle: Oracle,
|
||||
) => {
|
||||
const digest = createHash("sha256").update(bytes).digest("hex");
|
||||
expect(result.documents).toEqual([
|
||||
expect.objectContaining({
|
||||
relative_path: archivePath,
|
||||
archive_sha256: digest,
|
||||
}),
|
||||
]);
|
||||
for (const node of result.graph.nodes) {
|
||||
expect(node.evidence.length).toBeGreaterThan(0);
|
||||
for (const evidence of node.evidence)
|
||||
expect(evidence).toMatchObject({
|
||||
artifact_path: archivePath,
|
||||
artifact_sha256: digest,
|
||||
});
|
||||
}
|
||||
for (const edge of result.graph.edges)
|
||||
for (const evidence of edge.evidence)
|
||||
expect(evidence).toMatchObject({
|
||||
artifact_path: archivePath,
|
||||
artifact_sha256: digest,
|
||||
});
|
||||
expectView(result, oracle, oracle.rootUID);
|
||||
const keptUID = oracle.childUIDs[0];
|
||||
if (keptUID === undefined) throw new Error("Native oracle lacks kept child");
|
||||
expectView(result, oracle, keptUID);
|
||||
expect(childEdges(result, oracle)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
from: viewId(result, oracle.rootUID),
|
||||
to: viewId(result, keptUID),
|
||||
relation: "contains",
|
||||
resolution: "observed",
|
||||
}),
|
||||
);
|
||||
const coverage = result.graph.coverage.find(
|
||||
({ facet }) => facet === `hierarchy:${archivePath}`,
|
||||
);
|
||||
expect(coverage).toBeDefined();
|
||||
if (selectedCase !== "dangling-child") {
|
||||
for (const uid of oracle.childUIDs) expectView(result, oracle, uid);
|
||||
expect(childEdges(result, oracle).map(({ to }) => to)).toEqual(
|
||||
oracle.childUIDs.map((uid) => viewId(result, uid)),
|
||||
);
|
||||
expect(result.documents[0]?.hierarchy_complete).toBe(true);
|
||||
expect(coverage).toMatchObject({
|
||||
status: "complete",
|
||||
reason: null,
|
||||
omitted: 0,
|
||||
});
|
||||
expect(result.graph.truncated).toBe(false);
|
||||
return;
|
||||
}
|
||||
// A valid root and sibling do not make a silently discarded positive UID complete.
|
||||
expect(result.documents[0]?.hierarchy_complete).toBe(false);
|
||||
expect(coverage?.status).toBe("partial");
|
||||
expect(coverage?.reason).toEqual(expect.any(String));
|
||||
expect(coverage?.reason?.length).toBeGreaterThan(0);
|
||||
const explicitUnknown = childEdges(result, oracle).some(
|
||||
(edge) => edge.resolution === "unresolved" && edge.reason.length > 0,
|
||||
);
|
||||
expect((coverage?.omitted ?? 0) > 0 || explicitUnknown).toBe(true);
|
||||
};
|
||||
Loading…
Reference in New Issue