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:
1 parent
335c042d16
commit
00266efe13
7 files changed
+61
-14
No files matched your search
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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",
|
||||
);
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user