diff --git a/src/artifacts/apple/NibArchive.ts b/src/artifacts/apple/NibArchive.ts index bd8ef12caf..c673184f46 100644 --- a/src/artifacts/apple/NibArchive.ts +++ b/src/artifacts/apple/NibArchive.ts @@ -134,7 +134,14 @@ export const decodeNibArchive = ( const value = values.get(index); if (value === undefined) continue; const key = keys[value.key_index]; - if (key !== undefined) fields[key] = value.value; + if (key !== undefined) + // Archive keys are data, including the legacy __proto__ setter name. + Object.defineProperty(fields, key, { + value: value.value, + enumerable: true, + configurable: true, + writable: true, + }); } return [{ id, class_name: className, values: fields }]; }); @@ -519,7 +526,8 @@ const decodeUtf8 = ( label: string, ): string => { try { - return new TextDecoder("utf-8", { fatal: true }).decode( + // These are length-prefixed names, not standalone text documents. + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( bytes.subarray(start, end), ); } catch (cause: unknown) { diff --git a/tests/acceptance/applications/nibFieldIdentity.test.ts b/tests/acceptance/applications/nibFieldIdentity.test.ts new file mode 100644 index 0000000000..5af16f890f --- /dev/null +++ b/tests/acceptance/applications/nibFieldIdentity.test.ts @@ -0,0 +1,110 @@ +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { expect } from "vitest"; + +import { encodeNibArchiveFixture } from "../../../src/artifacts/apple/NibArchive.fixture.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js"; +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "preserves literal NIB fields and UTF-8 names through CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-nib-field-identity-"); + const app = join(root, "Fixture.app"); + const contents = join(app, "Contents"); + await mkdir(join(contents, "Resources"), { recursive: true }); + await mkdir(join(contents, "MacOS")); + await writeFile( + join(contents, "MacOS/App"), + thinMach(0xfeedfacf, 0x0100000c), + ); + await writeFile( + join(contents, "Info.plist"), + "CFBundleExecutableAppCFBundleIdentifierdev.rea.nib-fields", + ); + const archive = encodeNibArchiveFixture({ + classes: ["UIView", "\uFEFFCustomView"], + objects: [ + { + classIndex: 0, + values: { + ["__proto__"]: { ref: 1 }, + constructor: 7, + enabled: true, + "\uFEFFenabled": false, + }, + }, + { classIndex: 1, values: { ["__proto__"]: 23 } }, + ], + }); + const path = join(contents, "Resources/Panel.nib"); + await writeFile(path, archive); + const result = await cli.run({ + arguments: ["decode-interface-builder", app, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(result.exitCode, result.stdout + result.stderr).toBe(0); + const contract = toolContract("decode_interface_builder"); + const evidence = contract.outputSchema.parse(result.json); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [resolve("scripts/rea.mjs"), "mcp"], + env: { + PATH: process.env.PATH ?? "", + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "auto", + }, + stderr: "pipe", + }); + const client = new Client({ name: "nib-field-identity", version: "1" }); + try { + await client.connect(transport); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: app }, + }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const response = await client.callTool({ + name: contract.name, + arguments: {}, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const mcp = contract.outputSchema.parse(response.structuredContent); + expect(mcp.normalized_result).toEqual(evidence.normalized_result); + } finally { + try { + await client.close(); + } finally { + await transport.close(); + } + } + const report = interfaceBuilderAnalysisSchema.parse( + evidence.normalized_result, + ); + expect(report.documents[0]?.archive_sha256).toBe( + createHash("sha256").update(archive).digest("hex"), + ); + const objects = report.graph.nodes.filter( + ({ attributes }) => attributes.interface_builder_object_id !== undefined, + ); + expect(objects).toHaveLength(2); + expect(objects[0]?.attributes.nibValues).toEqual({ + ["__proto__"]: { objectID: "1" }, + constructor: 7, + enabled: true, + "\uFEFFenabled": false, + }); + expect(objects[1]?.attributes).toMatchObject({ + class_name: "\uFEFFCustomView", + nibValues: { ["__proto__"]: 23 }, + }); + expect(await readFile(path)).toEqual(archive); + }, + 60_000, +);