diff --git a/src/builder/XMLBuilderCBImpl.ts b/src/builder/XMLBuilderCBImpl.ts index 1e1b010..31733d5 100644 --- a/src/builder/XMLBuilderCBImpl.ts +++ b/src/builder/XMLBuilderCBImpl.ts @@ -5,13 +5,14 @@ import { } from "../interfaces" import { applyDefaults, isString, isObject } from "@oozcitak/util" import { fragment, create } from "./BuilderFunctions" +import { sanitizeInput } from "./dom" import { xml_isName, xml_isLegalChar, xml_isPubidChar } from "@oozcitak/dom/lib/algorithm" import { namespace as infraNamespace } from "@oozcitak/infra" import { NamespacePrefixMap } from "@oozcitak/dom/lib/serializer/NamespacePrefixMap" import { - Comment, Text, ProcessingInstruction, CDATASection, DocumentType, Element, Node + Comment, Text, ProcessingInstruction, DocumentType, Element, Node } from "@oozcitak/dom/lib/dom/interfaces" import { LocalNameSet } from "@oozcitak/dom/lib/serializer/LocalNameSet" import { Guard } from "@oozcitak/dom/lib/util" @@ -266,15 +267,28 @@ export class XMLBuilderCBImpl extends EventEmitter implements XMLBuilderCB { dat(content: string): this { this._serializeOpenTag(true) - let node: CDATASection + if (content === null || content === undefined) { + if (this._options.keepNullNodes) { + content = "" + } else { + return this + } + } + + let data: string try { - node = fragment(this._builderOptions).dat(content).first().node as CDATASection + data = sanitizeInput(content, this._options.invalidCharReplacement) } catch (err) { this.emit("error", err) return this } - this._push(this._writer.cdata(node.data)) + if (this._options.wellFormed && !xml_isLegalChar(data)) { + this.emit("error", new Error("CDATA contains invalid characters (well-formed required).")) + return this + } + + this._push(this._writer.cdata(data)) return this } diff --git a/src/builder/XMLBuilderImpl.ts b/src/builder/XMLBuilderImpl.ts index ef46583..88733ac 100644 --- a/src/builder/XMLBuilderImpl.ts +++ b/src/builder/XMLBuilderImpl.ts @@ -14,7 +14,7 @@ import { Guard } from "@oozcitak/dom/lib/util" import { namespace_extractQName, tree_index, create_element } from "@oozcitak/dom/lib/algorithm" -import { sanitizeInput } from "./dom" +import { sanitizeInput, splitCDATA } from "./dom" import { namespace as infraNamespace } from "@oozcitak/infra" import { ObjectReader, JSONReader, XMLReader, YAMLReader } from "../readers" @@ -297,9 +297,11 @@ export class XMLBuilderImpl implements XMLBuilder { } } - const child = this._doc.createCDATASection( - sanitizeInput(content, this._options.invalidCharReplacement)) - this.node.appendChild(child) + const data = sanitizeInput(content, this._options.invalidCharReplacement) + for (const section of splitCDATA(data)) { + const child = this._doc.createCDATASection(section) + this.node.appendChild(child) + } return this } diff --git a/src/builder/dom.ts b/src/builder/dom.ts index 16f023f..a8cad68 100644 --- a/src/builder/dom.ts +++ b/src/builder/dom.ts @@ -69,3 +69,16 @@ export function sanitizeInput(str: any, return result } } + +/** + * Splits CDATA content at its forbidden closing sequence while preserving the + * original text across adjacent CDATA sections. + * + * @param str - CDATA content + */ +export function splitCDATA(str: string): string[] { + const parts = str.split("]]>") + return parts.map((part, index) => + (index === 0 ? "" : ">") + part + + (index === parts.length - 1 ? "" : "]]")) +} diff --git a/src/writers/XMLCBWriter.ts b/src/writers/XMLCBWriter.ts index 5087b5e..20349a9 100644 --- a/src/writers/XMLCBWriter.ts +++ b/src/writers/XMLCBWriter.ts @@ -67,7 +67,8 @@ export class XMLCBWriter extends BaseCBWriter { } /** @inheritdoc */ cdata(data: string): string { - return this._beginLine() + "" + return this._beginLine() + "/g, "]]]]>") + "]]>" } /** @inheritdoc */ openTagBegin(name: string): string { diff --git a/src/writers/XMLWriter.ts b/src/writers/XMLWriter.ts index 6109c96..d9ec3af 100644 --- a/src/writers/XMLWriter.ts +++ b/src/writers/XMLWriter.ts @@ -1,6 +1,6 @@ import { XMLWriterOptions, XMLBuilderOptions } from "../interfaces" import { applyDefaults } from "@oozcitak/util" -import { Node, NodeType } from "@oozcitak/dom/lib/dom/interfaces" +import { CDATASection, Node, NodeType } from "@oozcitak/dom/lib/dom/interfaces" import { BaseWriter } from "./BaseWriter" import { Guard } from "@oozcitak/dom/lib/util" @@ -112,14 +112,23 @@ export class XMLWriter extends BaseWriter { let childNode = this.currentNode.firstChild let cdataCount = 0 let textCount = 0 + let splitCDATASequence = true + let previousCData: CDATASection | undefined while (childNode) { if (Guard.isExclusiveTextNode(childNode)) { textCount++ + splitCDATASequence = false } else if (Guard.isCDATASectionNode(childNode)) { + if (previousCData !== undefined && + (!previousCData.data.endsWith("]]") || !childNode.data.startsWith(">"))) { + splitCDATASequence = false + } + previousCData = childNode cdataCount++ } else { textOnlyNode = false emptyNode = false + splitCDATASequence = false break } @@ -129,7 +138,12 @@ export class XMLWriter extends BaseWriter { childNode = childNode.nextSibling } - this._refs.suppressPretty = !this._writerOptions.indentTextOnlyNodes && textOnlyNode && ((cdataCount <= 1 && textCount === 0) || cdataCount === 0) + // Adjacent CDATA sections created by splitCDATA must remain adjacent so + // pretty-printing does not change their combined text content. + const escapedCDATA = cdataCount > 1 && textCount === 0 && splitCDATASequence + this._refs.suppressPretty = escapedCDATA || + (!this._writerOptions.indentTextOnlyNodes && textOnlyNode && + ((cdataCount <= 1 && textCount === 0) || cdataCount === 0)) this._refs.emptyNode = emptyNode } @@ -256,4 +270,4 @@ type StringWriterRefs = { * The string representing the serialized document. */ markup: string -} \ No newline at end of file +} diff --git a/test/callback/wellformed.test.ts b/test/callback/wellformed.test.ts index 94522a4..0a48fab 100644 --- a/test/callback/wellformed.test.ts +++ b/test/callback/wellformed.test.ts @@ -178,10 +178,10 @@ $$.suite('well-formed checks', () => { await $$.expectCBError(xmlStream, () => xmlStream.ins('name', '\0')) }) - $$.test('invalid cdata node', async () => { + $$.test('escaped cdata terminator', async () => { const xmlStream = $$.createCB({ wellFormed: true }) - xmlStream.ele('ns', 'root') - await $$.expectCBError(xmlStream, () => xmlStream.dat(']]>')) + xmlStream.ele('ns', 'root').dat(']]>').end() + await $$.expectCBResult(xmlStream, ']]>') }) $$.test('same attribute', async () => { diff --git a/test/dom/sanitizeInput.test.ts b/test/dom/sanitizeInput.test.ts index 15e8d76..9624221 100644 --- a/test/dom/sanitizeInput.test.ts +++ b/test/dom/sanitizeInput.test.ts @@ -1,5 +1,5 @@ import $$ from '../TestHelpers' -import { sanitizeInput } from '../../src/builder/dom' +import { sanitizeInput, splitCDATA } from '../../src/builder/dom' $$.suite('sanitizeInput', () => { @@ -32,3 +32,15 @@ $$.suite('sanitizeInput', () => { }) }) + +$$.suite('splitCDATA', () => { + $$.test('preserves text across forbidden closing sequences', () => { + $$.deepEqual(splitCDATA('before]]>after'), ['before]]', '>after']) + $$.deepEqual(splitCDATA('before]]>middle]]>after'), [ + 'before]]', + '>middle]]', + '>after' + ]) + $$.deepEqual(splitCDATA('unchanged'), ['unchanged']) + }) +}) diff --git a/test/issues/issue-230.test.ts b/test/issues/issue-230.test.ts new file mode 100644 index 0000000..7a986d5 --- /dev/null +++ b/test/issues/issue-230.test.ts @@ -0,0 +1,41 @@ +import $$ from '../TestHelpers' + +$$.suite('Replicate issue', () => { + const content = 'before]]>after' + const expected = 'after]]>' + + $$.test('#230 - escapes CDATA terminators', () => { + const doc = $$.create() + .ele('root') + .dat(content) + .doc() + + $$.deepEqual(doc.root().node.textContent, content) + $$.deepEqual(doc.end({ headless: true }), expected) + $$.deepEqual(doc.end({ headless: true, prettyPrint: true }), expected) + $$.deepEqual(doc.end({ + headless: true, + prettyPrint: true, + indentTextOnlyNodes: true + }), expected) + }) + + $$.test('#230 - escapes CDATA terminators with the callback API', async () => { + const xmlStream = $$.createCB() + .ele('root') + .dat(content) + .end() + + await $$.expectCBResult(xmlStream, expected) + }) + + $$.test('#230 - escapes CDATA terminators from an object', () => { + const xml = $$.create( + { convert: { text: '#text', cdata: '#cdata' } }, + { root: { '#cdata': content } } + ) + .end({ headless: true, prettyPrint: true }) + + $$.deepEqual(xml, expected) + }) +})