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:
morluto committed 2026-10-26 22:20:00 +08:00
1 parent 3b26d7f80b
commit eb06e263cb
35 files changed
+1006 -785

No files matched your search

+2 -13
View File
@@ -84,10 +84,7 @@ import {
} from "../domain/apple/plistValue.js";
import { createPlistNumberProjection } from "../domain/apple/plistNumbers.js";
import type { NativeUiObservationResult } from "../domain/native/nativeUiObservation.js";
import {
omittedPrototypeKeysLimitation,
parseXmlPropertyList,
} from "../domain/propertyListKeys.js";
import { parseXmlPropertyList } from "../domain/propertyListKeys.js";
import {
demangleSwiftSchema,
inspectPlistSchema,
@@ -808,11 +805,6 @@ class NativeMacOSClient implements AnalysisClient {
...parsed.limitations,
...entitlementValue.limitations.map((note) => `Entitlements: ${note}`),
"Inspected a digest-verified Mach-O byte snapshot; filesystem-bound signing context and trust validation are not examined.",
...(entitlementValue.omittedPrototypeKeys === 0
? []
: [
`Entitlements: ${omittedPrototypeKeysLimitation(entitlementValue.omittedPrototypeKeys)}`,
]),
];
const architectures = codeSignArchitectures(parsed.format, this.target);
if (architectures.length > 1)
@@ -1491,13 +1483,11 @@ const parseEntitlements = (
output: string,
): {
readonly value: JsonValue;
readonly omittedPrototypeKeys: number;
readonly limitations: readonly string[];
} => {
const start = output.indexOf("<?xml");
const end = output.lastIndexOf("</plist>");
if (start < 0 || end < start)
return { value: null, omittedPrototypeKeys: 0, limitations: [] };
if (start < 0 || end < start) return { value: null, limitations: [] };
const decoded = parseXmlPropertyList(
output.slice(start, end + "</plist>".length),
);
@@ -1506,7 +1496,6 @@ const parseEntitlements = (
const value = numbers.project(projected.value);
return {
value,
omittedPrototypeKeys: decoded.omittedPrototypeKeys,
limitations: [
...numbers.limitations(),
...(projected.nonFiniteRealCount === 0
+41 -6
View File
@@ -530,15 +530,16 @@ it("rejects a truncated binary plist source", () => {
it("retains complete segmented XML keys in native plist fallback evidence", () => {
const parsed = parsePlistXml(
"<plist><dict><key>user<![CDATA[ID]]></key><integer>42</integer><key>__pro<![CDATA[to__]]></key><string>omitted</string><key>Blob</key><data>AA==</data></dict></plist>",
"<plist><dict><key>user<![CDATA[ID]]></key><integer>42</integer><key>__pro<![CDATA[to__]]></key><string>retained</string><key>Blob</key><data>AA==</data></dict></plist>",
);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toEqual({
userID: 42,
["__proto__"]: "retained",
Blob: { $plist_type: "data", base64: "AA==" },
});
expect(parsed.value.limitations).toContain(
"1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
expect(parsed.value.limitations).not.toContainEqual(
expect.stringContaining("omitted"),
);
});
@@ -720,18 +721,24 @@ it("uses only surviving duplicate JSON entries for number types and limitations"
]);
});
it("counts numeric source facts before omitting prototype dictionary entries", () => {
it("preserves numeric source facts inside prototype-named dictionary entries", () => {
const attempt = readPlistJson(
'{"__proto__":{"integer":9007199254740993,"zero":-0.0,"__proto__":7},"kept":42}',
);
expect(attempt.needsNumberTypes).toBe(true);
const parsed = attempt.project();
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toEqual({ kept: 42 });
expect(parsed.value.value).toEqual({
["__proto__"]: {
integer: 9007199254740992,
zero: { $plist_type: "real", value: "-0" },
["__proto__"]: 7,
},
kept: 42,
});
expect(parsed.value.limitations).toEqual([
expect.stringContaining("1 negative-zero real value(s)"),
expect.stringContaining("1 number(s)"),
"1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
]);
});
@@ -842,3 +849,31 @@ it("keeps CFPropertyList's non-finite real spellings in XML evidence", () => {
expect.stringContaining("1 XML scalar value(s) could not be established"),
]);
});
it("retains nested own dictionary names consistently in native JSON, XML and binary projections", () => {
const value = Object.fromEntries([
[
"__proto__",
Object.fromEntries([
["__proto__", "nested"],
["constructor", "authored"],
]),
],
["CFBundleExecutable", "Selected"],
]);
const xml =
"<plist><dict><key>__proto__</key><dict><key>__proto__</key><string>nested</string><key>constructor</key><string>authored</string></dict><key>CFBundleExecutable</key><string>Selected</string></dict></plist>";
for (const parsed of [
readPlistJson(JSON.stringify(value)).project(),
parsePlistXml(xml),
parsePlistBinary(Buffer.from(buildBinary(value))),
]) {
if (!parsed.ok) throw parsed.error;
expect(JSON.stringify(parsed.value.value)).toBe(JSON.stringify(value));
expect(jsonValueSchema.parse(parsed.value.value)).toEqual(value);
expect(parsed.value.bundle.executable).toBe("Selected");
expect(parsed.value.limitations).not.toContainEqual(
expect.stringContaining("omitted"),
);
}
});
+11 -30
View File
@@ -1,5 +1,4 @@
import { z } from "zod";
import { parseBinary } from "plist";
import { decodeBinaryPlistValue } from "../../artifacts/apple/BinaryPlistMaterializer.js";
import {
AnalysisOutputError,
@@ -18,11 +17,7 @@ import {
type ProjectedPlistValue,
PlistNestingLimitError,
} from "../../domain/apple/plistValue.js";
import {
omitPrototypeKeys,
omittedPrototypeKeysLimitation,
parseXmlPropertyList,
} from "../../domain/propertyListKeys.js";
import { parseXmlPropertyList } from "../../domain/propertyListKeys.js";
import { err, ok, type Result } from "../../domain/result.js";
import {
@@ -31,7 +26,6 @@ import {
type PlistNumberLiterals,
} from "../../domain/apple/plistNumbers.js";
import {
decodeBinaryPlistScalars,
scanBinaryPlistScalars,
UnsupportedBinaryPlistUidError,
} from "../../artifacts/apple/PlistScalars.js";
@@ -63,8 +57,6 @@ const mebibytes = (bytes: number): string =>
/** Recovery name for the fixed source-size ceiling. */
export const NATIVE_PLIST_INPUT_ALLOWANCE = `the fixed ${mebibytes(MAX_NATIVE_PLIST_BYTES)} (${String(MAX_NATIVE_PLIST_BYTES)}-byte) native plist input allowance (no runtime override)`;
const plistObject = z.record(z.string(), z.unknown());
/** Stable bundle metadata projected from one plist value. */
export interface PlistBundleMetadata {
readonly identifier: string | null;
@@ -234,7 +226,7 @@ const projectPlistJsonAttempt = (
);
return item;
};
let decoded: ReturnType<typeof omitPrototypeKeys>;
let value: unknown;
try {
// Preserve XML-before-JSON failure precedence during final projection.
literals =
@@ -243,12 +235,10 @@ const projectPlistJsonAttempt = (
: parseXmlPropertyList(xmlConversion, MAX_NATIVE_PLIST_NESTING_DEPTH)
.numberLiterals;
if (!attempt.ok) return err(plistOutputError(attempt.error));
// Numeric limitations also account for values under omitted source keys.
decoded = omitPrototypeKeys(project(attempt.value, 0));
value = project(attempt.value, 0);
} catch (cause: unknown) {
return err(plistOutputError(cause));
}
const { value } = decoded;
return ok({
value,
bundle: projectPlistBundle(value),
@@ -258,7 +248,6 @@ const projectPlistJsonAttempt = (
? [incompletePlistNumberLimitation]
: []),
...numberLimitations(exactIntegerCount, ambiguousNumberCount),
...prototypeKeyLimitations(decoded.omittedPrototypeKeys),
],
});
};
@@ -323,14 +312,12 @@ const projectPlistXml = (
failureReason?: string,
): Result<ParsedPlist, PlistParseError> => {
let projected: ProjectedPlistValue;
let omittedPrototypeKeys: number;
let literals: PlistNumberLiterals;
try {
const decoded = parseXmlPropertyList(
output,
MAX_NATIVE_PLIST_NESTING_DEPTH,
);
omittedPrototypeKeys = decoded.omittedPrototypeKeys;
literals = decoded.numberLiterals;
projected = projectPlistValue(
decoded.value,
@@ -367,7 +354,6 @@ const projectPlistXml = (
...projectedScalarLimitations(projected),
...(literals.incomplete ? [incompletePlistNumberLimitation] : []),
...numberLimitations(exactIntegerCount, ambiguousNumberCount),
...prototypeKeyLimitations(omittedPrototypeKeys),
],
});
};
@@ -400,9 +386,7 @@ export const parsePlistBinary = (
},
);
const scalarMetadata = scanBinaryPlistScalars(bytes, expansion);
const decoded = parseBinary(
decodeBinaryPlistScalars(bytes, scalarMetadata, expansion),
);
const decoded = decodeBinaryPlistValue(bytes, expansion, scalarMetadata);
const projected = projectPlistValue(
decoded,
undefined,
@@ -423,9 +407,6 @@ export const parsePlistBinary = (
),
]),
...numbers.limitations(),
...(expansion.binaryPrototypeKeyObserved
? [omittedPrototypeKeysLimitation(null)]
: []),
],
});
} catch (cause: unknown) {
@@ -462,9 +443,6 @@ export const parsePlistBinary = (
}
};
const prototypeKeyLimitations = (count: number): string[] =>
count === 0 ? [] : [omittedPrototypeKeysLimitation(count)];
const projectedScalarLimitations = (
projected: ProjectedPlistValue,
): string[] => [
@@ -517,10 +495,13 @@ const mapJsonNumbers = (
};
const projectPlistBundle = (value: unknown): PlistBundleMetadata => {
const object = plistObject.safeParse(value);
const object =
value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
const field = (name: string): string | null => {
if (!object.success) return null;
const candidate = object.data[name];
if (object === undefined || !Object.hasOwn(object, name)) return null;
const candidate = object[name];
return typeof candidate === "string" ? candidate : null;
};
return {