diff --git a/src/domain/propertyListKeys.test.ts b/src/domain/propertyListKeys.test.ts
index f29fc1c305..cf771ced15 100644
--- a/src/domain/propertyListKeys.test.ts
+++ b/src/domain/propertyListKeys.test.ts
@@ -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(
+ `${separator}kept${separator}keya${separator}b${separator}__proto__omitted`,
+ ),
+ );
+ expect(parsed).toEqual({
+ value: { [`kept${separator}key`]: `a${separator}b` },
+ omittedPrototypeKeys: 1,
+ });
+ },
+);
diff --git a/src/domain/propertyListXml.test.ts b/src/domain/propertyListXml.test.ts
index b349cec112..4de709e4be 100644
--- a/src/domain/propertyListXml.test.ts
+++ b/src/domain/propertyListXml.test.ts
@@ -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(`${value}`).value,
+ ).toBe(value);
+});
+
it.each(["\u0085", "\u2028", "\u2029"])(
"does not normalize unsupported leading whitespace %j into an observed scalar",
(prefix) => {
diff --git a/src/domain/propertyListXmlSource.ts b/src/domain/propertyListXmlSource.ts
index 26e29ab92c..cc1f13575d 100644
--- a/src/domain/propertyListXmlSource.ts
+++ b/src/domain/propertyListXmlSource.ts
@@ -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
diff --git a/src/domain/xmlDocument.test.ts b/src/domain/xmlDocument.test.ts
index c8ad068ab6..469bc31600 100644
--- a/src/domain/xmlDocument.test.ts
+++ b/src/domain/xmlDocument.test.ts
@@ -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(
+ 'a\r\u0085b\r\u2028c\r\n
d\u2029',
+ { 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",
+ );
+});
diff --git a/src/domain/xmlDocument.ts b/src/domain/xmlDocument.ts
index af4f636cc1..3022f4c6cd 100644
--- a/src/domain/xmlDocument.ts
+++ b/src/domain/xmlDocument.ts
@@ -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"),
);
diff --git a/tests/boundary/cli/nativePlistCli.test.ts b/tests/boundary/cli/nativePlistCli.test.ts
index 3c6f69dff6..5e0b6f3bbe 100644
--- a/tests/boundary/cli/nativePlistCli.test.ts
+++ b/tests/boundary/cli/nativePlistCli.test.ts
@@ -228,6 +228,7 @@ describe.skipIf(process.platform !== "darwin")(
"CFBundleExecutableApp",
"CFBundleIdentifiercom.example.types",
"BlobAAEC",
+ "Separatorsa\u0085b\u2028c\u2029d",
"Built2020-01-01T00:00:00Z",
// Parse the unsigned extended year as a UTC field, without JS string-date inference.
"Far11507-08-17T05:20:00Z",
@@ -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",
diff --git a/tests/boundary/providers/native/plistSourceFormat.test.ts b/tests/boundary/providers/native/plistSourceFormat.test.ts
index ab267b5768..0cbd27fa06 100644
--- a/tests/boundary/providers/native/plistSourceFormat.test.ts
+++ b/tests/boundary/providers/native/plistSourceFormat.test.ts
@@ -113,6 +113,20 @@ it.for([
expected: "binary",
xmlFallback: true,
},
+ {
+ description: "Apple binary property list\n",
+ expected: "binary",
+ xmlFallback: true,
+ convertedXml:
+ "nela\u0085blsa\u2028bpsa\u2029blfa\nbpayloadAA==",
+ 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 (