fix(plist): preserve Unicode separators during XML normalization

Use one XML 1.0 line-ending rule in the shared XML boundary and plist source-span owner. Normalize literal CRLF and CR while retaining NEL, line separator and paragraph separator as observed characters. Keep character references, raw scalar locations and prototype-key omission intact.

Validation: 229 XML/plist source tests, 60 adjacent Ghidra/Apktool/SAZ source tests, and 33 compiled CLI/provider tests passed. Full check:ci passed. Real plutil XML and binary fixtures reproduced the baseline loss and passed CLI plus SDK-validated stdio MCP checks with unchanged input digests. Independent source review found no blocker. Binary conversion losses tracked by #5701, #5703 and #5705 remain separate work.

Closes #5702
This commit is contained in:
morluto committed 2026-10-21 11:42:25 +08:00
1 parent 335c042d16
commit 00266efe13
7 files changed
+61 -14

No files matched your search

+16 -1
View File
@@ -315,7 +315,7 @@ it("bounds nested plist wrappers before recursive decoding", () => {
).toEqual(["x"]);
});
it.each(["\n", "\r\n", "\r", "\u0085", "\u2028", "\u2029"])(
it.each(["\n", "\r\n", "\r"])(
"preserves unrelated source values around %j line endings",
(newline) => {
const text = plist(
@@ -390,3 +390,18 @@ describe("OpenStep plist nesting preflight", () => {
});
});
});
it.each(["\u0085", "\u2028", "\u2029"])(
"preserves key identity and omission around inter-element separator %j",
(separator) => {
const parsed = parseXmlPropertyList(
plist(
`<dict>${separator}<key>kept${separator}key</key><string>a${separator}b</string>${separator}<key>__proto__</key><string>omitted</string></dict>`,
),
);
expect(parsed).toEqual({
value: { [`kept${separator}key`]: `a${separator}b` },
omittedPrototypeKeys: 1,
});
},
);
+7
View File
@@ -290,6 +290,13 @@ it("binds raw literals to their nodes across normalized line endings and UTF-16
]);
});
it("preserves Unicode line separators in plist string values", () => {
const value = "a\u0085b\u2028c\u2029d";
expect(
parseXmlPropertyList(`<plist><string>${value}</string></plist>`).value,
).toBe(value);
});
it.each(["\u0085", "\u2028", "\u2029"])(
"does not normalize unsupported leading whitespace %j into an observed scalar",
(prefix) => {
+3 -6
View File
@@ -1,15 +1,12 @@
import type { Document, Node } from "@xmldom/xmldom";
import { MAX_PLIST_DEPTH } from "./apple/plistValue.js";
import { err, ok, type Result } from "./result.js";
import { parseXmlDocument } from "./xmlDocument.js";
import { parseXmlDocument, XML_LINE_ENDING } from "./xmlDocument.js";
import {
decodeXmlPlistData,
XmlPlistDataError,
} from "./propertyListXmlData.js";
// Preserve DOM compatibility normalization while retaining raw scalar spans.
const LINE_ENDING = /\r[\n\u0085]|[\r\n\u0085\u2028\u2029]/gu;
interface ScalarSpan {
readonly start: number;
readonly end: number;
@@ -25,14 +22,14 @@ export class XmlPlistSourceError extends TypeError {
/** One source owner binds lexical scalar values to their validated DOM nodes. */
export const readXmlPlistSource = (text: string) => {
const normalized = text.replace(LINE_ENDING, "\n");
const normalized = text.replace(XML_LINE_ENDING, "\n");
const parsed = parseXmlDocument(normalized, {
doctype: "allow",
lineEndings: "preserve",
});
if (!parsed.ok) throw parsed.error;
const document = parsed.value;
const lineEndings = new RegExp(LINE_ENDING);
const lineEndings = new RegExp(XML_LINE_ENDING);
let line = 1;
let lineStart = 0;
// The depth walk visits elements in source order, so location lookup is
+14
View File
@@ -73,3 +73,17 @@ it("applies XML line-ending normalization only when the consumer selects it", ()
expect(parsed.value.documentElement?.getAttribute("attr")).toBe("a b");
expect(parsed.value.documentElement?.textContent).toBe("\nx");
});
it("preserves Unicode separators under XML 1.0 normalization", () => {
const parsed = parseXmlDocument(
'<root attr="a\u0085b\u2028c\u2029d">a\r\u0085b\r\u2028c\r\n&#13;d\u2029</root>',
{ doctype: "allow", lineEndings: "normalize" },
);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.documentElement?.getAttribute("attr")).toBe(
"a\u0085b\u2028c\u2029d",
);
expect(parsed.value.documentElement?.textContent).toBe(
"a\n\u0085b\n\u2028c\n\rd\u2029",
);
});
+5 -7
View File
@@ -1,12 +1,10 @@
import {
DOMParser,
normalizeLineEndings,
onErrorStopParsing,
type Document,
} from "@xmldom/xmldom";
import { DOMParser, onErrorStopParsing, type Document } from "@xmldom/xmldom";
import { SaxesParser } from "saxes";
import { err, ok, type Result } from "./result.js";
/** XML 1.0 literal line endings; Unicode separators remain source characters. */
export const XML_LINE_ENDING = /\r\n?|\n/gu;
export class XmlDocumentParseError extends Error {
readonly _tag = "XmlDocumentParseError";
constructor(
@@ -48,7 +46,7 @@ export const parseXmlDocument = (
normalizeLineEndings:
policy.lineEndings === "preserve"
? (text) => text
: normalizeLineEndings,
: (text) => text.replace(XML_LINE_ENDING, "\n"),
onError: onErrorStopParsing,
}).parseFromString(source, "application/xml"),
);
@@ -228,6 +228,7 @@ describe.skipIf(process.platform !== "darwin")(
"<key>CFBundleExecutable</key><string>App</string>",
"<key>CFBundleIdentifier</key><string>com.example.types</string>",
"<key>Blob</key><data>AAEC</data>",
"<key>Separators</key><string>a\u0085b\u2028c\u2029d</string>",
"<key>Built</key><date>2020-01-01T00:00:00Z</date>",
// Parse the unsigned extended year as a UTC field, without JS string-date inference.
"<key>Far</key><date>11507-08-17T05:20:00Z</date>",
@@ -261,6 +262,7 @@ describe.skipIf(process.platform !== "darwin")(
value: {
CFBundleIdentifier: "com.example.types",
Blob: { $plist_type: "data", base64: "AAEC" },
Separators: "a\u0085b\u2028c\u2029d",
Built: {
$plist_type: "date",
iso8601: "2020-01-01T00:00:00.000Z",
@@ -113,6 +113,20 @@ it.for<PlistSourceCase>([
expected: "binary",
xmlFallback: true,
},
{
description: "Apple binary property list\n",
expected: "binary",
xmlFallback: true,
convertedXml:
"<plist><dict><key>nel</key><string>a\u0085b</string><key>ls</key><string>a\u2028b</string><key>ps</key><string>a\u2029b</string><key>lf</key><string>a\nb</string><key>payload</key><data>AA==</data></dict></plist>",
expectedValue: {
nel: "a\u0085b",
ls: "a\u2028b",
ps: "a\u2029b",
lf: "a\nb",
payload: { $plist_type: "data", base64: "AA==" },
},
},
])(
"keeps source format $expected for $description (XML fallback: $xmlFallback)",
async (