mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(plist): own dictionary decoding across Apple workflows
Preserve XML, OpenStep and binary dictionary keys at their format owners. Share validated binary reference facts with materialization while retaining scalar precision, UID identity and bounded expansion. Migrate archive, Interface Builder, native and app-bundle consumers, then remove obsolete key-omission accounting and schema copies. Validated source regressions and compiled CLI/MCP workflows, including native plist fidelity and archive identity. Typecheck, lint, docs and test discovery pass. No performance benchmarks were required.
This commit is contained in:
1 parent
3b26d7f80b
commit
eb06e263cb
35 files changed
+1006
-785
No files matched your search
@@ -96,61 +96,70 @@ cliTest(
|
||||
},
|
||||
);
|
||||
|
||||
const keyedNibIdentityFixture = (format: "xml" | "binary"): Buffer => {
|
||||
// Source-derived keyed-nib records, not output from an Apple compiler.
|
||||
const uid = (value: number) =>
|
||||
format === "binary" ? { UID: value } : { CF$UID: value };
|
||||
const source = {
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: [
|
||||
"$null",
|
||||
{
|
||||
$class: uid(4),
|
||||
id: "2",
|
||||
archiveUID: 2,
|
||||
objectID: uid(8),
|
||||
"object-id": uid(9),
|
||||
title: "Root",
|
||||
proto_key: { constructor: "authored", kept: true },
|
||||
NSSubviews: [uid(2)],
|
||||
},
|
||||
{
|
||||
$class: uid(4),
|
||||
objectID: uid(8),
|
||||
"object-id": uid(9),
|
||||
title: "Child",
|
||||
},
|
||||
{
|
||||
$class: uid(5),
|
||||
objectID: uid(8),
|
||||
UISource: uid(2),
|
||||
UIDestination: uid(1),
|
||||
UILabel: "parent",
|
||||
},
|
||||
{
|
||||
$classname: "ArchiveProbeView",
|
||||
$classes: ["ArchiveProbeView", "UIView", "NSObject"],
|
||||
},
|
||||
{
|
||||
$classname: "UIRuntimeOutletConnection",
|
||||
$classes: [
|
||||
"UIRuntimeOutletConnection",
|
||||
"UIRuntimeConnection",
|
||||
"NSObject",
|
||||
],
|
||||
},
|
||||
{ $class: uid(7), "NS.objects": [uid(1)] },
|
||||
{ $classname: "NSArray", $classes: ["NSArray", "NSObject"] },
|
||||
"2",
|
||||
"shared:hierarchy:authored",
|
||||
],
|
||||
$top: { UINibTopLevelObjectsKey: uid(6) },
|
||||
};
|
||||
const bytes = Buffer.from(
|
||||
format === "binary" ? buildBinary(source) : build(source),
|
||||
);
|
||||
const keyOffset = bytes.indexOf("proto_key");
|
||||
expect(keyOffset).toBeGreaterThanOrEqual(0);
|
||||
Buffer.from("__proto__").copy(bytes, keyOffset);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
cliTest.for(["xml", "binary"] as const)(
|
||||
"preserves UID identity despite colliding authored IDs in %s through CLI and MCP",
|
||||
async (format, { cli }) => {
|
||||
// Source-derived keyed-nib records, not output from an Apple compiler.
|
||||
const uid = (value: number) =>
|
||||
format === "binary" ? { UID: value } : { CF$UID: value };
|
||||
const source = {
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: [
|
||||
"$null",
|
||||
{
|
||||
$class: uid(4),
|
||||
id: "2",
|
||||
archiveUID: 2,
|
||||
objectID: uid(8),
|
||||
"object-id": uid(9),
|
||||
title: "Root",
|
||||
NSSubviews: [uid(2)],
|
||||
},
|
||||
{
|
||||
$class: uid(4),
|
||||
objectID: uid(8),
|
||||
"object-id": uid(9),
|
||||
title: "Child",
|
||||
},
|
||||
{
|
||||
$class: uid(5),
|
||||
objectID: uid(8),
|
||||
UISource: uid(2),
|
||||
UIDestination: uid(1),
|
||||
UILabel: "parent",
|
||||
},
|
||||
{
|
||||
$classname: "ArchiveProbeView",
|
||||
$classes: ["ArchiveProbeView", "UIView", "NSObject"],
|
||||
},
|
||||
{
|
||||
$classname: "UIRuntimeOutletConnection",
|
||||
$classes: [
|
||||
"UIRuntimeOutletConnection",
|
||||
"UIRuntimeConnection",
|
||||
"NSObject",
|
||||
],
|
||||
},
|
||||
{ $class: uid(7), "NS.objects": [uid(1)] },
|
||||
{ $classname: "NSArray", $classes: ["NSArray", "NSObject"] },
|
||||
"2",
|
||||
"shared:hierarchy:authored",
|
||||
],
|
||||
$top: { UINibTopLevelObjectsKey: uid(6) },
|
||||
};
|
||||
const bytes = Buffer.from(
|
||||
format === "binary" ? buildBinary(source) : build(source),
|
||||
);
|
||||
const bytes = keyedNibIdentityFixture(format);
|
||||
const app = await writeUIKitApp("rea-keyed-nib-hierarchy-", bytes);
|
||||
const path = join(app, "Contents/Resources/Main.nib");
|
||||
const output = await cli.run({
|
||||
@@ -180,8 +189,10 @@ cliTest.for(["xml", "binary"] as const)(
|
||||
objectID: "2",
|
||||
"object-id": "shared:hierarchy:authored",
|
||||
title: "Root",
|
||||
["__proto__"]: { constructor: "authored", kept: true },
|
||||
},
|
||||
});
|
||||
expect(Object.hasOwn(root!.attributes, "__proto__")).toBe(true);
|
||||
const child = report.graph.nodes.find(
|
||||
({ attributes }) => attributes.interface_builder_object_id === "2",
|
||||
);
|
||||
|
||||
@@ -171,7 +171,13 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
separators: "a\u0085b\u2028c\u2029d",
|
||||
payload: { $plist_type: "data", base64: "AAEC" },
|
||||
},
|
||||
provenance: [{ tool: "file" }],
|
||||
provenance: [
|
||||
{ tool: "file" },
|
||||
{
|
||||
tool: "plutil",
|
||||
command: ["/usr/bin/plutil", "-lint", "--", "$PLIST"],
|
||||
},
|
||||
],
|
||||
limitations: [],
|
||||
});
|
||||
expect(Buffer.compare(await readFile(binaryPath), binaryBytes)).toBe(0);
|
||||
|
||||
@@ -7,9 +7,6 @@ import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const OMITTED =
|
||||
"1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.";
|
||||
|
||||
describe.skipIf(process.platform !== "darwin")(
|
||||
"native plist CLI with a __proto__ key",
|
||||
() => {
|
||||
@@ -17,7 +14,7 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
["JSON-expressible", ""],
|
||||
["data-bearing", "<key>Blob</key><data>AAEC</data>"],
|
||||
] as const)(
|
||||
"inspects a $0 plist and reports the omitted key",
|
||||
"inspects a $0 plist and retains the prototype-named key",
|
||||
async ([, extra], { cli }) => {
|
||||
const directory = await createTestTempDirectory("rea-plist-proto-");
|
||||
const app = join(directory, "Proto.app");
|
||||
@@ -41,12 +38,19 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.json).toMatchObject({
|
||||
normalized_result: {
|
||||
value: { CFBundleExecutable: "App" },
|
||||
value: {
|
||||
CFBundleExecutable: "App",
|
||||
["__proto__"]: { polluted: true },
|
||||
},
|
||||
bundle: { executable: "App" },
|
||||
limitations: expect.arrayContaining([OMITTED]),
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result.json)).not.toContain("polluted");
|
||||
expect(JSON.stringify(result.json)).toContain(
|
||||
'"__proto__":{"polluted":true}',
|
||||
);
|
||||
expect(JSON.stringify(result.json)).not.toContain(
|
||||
"keyed __proto__ was omitted",
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -296,9 +296,9 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
},
|
||||
);
|
||||
cliTest(
|
||||
"omits a valid prototype key after native validation",
|
||||
"retains a valid prototype key after native validation",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-plist-valid-omission-");
|
||||
const root = await createTestTempDirectory("rea-plist-valid-key-");
|
||||
const path = join(root, "chosen.plist");
|
||||
const source =
|
||||
"<plist><dict><key>__proto__</key><integer>42</integer><key>kept</key><string>value</string></dict></plist>";
|
||||
@@ -310,7 +310,7 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.json).toMatchObject({
|
||||
normalized_result: {
|
||||
value: { kept: "value" },
|
||||
value: { ["__proto__"]: 42, kept: "value" },
|
||||
provenance: [
|
||||
expect.objectContaining({ tool: "file" }),
|
||||
expect.objectContaining({
|
||||
@@ -318,13 +318,12 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
command: ["/usr/bin/plutil", "-lint", "--", "$PLIST"],
|
||||
}),
|
||||
],
|
||||
limitations: expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"1 dictionary entry keyed __proto__ was omitted",
|
||||
),
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result.json)).toContain('"__proto__":42');
|
||||
expect(JSON.stringify(result.json)).not.toContain(
|
||||
"keyed __proto__ was omitted",
|
||||
);
|
||||
expect(await readFile(path, "utf8")).toBe(source);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1306,8 +1306,8 @@ cliTest(
|
||||
},
|
||||
);
|
||||
|
||||
describe("binary Interface Builder archive omissions", () => {
|
||||
it("reports omitted __proto__ entries as partial decoding", async () => {
|
||||
describe("binary Interface Builder dictionary keys", () => {
|
||||
it("does not report decoded __proto__ entries as omitted", async () => {
|
||||
const placeholder = "proto_key";
|
||||
const encoded = Buffer.from(
|
||||
buildBinary({
|
||||
@@ -1336,13 +1336,13 @@ describe("binary Interface Builder archive omissions", () => {
|
||||
expect(result.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "archive_decode",
|
||||
status: "partial",
|
||||
reason: "dictionary_entries_omitted",
|
||||
status: "complete",
|
||||
reason: null,
|
||||
}),
|
||||
);
|
||||
expect(result.limitations).toContain(
|
||||
"Contents/Resources/Prototype.nib: Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
|
||||
);
|
||||
expect(
|
||||
result.limitations.some((value) => value.includes("keyed __proto__")),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1505,7 +1505,7 @@ describe("bounded Interface Builder archive decoding", () => {
|
||||
expect(result.graph.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("marks archive decoding partial when __proto__ entries are omitted", async () => {
|
||||
it("does not mark XML archive decoding partial for __proto__ entries", async () => {
|
||||
const root = await createTestTempDirectory("rea-ib-test-");
|
||||
const bundle = join(root, "Example.app");
|
||||
const resources = join(bundle, "Contents", "Resources");
|
||||
@@ -1523,14 +1523,13 @@ describe("bounded Interface Builder archive decoding", () => {
|
||||
expect(result.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "archive_decode",
|
||||
status: "partial",
|
||||
reason: "dictionary_entries_omitted",
|
||||
status: "complete",
|
||||
reason: null,
|
||||
}),
|
||||
);
|
||||
expect(result.graph.truncated).toBe(true);
|
||||
expect(result.limitations).toContain(
|
||||
"Contents/Resources/Prototype.nib: 1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
|
||||
);
|
||||
expect(
|
||||
result.limitations.some((value) => value.includes("keyed __proto__")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("decodes an archive with a date year beyond four digits", async () => {
|
||||
|
||||
@@ -20,8 +20,8 @@ it.each([129, 3000])(
|
||||
parameters: { path: "Deep.plist" },
|
||||
platform: "linux",
|
||||
}).catch((cause: unknown) => cause);
|
||||
// Recursive decoding exhausts the call stack before the projection limit
|
||||
// can report it, so the limit diagnostic must come from the preflight.
|
||||
// The format owner must reject source depth before recursive decoding
|
||||
// exhausts the call stack.
|
||||
expect(rejected).toMatchObject({
|
||||
reason: "limit",
|
||||
message: `Cannot decode selected Foundation keyed archive: ${plistNestingLimitMessage()}`,
|
||||
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
} from "../../../../src/native/CommandRunner.js";
|
||||
import { err, ok } from "../../../../src/domain/result.js";
|
||||
import { parseLipoOutput } from "../../../../src/native/parsers/lipo.js";
|
||||
import { inspectPlistSchema } from "../../../../src/domain/native/nativeInspection.js";
|
||||
import {
|
||||
inspectPlistSchema,
|
||||
inspectSignatureSchema,
|
||||
} from "../../../../src/domain/native/nativeInspection.js";
|
||||
import { parseCodeSignature } from "../../../../src/native/parsers/codesign.js";
|
||||
import { prepareProcessOwnershipInspection } from "../../../../src/process/ProcessOwnershipObservation.js";
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
@@ -1504,7 +1507,7 @@ class PrototypeEntitlementsRunner extends FixtureRunner {
|
||||
}
|
||||
|
||||
describe("native signature entitlements", () => {
|
||||
it("reports entitlement entries keyed __proto__ that the result omits", async () => {
|
||||
it("retains own entitlement dictionary keys through signature Evidence", async () => {
|
||||
directory = await createTestTempDirectory("rea-signature-entitlements-");
|
||||
const executable = join(directory, "fixture");
|
||||
await writeFile(executable, "fixture");
|
||||
@@ -1518,10 +1521,17 @@ describe("native signature entitlements", () => {
|
||||
|
||||
if (!signature.ok) throw signature.error;
|
||||
expect(signature.value.result).toMatchObject({
|
||||
entitlements: { "com.apple.security.app-sandbox": true },
|
||||
entitlements: {
|
||||
"com.apple.security.app-sandbox": true,
|
||||
["__proto__"]: { "com.apple.security.get-task-allow": true },
|
||||
},
|
||||
});
|
||||
expect(signature.value.limitations).toContain(
|
||||
"Entitlements: 1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
|
||||
const observed = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(observed.entitlements, "__proto__"),
|
||||
).toBe(true);
|
||||
expect(signature.value.limitations).not.toContainEqual(
|
||||
expect.stringContaining("omitted"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+22
-11
@@ -15,16 +15,16 @@ export const keyedArchivePrototypeKeyFixture = async () => {
|
||||
<key>$archiver</key><string>NSKeyedArchiver</string>
|
||||
<key>$version</key><integer>100000</integer>
|
||||
<key>$objects</key><array><string>$null</string><dict>
|
||||
<key >__proto__</key ><string>omitted outer value</string>
|
||||
<key >__proto__</key ><string>recorded outer value</string>
|
||||
<key>kept</key><string>a b…c
d</string>
|
||||
<key>__rea_prototype_key__</key><string>ordinary key</string>
|
||||
<key>nested</key><dict>
|
||||
<key><![CDATA[__proto__]]><!-- trailing comment --></key>
|
||||
<string>omitted nested value</string>
|
||||
<string>recorded nested value</string>
|
||||
<key>value</key><integer>7</integer>
|
||||
</dict>
|
||||
</dict></array>
|
||||
<key>$top</key><dict><key>root</key><dict>
|
||||
<key>$top</key><dict><key>__proto__</key><dict><key>CF$UID</key><integer>1</integer></dict><key>root</key><dict>
|
||||
<key>CF$UID</key><integer>1</integer>
|
||||
</dict></dict>
|
||||
</dict></plist>`);
|
||||
@@ -32,27 +32,38 @@ export const keyedArchivePrototypeKeyFixture = async () => {
|
||||
return { path, digest: createHash("sha256").update(bytes).digest("hex") };
|
||||
};
|
||||
|
||||
/** Require original source identity, ordinary values and the exact omission count. */
|
||||
/** Require original source identity and every decoded dictionary member. */
|
||||
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.roots).toEqual({
|
||||
["__proto__"]: { CF$UID: 1 },
|
||||
root: { CF$UID: 1 },
|
||||
});
|
||||
expect(Object.hasOwn(result.roots, "__proto__")).toBe(true);
|
||||
expect(result.references).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: null,
|
||||
path: ["__proto__"],
|
||||
target: 1,
|
||||
status: "resolved",
|
||||
}),
|
||||
);
|
||||
expect(result.objects).toHaveLength(2);
|
||||
expect(result.objects[1]).toMatchObject({
|
||||
id: 1,
|
||||
kind: "dictionary",
|
||||
value: {
|
||||
["__proto__"]: "recorded outer value",
|
||||
kept: "a\rb\u0085c\u2028d",
|
||||
__rea_prototype_key__: "ordinary key",
|
||||
nested: { value: 7 },
|
||||
nested: { ["__proto__"]: "recorded nested value", 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");
|
||||
expect(
|
||||
result.limitations.some((value) => value.includes("keyed __proto__")),
|
||||
).toBe(false);
|
||||
};
|
||||
Reference in new issue
Block a user