From 79323128f23b74536c026c0fbe4f5a00ff245772 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:09:40 +0800 Subject: [PATCH] fix(apple): qualify unexamined nib content coverage --- docs/native-investigation.md | 4 ++ .../apple/InterfaceBuilderAnalysis.ts | 68 ++++++++++++++++++- .../filesystem/interfaceBuilderBundle.test.ts | 48 +++++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index b582ddf4fa..cb54efe466 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -454,6 +454,10 @@ complete observation. undecodable filenames and skipped symlinks, as partial coverage. Known executable roots remain available; unexamined names and symlink targets cannot establish an empty root set or the absence of other roots. + Embedded `UINib` archive data remains retained and undecoded; affected + object, connection, class and hierarchy coverage is partial. Recognized + `UIStoryboardShowSegueTemplate` source records similarly qualify connection + coverage until their navigation semantics are decoded. - `inspect_asset_catalog` / `rea inspect-asset-catalog ` reads compiled `Assets.car` metadata through macOS `assetutil --info`. Catalog digests, raw rendition fields, pagination and exact UI resource-name matches are returned. diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index bcd5d0a00f..64a561d611 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -88,6 +88,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { const omittedArchives: OmittedArchive[] = []; let unreportedOmittedArchives = 0; const incompleteHierarchies = new Map(); + const unexaminedSources = new Map(); const unresolvedConnections = new Map(); const prototypeKeyOmissions = new Map(); const unknownDates = new Map(); @@ -217,6 +218,8 @@ export const analyzeInterfaceBuilderBundle = async (input: { numberLiterals: undefined, }; requireInterfaceBuilderArchive(raw); + if (nib !== null && nib.unexaminedSources.length > 0) + unexaminedSources.set(entry.path, nib.unexaminedSources); if (nib !== null && nib.omitted > 0) incompleteHierarchies.set(entry.path, nib.omitted); if (nib !== null && nib.unresolvedConnections > 0) @@ -283,6 +286,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { limits, }); return finalizeHierarchyCoverage(result, { + unexaminedSources, incompleteHierarchies, unresolvedConnections, prototypeKeyOmissions, @@ -384,6 +388,7 @@ const retainCandidate = ( const finalizeHierarchyCoverage = ( result: ReturnType, counts: { + unexaminedSources: ReadonlyMap; incompleteHierarchies: ReadonlyMap; unresolvedConnections: ReadonlyMap; prototypeKeyOmissions: ReadonlyMap; @@ -401,6 +406,7 @@ const finalizeHierarchyCoverage = ( }, ) => { const { + unexaminedSources, incompleteHierarchies, unresolvedConnections, prototypeKeyOmissions, @@ -415,6 +421,7 @@ const finalizeHierarchyCoverage = ( } = counts; const archiveDecodePartial = invalid.length > 0 || + unexaminedSources.size > 0 || prototypeKeyOmissions.size > 0 || counts.omitted > 0 || counts.traversal.status === "unknown"; @@ -422,6 +429,11 @@ const finalizeHierarchyCoverage = ( ...result, documents: result.documents.map((document) => ({ ...document, + hierarchy_complete: + document.hierarchy_complete && + !(unexaminedSources.get(document.relative_path) ?? []).some((source) => + source.facets.includes("hierarchy"), + ), connection_count: document.connection_count + (unresolvedConnections.get(document.relative_path) ?? 0), @@ -433,6 +445,18 @@ const finalizeHierarchyCoverage = ( const omittedConnection = unresolvedConnections.get( facet.facet.replace(/^connections:/u, ""), ); + const [kind, ...pathParts] = facet.facet.split(":"); + const source = ( + unexaminedSources.get(pathParts.join(":")) ?? [] + ).find((source) => + source.facets.some((affected) => affected === kind), + ); + if (source !== undefined) + return { + ...facet, + status: "partial" as const, + reason: facet.reason ?? source.reason, + }; return facet.facet.startsWith("connections:") && omittedConnection !== undefined ? { @@ -467,9 +491,11 @@ const finalizeHierarchyCoverage = ( ? "one_or_more_archives_invalid" : prototypeKeyOmissions.size > 0 ? "dictionary_entries_omitted" - : counts.traversal.status === "unknown" - ? "source_traversal_unknown" - : null, + : unexaminedSources.size > 0 + ? "serialized_archive_content_unexamined" + : counts.traversal.status === "unknown" + ? "source_traversal_unknown" + : null, examined: counts.attempted, omitted: counts.omitted, }, @@ -483,6 +509,9 @@ const finalizeHierarchyCoverage = ( limitations: [ ...result.limitations, ...counts.traversal.limitations, + ...[...unexaminedSources].flatMap(([path, sources]) => + sources.map((source) => `${path}: ${source.description}`), + ), "Compiled Interface Builder archives are private serialized object graphs. The decoder reports only recognized keyed-archive objects and connections; unrecognized object classes and fields remain unknown.", ...(incompleteHierarchies.size === 0 ? [] @@ -530,12 +559,44 @@ const finalizeHierarchyCoverage = ( }; }; +interface NibUnexaminedSource { + readonly reason: string; + readonly facets: readonly ( + | "objects" + | "connections" + | "hierarchy" + | "classes" + )[]; + readonly description: string; +} + /** Project decoded NIB records into the same bounded graph input as ibtool. */ const projectNibArchive = ( archive: NibArchiveDocument, budget: InterfaceBuilderDecodeBudget, maxHierarchyNodes: number, ) => { + const unexaminedSources: NibUnexaminedSource[] = []; + for (const object of archive.objects) { + const name = object.class_name.replace(/\0+$/u, ""); + if ( + name === "UINib" && + ["UINib.archiveData", "archiveData"].some((key) => + Object.hasOwn(object.values, key), + ) + ) + unexaminedSources.push({ + reason: "embedded_nib_archive_not_decoded", + facets: ["objects", "connections", "hierarchy", "classes"], + description: `UINib object ${object.id} retains embedded archive data whose objects, connections, classes and hierarchy are not decoded.`, + }); + if (name === "UIStoryboardShowSegueTemplate") + unexaminedSources.push({ + reason: "storyboard_segue_template_not_decoded", + facets: ["connections"], + description: `Storyboard segue template object ${object.id} is retained as source data; its connection is not decoded.`, + }); + } const byId = new Map(archive.objects.map((object) => [object.id, object])); const normalizedClassNames = new Map(); const normalizeClassName = (value: string): string => { @@ -836,6 +897,7 @@ const projectNibArchive = ( ); return { omitted: viewHierarchy.omitted + hierarchyOmitted + merged.omitted, + unexaminedSources, unresolvedConnections, nonFiniteRealCount, negativeZeroRealCount, diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 219d22a7dd..89d0597fce 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -2184,3 +2184,51 @@ it("preserves authored binary UID-shaped dictionaries through UI archive decodin actual: { archiveUID: 2, className: "NSView" }, }); }); + +it.each(["embedded", "segue", "neither"] as const)( + "reports recognized unexamined nib source coverage: %s", + async (source) => { + const bundle = await createTestTempDirectory("rea-ui-unexamined-sources-"); + await writeFile( + join(bundle, "Main.nib"), + encodeNibArchiveFixture({ + classes: ["UINib", "UIStoryboardShowSegueTemplate", "NSView"], + objects: [ + { classIndex: 2, values: {} }, + ...(source === "embedded" + ? [{ classIndex: 0, values: { "UINib.archiveData": { ref: 0 } } }] + : []), + ...(source === "segue" ? [{ classIndex: 1, values: {} }] : []), + ], + }), + ); + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + }); + for (const kind of ["objects", "classes"]) + expect( + result.graph.coverage.find(({ facet }) => facet === `${kind}:Main.nib`) + ?.status, + ).toBe(source === "embedded" ? "partial" : "complete"); + expect( + result.graph.coverage.find( + ({ facet }) => facet === "connections:Main.nib", + )?.status, + ).toBe(source === "neither" ? "complete" : "partial"); + expect( + result.graph.coverage.find(({ facet }) => facet === "archive_decode") + ?.status, + ).toBe(source === "neither" ? "complete" : "partial"); + if (source !== "neither") { + expect(result.graph.truncated).toBe(true); + expect(result.limitations).toContainEqual( + expect.stringContaining( + source === "embedded" + ? "embedded archive data" + : "segue template object", + ), + ); + } + }, +);