diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 670c641d30..69fdaf3b34 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -12,6 +12,8 @@ import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; import { projectPlistValue, binaryDatePrecisionOverlapLimitation, + negativeZeroRealLimitation, + nonFiniteRealLimitation, unknownDateLimitation, unknownScalarLimitation, } from "../../domain/apple/plistValue.js"; @@ -75,6 +77,8 @@ export const analyzeInterfaceBuilderBundle = async (input: { const unknownDates = new Map(); const datePrecisionOverlaps = new Map(); const unknownScalars = new Map(); + const nonFiniteReals = new Map(); + const negativeZeroReals = new Map(); let omitted = 0; let attempted = 0; let aggregateInputBytes = 0; @@ -186,6 +190,10 @@ export const analyzeInterfaceBuilderBundle = async (input: { incompleteHierarchies.set(entry.path, nib.omitted); if (nib !== null && nib.unresolvedConnections > 0) unresolvedConnections.set(entry.path, nib.unresolvedConnections); + if (nib !== null && nib.nonFiniteRealCount > 0) + nonFiniteReals.set(entry.path, nib.nonFiniteRealCount); + if (nib !== null && nib.negativeZeroRealCount > 0) + negativeZeroReals.set(entry.path, nib.negativeZeroRealCount); if (omittedPrototypeKeys !== 0) prototypeKeyOmissions.set(entry.path, omittedPrototypeKeys); if (unknownDateCount !== 0) @@ -241,6 +249,8 @@ export const analyzeInterfaceBuilderBundle = async (input: { unknownDates, datePrecisionOverlaps, unknownScalars, + nonFiniteReals, + negativeZeroReals, invalid, omittedArchives, unreportedOmittedArchives, @@ -312,6 +322,8 @@ const finalizeHierarchyCoverage = ( unknownDates: ReadonlyMap; datePrecisionOverlaps: ReadonlyMap; unknownScalars: ReadonlyMap; + nonFiniteReals: ReadonlyMap; + negativeZeroReals: ReadonlyMap; invalid: readonly string[]; omittedArchives: readonly { path: string; reason: string }[]; unreportedOmittedArchives: number; @@ -326,6 +338,8 @@ const finalizeHierarchyCoverage = ( unknownDates, datePrecisionOverlaps, unknownScalars, + nonFiniteReals, + negativeZeroReals, invalid, omittedArchives, unreportedOmittedArchives, @@ -420,6 +434,12 @@ const finalizeHierarchyCoverage = ( ...[...unknownScalars].map( ([path, count]) => `${path}: ${unknownScalarLimitation(count)}`, ), + ...[...nonFiniteReals].map( + ([path, count]) => `${path}: ${nonFiniteRealLimitation(count)}`, + ), + ...[...negativeZeroReals].map( + ([path, count]) => `${path}: ${negativeZeroRealLimitation(count)}`, + ), ...omittedArchives.map( ({ path, reason }) => `${path}: omitted because ${reason.replaceAll("_", " ")}.`, @@ -484,6 +504,21 @@ const projectNibArchive = ( archive.classes.length * 96, "NIBArchive class projection exceeds the aggregate Interface Builder decode budget", ); + let nonFiniteRealCount = 0; + let negativeZeroRealCount = 0; + const projectedValues = new Map< + number, + Readonly> + >(); + for (const object of archive.objects) { + const projected = projectPlistValue(object.values); + nonFiniteRealCount += projected.nonFiniteRealCount; + negativeZeroRealCount += projected.negativeZeroRealCount; + projectedValues.set( + object.id, + projected.value as Readonly>, + ); + } const dereference = (value: JsonValue | undefined): JsonValue | undefined => { if (typeof value !== "object" || value === null || Array.isArray(value)) return value; @@ -566,10 +601,12 @@ const projectNibArchive = ( } budget.reserveJsonStringOccurrence(name); const nibValues = Object.fromEntries( - Object.entries(object.values).map(([key, value]) => { - budget.reserveJsonPropertyNameOccurrence(key); - return [key, dereference(value) ?? null]; - }), + Object.entries(projectedValues.get(object.id) ?? {}).map( + ([key, value]) => { + budget.reserveJsonPropertyNameOccurrence(key); + return [key, dereference(value) ?? null]; + }, + ), ); // Normalize only recognized scalar fields at the format owner. A keyed // archive's authored nibValues dictionary must not gain this meaning. @@ -715,6 +752,8 @@ const projectNibArchive = ( return { omitted: viewHierarchy.omitted + hierarchyOmitted + merged.omitted, unresolvedConnections, + nonFiniteRealCount, + negativeZeroRealCount, raw: jsonValueSchema.parse({ "com.apple.ibtool.document.objects": objects, "com.apple.ibtool.document.connections": connections, diff --git a/src/artifacts/apple/NibArchive.fixture.ts b/src/artifacts/apple/NibArchive.fixture.ts index 8b142b9456..5eae14b783 100644 --- a/src/artifacts/apple/NibArchive.fixture.ts +++ b/src/artifacts/apple/NibArchive.fixture.ts @@ -4,7 +4,16 @@ export const encodeNibArchiveFixture = (input: { readonly objects: readonly { readonly classIndex: number; readonly values: Readonly< - Record + Record< + string, + | number + | string + | boolean + | null + | { ref: number } + | { float32: number } + | { float64: number } + > >; }[]; }): Buffer => { @@ -73,7 +82,14 @@ const varint = (value: number): Buffer => { }; const encodeValue = ( - value: number | string | boolean | null | { ref: number }, + value: + | number + | string + | boolean + | null + | { ref: number } + | { float32: number } + | { float64: number }, ): Buffer => { // Tags as written by ibtool and read by AppKit: 4 is NO, 5 is YES. if (typeof value === "boolean") return Buffer.from([value ? 5 : 4]); @@ -85,6 +101,18 @@ const encodeValue = ( return bytes; } if (typeof value === "object") { + if ("float32" in value) { + const bytes = Buffer.alloc(5); + bytes[0] = 6; + bytes.writeFloatLE(value.float32, 1); + return bytes; + } + if ("float64" in value) { + const bytes = Buffer.alloc(9); + bytes[0] = 7; + bytes.writeDoubleLE(value.float64, 1); + return bytes; + } const bytes = Buffer.alloc(5); bytes[0] = 10; bytes.writeUInt32LE(value.ref, 1); diff --git a/src/artifacts/apple/NibArchive.test.ts b/src/artifacts/apple/NibArchive.test.ts index 4c9ddb2a79..a061acf247 100644 --- a/src/artifacts/apple/NibArchive.test.ts +++ b/src/artifacts/apple/NibArchive.test.ts @@ -36,6 +36,33 @@ const archiveWithOverlappingObjectRanges = (objectCount: number): Buffer => { }; describe("NIBArchive decoder", () => { + it("preserves non-finite and negative-zero real values for typed projection", () => { + const values = decodeNibArchive( + encodeArchive({ + classes: ["NSObject"], + objects: [ + { + classIndex: 0, + values: { + nan: { float32: Number.NaN }, + positive: { float32: Number.POSITIVE_INFINITY }, + negative: { float64: Number.NEGATIVE_INFINITY }, + negativeZero32: { float32: -0 }, + negativeZero64: { float64: -0 }, + nil: null, + }, + }, + ], + }), + ).objects[0]?.values; + expect(Number.isNaN(values?.nan)).toBe(true); + expect(values?.positive).toBe(Number.POSITIVE_INFINITY); + expect(values?.negative).toBe(Number.NEGATIVE_INFINITY); + expect(Object.is(values?.negativeZero32, -0)).toBe(true); + expect(Object.is(values?.negativeZero64, -0)).toBe(true); + expect(values?.nil).toBeNull(); + }); + it("keeps leading U+FEFF in length-prefixed keys and class names", () => { const archive = encodeArchive({ classes: ["\uFEFFWidget"], diff --git a/src/artifacts/apple/NibArchive.ts b/src/artifacts/apple/NibArchive.ts index 24220f6c9a..e975029909 100644 --- a/src/artifacts/apple/NibArchive.ts +++ b/src/artifacts/apple/NibArchive.ts @@ -401,14 +401,14 @@ const readValue = ( { const value = bytes.readFloatLE(cursor.offset); cursor.offset += 4; - return Number.isFinite(value) ? value : null; + return value; } case NibValueType.float64: ensureRange(cursor.offset, 8, end, `value ${index}`); { const value = bytes.readDoubleLE(cursor.offset); cursor.offset += 8; - return Number.isFinite(value) ? value : null; + return value; } case NibValueType.data: { const length = readVarint(bytes, cursor, end); diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 5f55094ad8..efe59feff9 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -327,6 +327,52 @@ describe("compiled Interface Builder bundle reader", () => { }); describe("keyed archive hierarchy coverage", () => { + it("projects NIBArchive special reals as typed values with per-document counts", async () => { + const root = await createTestTempDirectory("rea-nib-real-projection-"); + const bundle = join(root, "Example.app"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + await writeFile( + join(resources, "Numbers.nib"), + encodeNibArchiveFixture({ + classes: ["UIView"], + objects: [ + { + classIndex: 0, + values: { + nan: { float32: Number.NaN }, + positive: { float32: Number.POSITIVE_INFINITY }, + negative: { float64: Number.NEGATIVE_INFINITY }, + negativeZero32: { float32: -0 }, + negativeZero64: { float64: -0 }, + nil: null, + }, + }, + ], + }), + ); + + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "f".repeat(64), + }); + const values = result.graph.nodes[0]?.attributes.nibValues; + expect(values).toMatchObject({ + nan: { $plist_type: "real", value: "nan" }, + positive: { $plist_type: "real", value: "+infinity" }, + negative: { $plist_type: "real", value: "-infinity" }, + negativeZero32: { $plist_type: "real", value: "-0" }, + negativeZero64: { $plist_type: "real", value: "-0" }, + nil: null, + }); + expect(result.limitations).toContain( + 'Numbers.nib: 3 non-finite real value(s) are reported as { "$plist_type": "real", "value": "" } using canonical numeric labels nan, +infinity or -infinity because a JSON number cannot carry them.', + ); + expect(result.limitations).toContain( + 'Numbers.nib: 2 negative-zero real value(s) are reported as { "$plist_type": "real", "value": "-0" } because a JSON number cannot carry the sign of zero.', + ); + }); + it("projects a deep keyed-archive hierarchy through NSArray wrappers", async () => { const root = await createTestTempDirectory("rea-ib-test-"); const bundle = join(root, "Example.app");