From f1924cf9c1910d5d29d11e7bf634b51af8d738cf Mon Sep 17 00:00:00 2001 From: Aliciawque <166712943+Aliciawque@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:06:29 +0800 Subject: [PATCH] fix(plist): decode formatted XML dictionary keys --- docs/native-investigation.md | 4 ++ src/domain/propertyListKeys.test.ts | 57 ++++++++++++++++ src/domain/propertyListKeys.ts | 65 ++++++++++++++----- .../cli/keyedArchivePrototypeKeyCli.test.ts | 24 +++++++ .../mcp/keyedArchivePrototypeKeyMcp.test.ts | 49 ++++++++++++++ tests/fixtures/keyedArchivePrototypeKeys.ts | 58 +++++++++++++++++ 6 files changed, 241 insertions(+), 16 deletions(-) create mode 100644 tests/boundary/cli/keyedArchivePrototypeKeyCli.test.ts create mode 100644 tests/boundary/mcp/keyedArchivePrototypeKeyMcp.test.ts create mode 100644 tests/fixtures/keyedArchivePrototypeKeys.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 1d2b6c01a..4df241546 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -53,6 +53,10 @@ complete observation. classes are preserved. Select `root`, `offset` and `limit` explicitly. UID 0 cannot distinguish conditional nil from ordinary nil. Limits are 64 MiB of input, 200,000 objects/references and 128 nesting levels. + XML dictionary entries decoded as `__proto__` are omitted with an explicit + count because REA results cannot represent that key. XML tag whitespace and + comments do not change that handling. Other values and the original archive + digest are preserved; key interpretation follows the pinned plist decoder. - `decode_interface_builder` / `rea decode-interface-builder ` reads compiled storyboard and nib resources into objects, outlets, actions, connections and controller/class names. `verify:interface-builder` compiles diff --git a/src/domain/propertyListKeys.test.ts b/src/domain/propertyListKeys.test.ts index 15eb1bd6d..c6c157a01 100644 --- a/src/domain/propertyListKeys.test.ts +++ b/src/domain/propertyListKeys.test.ts @@ -9,6 +9,10 @@ it.each([ "__proto__", "", "__proto__", + "__proto__", + "__proto__", + "__proto__", + "", ])("omits and counts a dictionary keyed %s", (key) => { const parsed = parseXmlPropertyList( plist( @@ -64,3 +68,56 @@ it("omits own __proto__ entries that JSON decoding creates", () => { omittedPrototypeKeys: 2, }); }); + +it("preserves the pinned decoder's first-child key semantics", () => { + const text = plist( + "__prokept__proto__omitted", + ); + expect(parseXmlPropertyList(text)).toEqual({ + value: { __pro: "kept" }, + omittedPrototypeKeys: 1, + }); +}); + +it("preserves OpenStep dictionary and array autodetection", () => { + expect( + parseXmlPropertyList( + " { CFBundleExecutable = App; values = (one, two); __rea_prototype_key__ = kept; }", + ), + ).toEqual({ + value: { + CFBundleExecutable: "App", + values: ["one", "two"], + __rea_prototype_key__: "kept", + }, + omittedPrototypeKeys: 0, + }); + expect(parseXmlPropertyList("(one, two)")).toEqual({ + value: ["one", "two"], + omittedPrototypeKeys: 0, + }); +}); + +it.each(["\n", "\r\n", "\r", "\u0085", "\u2028", "\u2029"])( + "preserves unrelated source values around %j line endings", + (newline) => { + const text = plist( + `${newline}kept keya b…c
d${newline}__proto__omitted`, + ); + expect(parseXmlPropertyList(text)).toEqual({ + value: { "kept\rkey": "a\rb\u0085c\u2028d" }, + omittedPrototypeKeys: 1, + }); + }, +); + +it("locates sparse omitted keys across newline-dense source", () => { + const lines = "\n".repeat(64 * 1024); + const text = plist( + `${lines}__proto__omittednested${lines}__proto__omittedkept`, + ); + expect(parseXmlPropertyList(text)).toEqual({ + value: { nested: { kept: true } }, + omittedPrototypeKeys: 2, + }); +}); diff --git a/src/domain/propertyListKeys.ts b/src/domain/propertyListKeys.ts index 2c537f38d..778af9295 100644 --- a/src/domain/propertyListKeys.ts +++ b/src/domain/propertyListKeys.ts @@ -1,3 +1,4 @@ +import { DOMParser, normalizeLineEndings } from "@xmldom/xmldom"; import { parse } from "plist"; /** The dictionary key REA results cannot carry: validation strips it. */ @@ -46,10 +47,6 @@ export const omitPrototypeKeys = ( value: unknown, ): PropertyListWithoutPrototypeKeys => withoutKey(value, PROTOTYPE_KEY); -// Comments and CDATA text are consumed whole so only element keys match. -const KEY_TOKEN = - /||([^<]*)<\/key>|<\/key>/gu; - const XML_ENTITIES: Readonly> = { amp: "&", apos: "'", @@ -87,18 +84,54 @@ export const decodeXmlText = (text: string): string => export const parseXmlPropertyList = ( text: string, ): PropertyListWithoutPrototypeKeys => { - // The decoded text holds every key the decoder can produce, including - // entity-encoded spellings of the placeholder, so no source key aliases it. - // Decoding comment or CDATA text too can only lengthen the placeholder. - const decoded = decodeXmlText(text); - let placeholder = "__rea_prototype_key__"; - while (decoded.includes(placeholder)) placeholder = `_${placeholder}`; - const substituted = text.replace( - KEY_TOKEN, - (token, plain?: string, cdata?: string) => - (plain === undefined ? cdata : decodeXmlText(plain)) === PROTOTYPE_KEY - ? `${placeholder}` - : token, + // Preserve the pinned decoder's existing OpenStep autodetection, also used + // by app Info.plist resolution. Only XML needs the key-element substitution. + const first = text.trimStart()[0]; + if (first === "{" || first === "(") return omitPrototypeKeys(parse(text)); + const { substituted, placeholder } = substitutePrototypeKeys( + normalizeLineEndings(text), ); return withoutKey(parse(substituted), placeholder); }; + +/** Locate keys with the same XML parser and first-child semantics as plist. */ +const substitutePrototypeKeys = (text: string) => { + // Normalize only the literal line endings the pinned decoder normalizes. + // Preserve character references in the source: serializing a DOM would turn + // e.g. an encoded carriage return into a literal newline on the second parse. + const document = new DOMParser().parseFromString(text, "text/xml"); + const keys = Array.from(document.getElementsByTagName("key")); + const decodedKeys = new Set(keys.map((key) => key.firstChild?.nodeValue)); + let placeholder = "__rea_prototype_key__"; + while (decodedKeys.has(placeholder)) placeholder = `_${placeholder}`; + + const omitted = keys + .map((key) => key.firstChild) + .filter((node) => node?.nodeValue === PROTOTYPE_KEY); + if (omitted.length === 0) return { substituted: text, placeholder }; + + // xmldom uses one-based locators in document order. Scan forward only to + // needed lines instead of retaining an offset for every source newline. + let line = 1; + let lineStart = 0; + const parts: string[] = []; + let cursor = 0; + for (const node of omitted) { + const nodeLine = node?.lineNumber; + const column = node?.columnNumber; + if (nodeLine === undefined || column === undefined) + throw new TypeError("XML plist key has no source location"); + while (line < nodeLine) { + const newline = text.indexOf("\n", lineStart); + if (newline === -1) + throw new TypeError("XML plist key has an invalid source location"); + lineStart = newline + 1; + line += 1; + } + const offset = lineStart + column - 1; + parts.push(text.slice(cursor, offset), ``); + cursor = offset; + } + parts.push(text.slice(cursor)); + return { substituted: parts.join(""), placeholder }; +}; diff --git a/tests/boundary/cli/keyedArchivePrototypeKeyCli.test.ts b/tests/boundary/cli/keyedArchivePrototypeKeyCli.test.ts new file mode 100644 index 000000000..79716b6cd --- /dev/null +++ b/tests/boundary/cli/keyedArchivePrototypeKeyCli.test.ts @@ -0,0 +1,24 @@ +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { + expectKeyedArchivePrototypeKeys, + keyedArchivePrototypeKeyFixture, +} from "../../fixtures/keyedArchivePrototypeKeys.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "compiled CLI decodes formatted XML prototype keys", + async ({ cli }) => { + const fixture = await keyedArchivePrototypeKeyFixture(); + const output = await cli.run({ + arguments: ["inspect-keyed-archive", fixture.path, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(output.exitCode, output.stderr).toBe(0); + expectKeyedArchivePrototypeKeys( + parseEvidence(output.json).normalized_result, + fixture.digest, + ); + }, +); diff --git a/tests/boundary/mcp/keyedArchivePrototypeKeyMcp.test.ts b/tests/boundary/mcp/keyedArchivePrototypeKeyMcp.test.ts new file mode 100644 index 000000000..b7803f0bd --- /dev/null +++ b/tests/boundary/mcp/keyedArchivePrototypeKeyMcp.test.ts @@ -0,0 +1,49 @@ +import { expect } from "vitest"; +import { z } from "zod"; + +import { createBinarySession } from "../../../src/composition/binary.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { + expectKeyedArchivePrototypeKeys, + keyedArchivePrototypeKeyFixture, +} from "../../fixtures/keyedArchivePrototypeKeys.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; + +mcpTest( + "MCP retains ordinary evidence with formatted XML prototype keys", + async ({ mcp, onTestFinished }) => { + const fixture = await keyedArchivePrototypeKeyFixture(); + const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" }); + if (!configured.ok) throw configured.error; + const session = createBinarySession( + configured.value, + silentLogger, + process.env, + ); + onTestFinished(async () => { + await session.close(); + }); + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: fixture.path }, + }); + expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true); + const result = await client.callTool({ + name: "inspect_keyed_archive", + arguments: {}, + }); + expect(result.isError, JSON.stringify(result.content)).not.toBe(true); + expectKeyedArchivePrototypeKeys( + z + .object({ normalized_result: keyedArchiveResultSchema }) + .parse(result.structuredContent).normalized_result, + fixture.digest, + ); + }, +); diff --git a/tests/fixtures/keyedArchivePrototypeKeys.ts b/tests/fixtures/keyedArchivePrototypeKeys.ts new file mode 100644 index 000000000..421935d4d --- /dev/null +++ b/tests/fixtures/keyedArchivePrototypeKeys.ts @@ -0,0 +1,58 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { keyedArchiveResultSchema } from "../../src/domain/apple/keyedArchive.js"; +import { createTestTempDirectory } from "./temporaryDirectory.js"; + +/** Inert XML with producer-equivalent key spellings and unchanged ordinary data. */ +export const keyedArchivePrototypeKeyFixture = async () => { + const directory = await createTestTempDirectory("rea-keyed-xml-keys-"); + const path = join(directory, "archive.plist"); + const bytes = Buffer.from(` + + $archiverNSKeyedArchiver + $version100000 + $objects$null + __proto__omitted outer value + kepta b…c
d + __rea_prototype_key__ordinary key + nested + + omitted nested value + value7 + + + $toproot + CF$UID1 + +`); + await writeFile(path, bytes); + return { path, digest: createHash("sha256").update(bytes).digest("hex") }; +}; + +/** Require original source identity, ordinary values and the exact omission count. */ +export const expectKeyedArchivePrototypeKeys = ( + value: unknown, + digest: string, +) => { + const result = keyedArchiveResultSchema.parse(value); + expect(result.archive_sha256).toBe(digest); + expect(result.roots).toEqual({ root: { CF$UID: 1 } }); + expect(result.objects).toHaveLength(2); + expect(result.objects[1]).toMatchObject({ + id: 1, + kind: "dictionary", + value: { + kept: "a\rb\u0085c\u2028d", + __rea_prototype_key__: "ordinary key", + nested: { value: 7 }, + }, + }); + expect(result.limitations).toContain( + "2 dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.", + ); + expect(JSON.stringify(result)).not.toContain("omitted outer value"); + expect(JSON.stringify(result)).not.toContain("omitted nested value"); +};