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 (