diff --git a/eksml/BENCHMARKS.md b/eksml/BENCHMARKS.md index 3b1433c..dff6b19 100644 --- a/eksml/BENCHMARKS.md +++ b/eksml/BENCHMARKS.md @@ -1,6 +1,6 @@ # Benchmarks -All benchmarks run via [Vitest bench](https://vitest.dev/guide/features.html#benchmarking) on Node.js. Fixtures range from ~100 B to ~30 KB of real-world XML (RSS feeds, Atom feeds, SOAP envelopes, Maven POMs, XMLTV EPGs). Full benchmark source is in [`bench/`](./bench/). +All benchmarks run via [Vitest bench](https://vitest.dev/guide/features.html#benchmarking) on Node.js. Fixtures range from ~100 B to ~30 KB of real-world XML (RSS feeds, Atom feeds, SOAP envelopes, Maven POMs, XMLTV EPGs), plus a ~10 KB synthetic attribute-heavy stress document. Full benchmark source is in [`bench/`](./bench/). Run them yourself: @@ -21,64 +21,71 @@ Parse an XML string into a tree structure. Atom (~3 KB) POM (~5 KB) EPG (~9 KB) + attr-heavy (~10 KB) Eksml - 1,367,666 op/s - 85,865 op/s - 64,349 op/s - 35,517 op/s - 13,759 op/s - 17,521 op/s + 1,221,950 op/s + 87,714 op/s + 66,135 op/s + 35,922 op/s + 13,943 op/s + 16,917 op/s + 14,420 op/s tXml - 933,149 op/s + 1,007,229 op/s -- - 48,483 op/s - 27,743 op/s - 10,431 op/s - 13,589 op/s + 45,389 op/s + 27,982 op/s + 9,383 op/s + 13,520 op/s + 11,369 op/s htmlparser2 - 504,972 op/s - 24,360 op/s - 17,565 op/s - 9,660 op/s - 3,529 op/s - 4,902 op/s + 523,318 op/s + 25,204 op/s + 17,709 op/s + 10,151 op/s + 3,590 op/s + 4,942 op/s + 3,886 op/s fast-xml-parser - 166,395 op/s - 9,365 op/s - 7,477 op/s - 3,680 op/s - 1,339 op/s - 2,342 op/s + 171,272 op/s + 9,932 op/s + 7,520 op/s + 3,745 op/s + 1,360 op/s + 2,358 op/s + 1,282 op/s xml2js - 135,079 op/s - 9,673 op/s - 6,895 op/s - 3,912 op/s - 1,525 op/s - 2,068 op/s + 133,748 op/s + 9,809 op/s + 6,727 op/s + 3,951 op/s + 1,563 op/s + 2,080 op/s + 1,510 op/s @xmldom/xmldom - 93,167 op/s - 6,088 op/s - 4,924 op/s - 2,471 op/s - 853 op/s - 1,344 op/s + 89,126 op/s + 5,640 op/s + 5,112 op/s + 2,604 op/s + 895 op/s + 1,414 op/s + 702 op/s -Eksml is **1.3-1.5x faster than tXml**, **2.7-4x faster than htmlparser2**, **7-10x faster than fast-xml-parser**, and **13-16x faster than xmldom**. +Eksml is **1.2-1.5x faster than tXml**, **2.3-3.9x faster than htmlparser2**, **7-11x faster than fast-xml-parser**, and **12-21x faster than xmldom**. > [!note] > tXml crashed on the RSS fixture @@ -94,59 +101,67 @@ Chunked streaming parse where each parser tokenizes SAX events and builds a full EPG (~9 KB) POM (~5 KB) EPG 64 B stress + attr-heavy (~10 KB) Eksml (SAX) - 83,958 op/s - 17,182 op/s - 14,213 op/s - 13,714 op/s + 95,408 op/s + 17,945 op/s + 13,932 op/s + 14,932 op/s + 13,431 op/s easysax - 65,233 op/s - 14,352 op/s - 10,898 op/s - 11,075 op/s + 66,897 op/s + 14,591 op/s + 11,235 op/s + 10,517 op/s + 10,604 op/s Eksml (XmlParseStream) - 30,238 op/s - 8,432 op/s - 7,916 op/s - 4,679 op/s + 30,549 op/s + 9,794 op/s + 8,947 op/s + 4,457 op/s + 8,690 op/s htmlparser2 - 29,467 op/s - 7,269 op/s - 5,977 op/s - 6,703 op/s + 30,083 op/s + 7,384 op/s + 5,461 op/s + 6,912 op/s + 6,877 op/s saxes - 28,439 op/s - 6,233 op/s - 6,621 op/s - 5,665 op/s + 26,185 op/s + 6,602 op/s + 6,780 op/s + 5,821 op/s + 5,634 op/s sax - 14,498 op/s - 3,757 op/s - 2,918 op/s - 3,724 op/s + 14,988 op/s + 3,561 op/s + 3,044 op/s + 3,806 op/s + 2,922 op/s @tuananh/sax-parser - 13,598 op/s - 4,283 op/s - 3,309 op/s - 2,444 op/s + 14,084 op/s + 3,918 op/s + 3,249 op/s + 2,394 op/s + 2,410 op/s -Eksml's SAX parser is **1.2-1.3x faster than easysax**, **2-3x faster than htmlparser2/saxes**, and **3.5-6x faster than sax**. +Eksml's SAX parser is **1.2-1.4x faster than easysax**, **2-3.6x faster than htmlparser2/saxes**, and **3.9-6.4x faster than sax**. > [!note] > @tuananh/sax-parser is a native C++ addon; every `write()` crosses the JS↔C++ boundary, which dominates in chunked streaming. @@ -162,52 +177,59 @@ Pure scanner throughput with no downstream work -- isolates the tokenizer's raw EPG (~9 KB) POM (~5 KB) EPG 64 B stress + attr-heavy (~10 KB) Eksml (SAX) - 105,293 op/s - 20,197 op/s - 16,401 op/s - 16,656 op/s + 104,122 op/s + 20,970 op/s + 19,927 op/s + 17,063 op/s + 15,605 op/s easysax - 66,841 op/s - 15,552 op/s - 12,501 op/s - 12,026 op/s + 73,389 op/s + 15,678 op/s + 11,899 op/s + 11,982 op/s + 11,933 op/s saxes - 31,800 op/s - 9,408 op/s - 8,328 op/s - 8,885 op/s + 35,355 op/s + 9,492 op/s + 7,290 op/s + 8,870 op/s + 8,562 op/s htmlparser2 - 30,724 op/s - 7,404 op/s - 5,771 op/s - 7,058 op/s + 30,520 op/s + 7,647 op/s + 6,334 op/s + 6,475 op/s + 7,145 op/s sax - 14,970 op/s - 3,936 op/s - 3,144 op/s - 3,557 op/s + 15,307 op/s + 3,679 op/s + 3,184 op/s + 3,928 op/s + 3,315 op/s @tuananh/sax-parser - 14,904 op/s - 4,513 op/s - 3,463 op/s - 2,307 op/s + 12,907 op/s + 4,197 op/s + 3,217 op/s + 2,388 op/s + 2,608 op/s -Eksml's tokenizer is **1.3-1.6x faster than easysax**, **1.9-3.4x faster than saxes/htmlparser2**, and **4.5-7x faster than sax**. +Eksml's tokenizer is **1.3-1.7x faster than easysax**, **1.8-3.4x faster than saxes/htmlparser2**, and **4.3-6.8x faster than sax**. > [!note] > easysax parses attributes lazily (`startNode` receives a `getAttr()` thunk); the benchmark invokes it so every parser materializes attributes, the work all other parsers do unconditionally for their open-tag events. @@ -227,85 +249,86 @@ Serialize a pre-parsed in-memory tree back to XML. EPG (~30 KB) - tXml - 2,485,611 op/s - -- - 225,162 op/s - 96,822 op/s - 48,394 op/s - 43,455 op/s + Eksml (validate: false) + 2,956,198 op/s + 398,598 op/s + 242,411 op/s + 105,006 op/s + 67,255 op/s + 50,517 op/s - htmlparser2 - 2,027,682 op/s - 100,516 op/s - 73,469 op/s - 30,964 op/s - 14,839 op/s - 15,867 op/s + tXml + 2,594,809 op/s + -- + 205,646 op/s + 84,696 op/s + 44,360 op/s + 39,346 op/s - Eksml (validate: false) - 1,936,294 op/s - 357,848 op/s - 204,989 op/s - 94,131 op/s - 64,960 op/s - 36,462 op/s + Eksml + 2,290,597 op/s + 319,812 op/s + 246,313 op/s + 101,814 op/s + 53,384 op/s + 36,384 op/s - Eksml - 1,749,947 op/s - 291,007 op/s - 176,028 op/s - 78,439 op/s - 53,619 op/s - 32,253 op/s + htmlparser2 + 2,144,522 op/s + 103,346 op/s + 75,736 op/s + 31,101 op/s + 14,203 op/s + 17,500 op/s @xmldom/xmldom - 1,117,265 op/s - 45,503 op/s - 39,805 op/s - 15,015 op/s - 6,973 op/s - 10,019 op/s + 1,133,615 op/s + 42,888 op/s + 40,083 op/s + 14,805 op/s + 7,392 op/s + 10,422 op/s fast-xml-parser - 347,964 op/s - 23,913 op/s - 28,453 op/s - 11,452 op/s - 4,610 op/s - 3,609 op/s + 333,373 op/s + 24,028 op/s + 29,116 op/s + 12,108 op/s + 4,785 op/s + 3,645 op/s xml2js - 239,685 op/s - 15,809 op/s - 19,398 op/s - 8,295 op/s - 3,954 op/s - 3,898 op/s + 239,138 op/s + 17,067 op/s + 20,056 op/s + 8,778 op/s + 4,332 op/s + 4,174 op/s -Eksml wins POM and RSS (where tXml crashes) and, with `validate: false`, ties tXml on Atom (0.97x); tXml keeps the lead on the small, SOAP, and EPG fixtures. Unlike tXml's stringify, Eksml's writer validates tag/attribute names by default, guards against circular references, and escapes mixed-quote attribute values — `validate: false` skips only the name validation. Eksml is **2-6x faster than @xmldom/xmldom** and **4-13x faster than fast-xml-parser/xml2js** at serialization on non-trivial documents. +With `validate: false`, Eksml leads every fixture (tXml crashes on RSS). With validation on (the default), Eksml still wins RSS, SOAP, Atom, and POM; tXml edges out the small and EPG fixtures. Unlike tXml's stringify, Eksml's writer validates tag/attribute names by default, guards against circular references, and escapes mixed-quote attribute values — `validate: false` skips only the name validation. Eksml is **2-7x faster than @xmldom/xmldom** and **7-19x faster than fast-xml-parser/xml2js** at serialization on non-trivial documents. > [!note] > tXml crashed on the RSS fixture ## Fixtures -| Fixture | Size | Description | -| ------- | -------- | ------------------------------------ | -| small | ~100 B | Minimal XML element | -| RSS | ~3 KB | Real-world RSS feed (`rss-feed.xml`) | -| SOAP | ~2-3 KB | SOAP envelope (`soap-envelope.xml`) | -| Atom | ~3-6 KB | Atom feed (`atom-feed.xml`) | -| POM | ~5-8 KB | Maven POM (`pom.xml`) | -| EPG | ~9-30 KB | XMLTV EPG listing (`xmltv-epg.xml`) | +| Fixture | Size | Description | +| ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| small | ~100 B | Minimal XML element | +| RSS | ~3 KB | Real-world RSS feed (`rss-feed.xml`) | +| SOAP | ~2-3 KB | SOAP envelope (`soap-envelope.xml`) | +| Atom | ~3-6 KB | Atom feed (`atom-feed.xml`) | +| POM | ~5-8 KB | Maven POM (`pom.xml`) | +| EPG | ~9-30 KB | XMLTV EPG listing (`xmltv-epg.xml`) | +| attr-heavy | ~10 KB | Synthetic stress doc mirroring @tuananh/sax-parser's benchmark: 158 tiny elements, one attribute each (`attr-heavy-synthetic.xml`) | Size varies between tables because parsing benchmarks measure input size while serialization benchmarks measure output size (which includes indentation and formatting). diff --git a/eksml/README.md b/eksml/README.md index 10660af..043a904 100644 --- a/eksml/README.md +++ b/eksml/README.md @@ -531,12 +531,12 @@ import { ## Benchmarks -Eksml is consistently the fastest at parsing, streaming, and tokenization, and trades the serialization lead with tXml. Benchmarks run via [Vitest bench](https://vitest.dev/guide/features.html#benchmarking) against real-world XML fixtures from ~100 B to ~30 KB. +Eksml is consistently the fastest at parsing, streaming, and tokenization, and leads serialization with `validate: false`. Benchmarks run via [Vitest bench](https://vitest.dev/guide/features.html#benchmarking) against real-world XML fixtures from ~100 B to ~30 KB, plus a synthetic attribute-heavy stress document. -- **DOM parsing**: 1.3-1.5x faster than tXml, 2.7-4x faster than htmlparser2, 7-16x faster than fast-xml-parser/xml2js/xmldom -- **SAX streaming**: 1.2-1.3x faster than easysax, 2-3x faster than htmlparser2/saxes, 3.5-6x faster than sax -- **Raw tokenization**: 1.3-1.6x faster than easysax, 1.9-3.4x faster than saxes/htmlparser2, 4.5-7x faster than sax -- **Serialization**: Beats tXml on POM/RSS and ties it on Atom with `validate: false`; 2-6x faster than xmldom and 4-13x faster than fast-xml-parser/xml2js +- **DOM parsing**: 1.2-1.5x faster than tXml, 2.3-3.9x faster than htmlparser2, 7-21x faster than fast-xml-parser/xml2js/xmldom +- **SAX streaming**: 1.2-1.4x faster than easysax, 2-3.6x faster than htmlparser2/saxes, 3.9-6.4x faster than sax +- **Raw tokenization**: 1.3-1.7x faster than easysax, 1.8-3.4x faster than saxes/htmlparser2, 4.3-6.8x faster than sax +- **Serialization**: Fastest on every fixture with `validate: false`; with validation on (the default), wins RSS/SOAP/Atom/POM while tXml edges small/EPG; 2-7x faster than xmldom and 7-19x faster than fast-xml-parser/xml2js Full results with per-fixture op/s tables: **[BENCHMARKS.md](https://github.com/evoactivity/eksml/blob/main/eksml/BENCHMARKS.md)** diff --git a/eksml/bench/parse.bench.ts b/eksml/bench/parse.bench.ts index 193cdfc..9743fd2 100644 --- a/eksml/bench/parse.bench.ts +++ b/eksml/bench/parse.bench.ts @@ -36,6 +36,10 @@ const atomFeed = fixture('atom-feed.xml'); const xhtmlPage = fixture('xhtml-page.xml'); const pomXml = fixture('pom.xml'); const xmltvEpg = fixture('xmltv-epg.xml'); +// Mirrors the ~10 KB synthetic document from @tuananh/sax-parser's benchmark: +// 158 tiny elements, one attribute each. Attribute-dense worst +// case, complements the attribute-light real fixtures above. +const attrHeavy = fixture('attr-heavy-synthetic.xml'); // Pre-instantiate reusable parser instances const fxp = new XMLParser(); @@ -239,3 +243,32 @@ describe('XMLTV EPG (~9 KB)', () => { txmlParse(xmltvEpg); }); }); + +// --------------------------------------------------------------------------- +// Attr-heavy synthetic (sax-parser bench mirror) — ~10 KB +// --------------------------------------------------------------------------- +describe('attr-heavy synthetic (~10 KB)', () => { + bench('eksml', () => { + parse(attrHeavy); + }); + + bench('fast-xml-parser', () => { + fxp.parse(attrHeavy); + }); + + bench('xml2js', async () => { + await parseStringPromise(attrHeavy); + }); + + bench('@xmldom/xmldom', () => { + domParser.parseFromString(attrHeavy, 'text/xml'); + }); + + bench('htmlparser2', () => { + parseDocument(attrHeavy); + }); + + bench('txml', () => { + txmlParse(attrHeavy); + }); +}); diff --git a/eksml/bench/stream.bench.ts b/eksml/bench/stream.bench.ts index 8f2c9a9..1df6b97 100644 --- a/eksml/bench/stream.bench.ts +++ b/eksml/bench/stream.bench.ts @@ -50,6 +50,10 @@ const fixture = (name: string) => const rssFeed = fixture('rss-feed.xml'); const xmltvEpg = fixture('xmltv-epg.xml'); const pomXml = fixture('pom.xml'); +// Mirrors the ~10 KB synthetic document from @tuananh/sax-parser's benchmark: +// 158 tiny elements, one attribute each. Attribute-dense worst +// case, complements the attribute-light real fixtures above. +const attrHeavy = fixture('attr-heavy-synthetic.xml'); // --------------------------------------------------------------------------- // Shared tree node type (equivalent to eksml's TNode) @@ -76,6 +80,7 @@ const rssChunks256 = chunkString(rssFeed, 256); const xmltvChunks256 = chunkString(xmltvEpg, 256); const pomChunks256 = chunkString(pomXml, 256); const xmltvChunks64 = chunkString(xmltvEpg, 64); +const attrHeavyChunks256 = chunkString(attrHeavy, 256); // Shared tree-builder state, reset before each run. let roots: (Node | string)[] = []; @@ -468,3 +473,40 @@ describe('stream: XMLTV EPG (64 B chunks — stress)', () => { fxpSync(xmltvEpg); }); }); + +// --------------------------------------------------------------------------- +// Attr-heavy synthetic (sax-parser bench mirror) — chunked streaming +// --------------------------------------------------------------------------- +describe('stream: attr-heavy synthetic (256 B chunks)', () => { + bench('eksml (XmlParseStream)', async () => { + await eksmlStream(attrHeavyChunks256); + }); + + bench('eksml (SAX)', () => { + eksmlSaxEngine(attrHeavyChunks256); + }); + + bench('sax', () => { + saxStream(attrHeavyChunks256); + }); + + bench('saxes', () => { + saxesStream(attrHeavyChunks256); + }); + + bench('htmlparser2', () => { + htmlparser2Stream(attrHeavyChunks256); + }); + + bench('@tuananh/sax-parser', () => { + tuananhStream(attrHeavyChunks256); + }); + + bench('easysax', () => { + easysaxStream(attrHeavyChunks256); + }); + + bench('fast-xml-parser (sync, no streaming)', () => { + fxpSync(attrHeavy); + }); +}); diff --git a/eksml/bench/tokenize.bench.ts b/eksml/bench/tokenize.bench.ts index 7c9d183..a790fb8 100644 --- a/eksml/bench/tokenize.bench.ts +++ b/eksml/bench/tokenize.bench.ts @@ -41,6 +41,11 @@ const fixture = (name: string) => const rssFeed = fixture('rss-feed.xml'); const xmltvEpg = fixture('xmltv-epg.xml'); const pomXml = fixture('pom.xml'); +// Mirrors the ~10 KB synthetic document from @tuananh/sax-parser's benchmark: +// 158 tiny elements, one attribute each, ~8 bytes per SAX +// event. Attribute-dense worst case, complements the attribute-light real +// fixtures above. +const attrHeavy = fixture('attr-heavy-synthetic.xml'); // --------------------------------------------------------------------------- // Helpers @@ -58,6 +63,7 @@ const rssChunks256 = chunkString(rssFeed, 256); const xmltvChunks256 = chunkString(xmltvEpg, 256); const pomChunks256 = chunkString(pomXml, 256); const xmltvChunks64 = chunkString(xmltvEpg, 64); +const attrHeavyChunks256 = chunkString(attrHeavy, 256); // No-op function used as callback const noop = () => {}; @@ -292,3 +298,32 @@ describe('tokenize: XMLTV EPG (64 B chunks — stress)', () => { easysaxStream(xmltvChunks64); }); }); + +// --------------------------------------------------------------------------- +// Attr-heavy synthetic (sax-parser bench mirror) — 256 B chunks +// --------------------------------------------------------------------------- +describe('tokenize: attr-heavy synthetic (256 B chunks)', () => { + bench('eksml', () => { + eksmlSaxEngine(attrHeavyChunks256); + }); + + bench('sax', () => { + saxStream(attrHeavyChunks256); + }); + + bench('saxes', () => { + saxesStream(attrHeavyChunks256); + }); + + bench('htmlparser2', () => { + htmlparser2Stream(attrHeavyChunks256); + }); + + bench('@tuananh/sax-parser', () => { + tuananhStream(attrHeavyChunks256); + }); + + bench('easysax', () => { + easysaxStream(attrHeavyChunks256); + }); +}); diff --git a/eksml/src/converters/fromLossy.ts b/eksml/src/converters/fromLossy.ts index 13d8851..dbecf63 100644 --- a/eksml/src/converters/fromLossy.ts +++ b/eksml/src/converters/fromLossy.ts @@ -33,6 +33,7 @@ import type { LossyObject, LossyMixedEntry, } from '#src/converters/lossy.ts'; +import { setOwnProperty } from '#src/utilities/setOwnProperty.ts'; // @generated:char-codes:begin const DOLLAR = 36; // $ // @generated:char-codes:end @@ -157,12 +158,15 @@ function convertElement( } else if (key.charCodeAt(0) === DOLLAR) { // Attribute — strip the $ prefix if (attributes === null) { - attributes = Object.create(null) as Record; + attributes = {}; } const attributeName = key.substring(1); const attributeValue = objectValue[key]; - attributes[attributeName] = - attributeValue === null ? null : String(attributeValue); + setOwnProperty( + attributes, + attributeName, + attributeValue === null ? null : String(attributeValue), + ); } else { // Element child(ren) const childValue = objectValue[key]!; diff --git a/eksml/src/converters/lossy.ts b/eksml/src/converters/lossy.ts index 4368847..f01ed10 100644 --- a/eksml/src/converters/lossy.ts +++ b/eksml/src/converters/lossy.ts @@ -52,6 +52,7 @@ */ import { parse, type TNode, type ParseOptions } from '#src/parser.ts'; +import { setOwnProperty } from '#src/utilities/setOwnProperty.ts'; // @generated:char-codes:begin const DOLLAR = 36; // $ // @generated:char-codes:end @@ -98,8 +99,9 @@ function convertNode(node: TNode): LossyValue { } // --- Build object with attributes --- - // Use null-prototype object to prevent __proto__ / constructor pollution - const elementObject: LossyObject = Object.create(null); + // Plain object for V8 hidden-class sharing; dangerous keys (__proto__) + // are guarded by setOwnProperty and own-key checks use Object.hasOwn. + const elementObject: LossyObject = {}; if (hasAttributes) { for (const key in attributes) { @@ -165,8 +167,10 @@ function convertNode(node: TNode): LossyValue { if (mixed !== null) { mixed.push({ [tag]: convertedValue }); } else { - if (!(tag in elementObject)) { - elementObject[tag] = convertedValue; + // Object.hasOwn (not `in`): on plain objects `in` sees inherited + // keys like `constructor`, which would corrupt sibling grouping. + if (!Object.hasOwn(elementObject, tag)) { + setOwnProperty(elementObject, tag, convertedValue); } else if (!Array.isArray(elementObject[tag])) { elementObject[tag] = [ elementObject[tag] as LossyValue, diff --git a/eksml/src/parser.ts b/eksml/src/parser.ts index b113941..5f22913 100644 --- a/eksml/src/parser.ts +++ b/eksml/src/parser.ts @@ -1,5 +1,6 @@ import { decodeXML, decodeHTML } from 'entities'; import { escapeRegExp } from '#src/utilities/escapeRegExp.ts'; +import { setOwnProperty } from '#src/utilities/setOwnProperty.ts'; import { filter } from '#src/utilities/filter.ts'; import { HTML_VOID_ELEMENTS, @@ -344,9 +345,8 @@ export function parse( break; } const token = S.substring(pos + 1, closePos); - if (declAttributes === null) - declAttributes = Object.create(null); - declAttributes![token] = null; + if (declAttributes === null) declAttributes = {}; + setOwnProperty(declAttributes!, token, null); pos = closePos + 1; continue; } @@ -358,8 +358,8 @@ export function parse( pos++; } const token = S.substring(tokenStart, pos); - if (declAttributes === null) declAttributes = Object.create(null); - declAttributes![token] = null; + if (declAttributes === null) declAttributes = {}; + setOwnProperty(declAttributes!, token, null); } // Skip internal DTD subset ([...]) if present @@ -460,8 +460,8 @@ export function parse( value = null; pos--; } - if (attributes === null) attributes = Object.create(null); - attributes![name] = value; + if (attributes === null) attributes = {}; + setOwnProperty(attributes!, name, value); } pos++; } @@ -636,8 +636,8 @@ export function parse( pos--; } // Allocate attributes object lazily on first attribute - if (attributes === null) attributes = Object.create(null); - attributes![name] = value; + if (attributes === null) attributes = {}; + setOwnProperty(attributes!, name, value); } pos++; } diff --git a/eksml/src/saxEngine.ts b/eksml/src/saxEngine.ts index a1ab10f..e812177 100644 --- a/eksml/src/saxEngine.ts +++ b/eksml/src/saxEngine.ts @@ -10,6 +10,8 @@ * to extract tokens via a single substring() rather than per-character +=. */ +import { setOwnProperty } from '#src/utilities/setOwnProperty.ts'; + // @generated:char-codes:begin const GT = 62; // > const SLASH = 47; // / @@ -147,11 +149,12 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { let tagName = ''; let attributeName = ''; let attributeValue = ''; - // Null-prototype object to prevent prototype pollution via attribute names - // (e.g. `__proto__`, `constructor`). The `{ __proto__: null }` literal keeps - // the null prototype while letting V8 use fast in-object properties, which - // `Object.create(null)` does not (it forces slow dictionary mode). - let attributes: Attributes = { __proto__: null } as Attributes; + // Plain object so V8 shares hidden classes across attribute records and + // the attribute store site stays monomorphic (null-prototype objects get a + // unique dictionary-mode map each, which permanently blocks optimization + // of this function). Pollution via dangerous attribute names is prevented + // by setOwnProperty(), which stores `__proto__` as an own property. + let attributes: Attributes = {}; let special = ''; let rawTag = ''; let rawText = ''; @@ -227,7 +230,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { const tagName = '!' + body.substring(0, i); // Parse space-separated tokens as null-valued attributes - const attributes: Attributes = Object.create(null); + const attributes: Attributes = {}; while (i < bodyLength) { const charCode = body.charCodeAt(i); // Skip whitespace @@ -246,10 +249,10 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { const closeIndex = body.indexOf(quoteChar, i + 1); if (closeIndex === -1) { // Unclosed quote — take rest as token - attributes[body.substring(i + 1)] = null; + setOwnProperty(attributes, body.substring(i + 1), null); break; } - attributes[body.substring(i + 1, closeIndex)] = null; + setOwnProperty(attributes, body.substring(i + 1, closeIndex), null); i = closeIndex + 1; continue; } @@ -266,7 +269,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { break; i++; } - attributes[body.substring(tokenStart, i)] = null; + setOwnProperty(attributes, body.substring(tokenStart, i), null); } onDoctype!(tagName, attributes); @@ -427,16 +430,14 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } if (j >= chunkLength) { tagName = chunk.substring(i); - attributes = { __proto__: null } as Attributes; + attributes = {}; state = State.OPEN_TAG_NAME; i = chunkLength; break fastPath; } const name = chunk.substring(i, j); const attrs: Attributes | null = - handleOpenTag !== undefined - ? ({ __proto__: null } as Attributes) - : null; + handleOpenTag !== undefined ? ({} as Attributes) : null; let selfClosed = false; let c = chunk.charCodeAt(j); @@ -453,10 +454,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { body: while (true) { if (i >= chunkLength) { tagName = name; - attributes = - attrs !== null - ? attrs - : ({ __proto__: null } as Attributes); + attributes = attrs !== null ? attrs : ({} as Attributes); state = State.OPEN_TAG_BODY; break fastPath; } @@ -473,10 +471,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { i++; if (i >= chunkLength) { tagName = name; - attributes = - attrs !== null - ? attrs - : ({ __proto__: null } as Attributes); + attributes = attrs !== null ? attrs : ({} as Attributes); state = State.SELF_CLOSING; break fastPath; } @@ -507,10 +502,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } if (k >= chunkLength) { tagName = name; - attributes = - attrs !== null - ? attrs - : ({ __proto__: null } as Attributes); + attributes = attrs !== null ? attrs : ({} as Attributes); attributeName = chunk.substring(i); state = State.ATTR_NAME; i = chunkLength; @@ -521,11 +513,11 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { i = k + 1; if (c !== EQ) { if (c === GT) { - if (attrs !== null) attrs[attrName] = null; + if (attrs !== null) setOwnProperty(attrs, attrName, null); break emitTag; } if (c === SLASH) { - if (attrs !== null) attrs[attrName] = null; + if (attrs !== null) setOwnProperty(attrs, attrName, null); i = k; // body's slash branch re-handles it continue body; } @@ -538,10 +530,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } if (i >= chunkLength) { tagName = name; - attributes = - attrs !== null - ? attrs - : ({ __proto__: null } as Attributes); + attributes = attrs !== null ? attrs : ({} as Attributes); attributeName = attrName; state = State.ATTR_AFTER_NAME; break fastPath; @@ -550,7 +539,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { if (cc !== EQ) { // boolean attribute; '>' ends tag, '/' or a new attr // name is re-processed by the body loop - if (attrs !== null) attrs[attrName] = null; + if (attrs !== null) setOwnProperty(attrs, attrName, null); if (cc === GT) { i++; break emitTag; @@ -569,10 +558,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } if (i >= chunkLength) { tagName = name; - attributes = - attrs !== null - ? attrs - : ({ __proto__: null } as Attributes); + attributes = attrs !== null ? attrs : ({} as Attributes); attributeName = attrName; state = State.ATTR_AFTER_EQ; break fastPath; @@ -585,10 +571,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { ); if (quoteIndex === -1) { tagName = name; - attributes = - attrs !== null - ? attrs - : ({ __proto__: null } as Attributes); + attributes = attrs !== null ? attrs : ({} as Attributes); attributeName = attrName; attributeValue = chunk.substring(i + 1); state = @@ -600,12 +583,12 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { // Mirror the baseline's stale attributeValue so the // maxBufferSize check in write() behaves identically. if (maxBufferSize !== undefined) attributeValue = value; - if (attrs !== null) attrs[attrName] = value; + if (attrs !== null) setOwnProperty(attrs, attrName, value); i = quoteIndex + 1; continue body; } if (c === GT) { - if (attrs !== null) attrs[attrName] = ''; + if (attrs !== null) setOwnProperty(attrs, attrName, ''); i++; break emitTag; } @@ -626,10 +609,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } if (m >= chunkLength) { tagName = name; - attributes = - attrs !== null - ? attrs - : ({ __proto__: null } as Attributes); + attributes = attrs !== null ? attrs : ({} as Attributes); attributeName = attrName; attributeValue = chunk.substring(i); state = State.ATTR_VALUE_UQ; @@ -638,7 +618,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } const value = chunk.substring(i, m); if (maxBufferSize !== undefined) attributeValue = value; - if (attrs !== null) attrs[attrName] = value; + if (attrs !== null) setOwnProperty(attrs, attrName, value); c = chunk.charCodeAt(m); if (c === GT) { i = m + 1; @@ -690,7 +670,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } else { state = State.OPEN_TAG_NAME; tagName = ''; - attributes = { __proto__: null } as Attributes; + attributes = {}; } continue; } @@ -784,12 +764,12 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { state = State.ATTR_AFTER_EQ; i = j + 1; } else if (charCode === GT) { - attributes[attributeName] = null; + setOwnProperty(attributes, attributeName, null); state = State.TEXT; i = j + 1; finishOpenTag(); } else if (charCode === SLASH) { - attributes[attributeName] = null; + setOwnProperty(attributes, attributeName, null); state = State.SELF_CLOSING; i = j + 1; } else { @@ -816,17 +796,17 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { ) { i++; } else if (charCode === GT) { - attributes[attributeName] = null; + setOwnProperty(attributes, attributeName, null); state = State.TEXT; i++; finishOpenTag(); } else if (charCode === SLASH) { - attributes[attributeName] = null; + setOwnProperty(attributes, attributeName, null); state = State.SELF_CLOSING; i++; } else { // New attribute — boolean (no value) - attributes[attributeName] = null; + setOwnProperty(attributes, attributeName, null); state = State.ATTR_NAME; attributeName = ''; } @@ -854,7 +834,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { ) { i++; } else if (charCode === GT) { - attributes[attributeName] = ''; + setOwnProperty(attributes, attributeName, ''); state = State.TEXT; i++; finishOpenTag(); @@ -877,7 +857,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } else { if (quoteIndex > i) attributeValue += chunk.substring(i, quoteIndex); - attributes[attributeName] = attributeValue; + setOwnProperty(attributes, attributeName, attributeValue); state = State.OPEN_TAG_BODY; i = quoteIndex + 1; } @@ -895,7 +875,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } else { if (quoteIndex > i) attributeValue += chunk.substring(i, quoteIndex); - attributes[attributeName] = attributeValue; + setOwnProperty(attributes, attributeName, attributeValue); state = State.OPEN_TAG_BODY; i = quoteIndex + 1; } @@ -927,7 +907,7 @@ export function saxEngine(options: SaxEngineOptions = {}): SaxEngineParser { } const charCode = chunk.charCodeAt(j); - attributes[attributeName] = attributeValue; + setOwnProperty(attributes, attributeName, attributeValue); if (charCode === GT) { state = State.TEXT; i = j + 1; diff --git a/eksml/src/utilities/setOwnProperty.ts b/eksml/src/utilities/setOwnProperty.ts new file mode 100644 index 0000000..424ac12 --- /dev/null +++ b/eksml/src/utilities/setOwnProperty.ts @@ -0,0 +1,37 @@ +/** + * Assign a key onto a plain record, guarding the one key that is unsafe to + * store normally on plain objects: `__proto__`. + * + * Attribute and element records are plain `{}` objects rather than + * null-prototype objects. Null-prototype objects (via `Object.create(null)` + * or a `{ __proto__: null }` literal) get a unique dictionary-mode map per + * object in V8, which makes the store site's inline cache churn on every + * record. On large parse functions that churn permanently blocks + * optimization ("delaying optimization, IC changed"), costing up to 7x + * throughput for elements with a single attribute. Plain objects share + * hidden classes, so store sites stay monomorphic and the parsers optimize + * normally. + * + * A normal `record['__proto__'] = value` store on a plain object would walk + * the `Object.prototype.__proto__` setter instead of creating an own + * property (losing the entry, or mutating the record's prototype when the + * value is an object or `null`), so that key is defined explicitly. This + * keeps the prototype-pollution guarantees: dangerous names become own + * properties and `Object.prototype` is never touched. + */ +export function setOwnProperty( + target: Record, + key: string, + value: NoInfer, +): void { + if (key === '__proto__') { + Object.defineProperty(target, key, { + value, + writable: true, + enumerable: true, + configurable: true, + }); + } else { + target[key] = value; + } +} diff --git a/eksml/src/xmlParseStream.ts b/eksml/src/xmlParseStream.ts index 06ed82c..944e861 100644 --- a/eksml/src/xmlParseStream.ts +++ b/eksml/src/xmlParseStream.ts @@ -5,6 +5,7 @@ import { HTML_VOID_ELEMENTS, HTML_RAW_CONTENT_TAGS, } from '#src/utilities/htmlConstants.ts'; +import { setOwnProperty } from '#src/utilities/setOwnProperty.ts'; import { convertItemToLossy } from '#src/converters/lossy.ts'; import type { LossyValue } from '#src/converters/lossy.ts'; import { convertItemToLossless } from '#src/converters/lossless.ts'; @@ -107,16 +108,17 @@ const SPACE = 32; // (space) /** * Convert a SAX `Attributes` object into the format used by `parse()`: * - Returns `null` when there are no attributes. - * - Returns an `Object.create(null)` prototype-free record otherwise. + * - Returns a plain-object record otherwise (dangerous keys such as + * `__proto__` are stored as own properties via setOwnProperty). */ function toNodeAttributes( attributes: Attributes, ): Record | null { const keys = Object.keys(attributes); if (keys.length === 0) return null; - const out: Record = Object.create(null); + const out: Record = {}; for (let i = 0; i < keys.length; i++) { - out[keys[i]!] = attributes[keys[i]!]!; + setOwnProperty(out, keys[i]!, attributes[keys[i]!]!); } return out; } @@ -130,7 +132,7 @@ function toNodeAttributes( * format that consumers expect. * * Returns `null` when the body contains no attributes, matching `parse()`. - * Uses `Object.create(null)` for prototype-free attribute records. + * Uses plain-object records; dangerous keys are guarded by setOwnProperty. */ function parsePIAttributes(body: string): Record | null { let attributes: Record | null = null; @@ -171,7 +173,7 @@ function parsePIAttributes(body: string): Record | null { const name = body.substring(nameStart, i); // Allocate attributes object lazily on first attribute - if (attributes === null) attributes = Object.create(null); + if (attributes === null) attributes = {}; // Skip whitespace while (i < bodyLength) { @@ -210,10 +212,14 @@ function parsePIAttributes(body: string): Record | null { const valueStartIndex = i; const end = body.indexOf(quoteCharacter, i); if (end === -1) { - attributes![name] = body.substring(valueStartIndex); + setOwnProperty(attributes!, name, body.substring(valueStartIndex)); i = bodyLength; } else { - attributes![name] = body.substring(valueStartIndex, end); + setOwnProperty( + attributes!, + name, + body.substring(valueStartIndex, end), + ); i = end + 1; } } else { @@ -230,14 +236,14 @@ function parsePIAttributes(body: string): Record | null { break; i++; } - attributes![name] = body.substring(valueStartIndex, i); + setOwnProperty(attributes!, name, body.substring(valueStartIndex, i)); } } else { - attributes![name] = null; + setOwnProperty(attributes!, name, null); } } else { // Boolean attribute (no value) - attributes![name] = null; + setOwnProperty(attributes!, name, null); } } diff --git a/eksml/test/fixtures/attr-heavy-synthetic.xml b/eksml/test/fixtures/attr-heavy-synthetic.xml new file mode 100644 index 0000000..cf439d7 --- /dev/null +++ b/eksml/test/fixtures/attr-heavy-synthetic.xml @@ -0,0 +1 @@ +item-0value-0item-1value-1item-2value-2item-3value-3item-4value-4item-5value-5item-6value-6item-7value-7item-8value-8item-9value-9item-10value-10item-11value-11item-12value-12item-13value-13item-14value-14item-15value-15item-16value-16item-17value-17item-18value-18item-19value-19item-20value-20item-21value-21item-22value-22item-23value-23item-24value-24item-25value-25item-26value-26item-27value-27item-28value-28item-29value-29item-30value-30item-31value-31item-32value-32item-33value-33item-34value-34item-35value-35item-36value-36item-37value-37item-38value-38item-39value-39item-40value-40item-41value-41item-42value-42item-43value-43item-44value-44item-45value-45item-46value-46item-47value-47item-48value-48item-49value-49item-50value-50item-51value-51item-52value-52item-53value-53item-54value-54item-55value-55item-56value-56item-57value-57item-58value-58item-59value-59item-60value-60item-61value-61item-62value-62item-63value-63item-64value-64item-65value-65item-66value-66item-67value-67item-68value-68item-69value-69item-70value-70item-71value-71item-72value-72item-73value-73item-74value-74item-75value-75item-76value-76item-77value-77item-78value-78item-79value-79item-80value-80item-81value-81item-82value-82item-83value-83item-84value-84item-85value-85item-86value-86item-87value-87item-88value-88item-89value-89item-90value-90item-91value-91item-92value-92item-93value-93item-94value-94item-95value-95item-96value-96item-97value-97item-98value-98item-99value-99item-100value-100item-101value-101item-102value-102item-103value-103item-104value-104item-105value-105item-106value-106item-107value-107item-108value-108item-109value-109item-110value-110item-111value-111item-112value-112item-113value-113item-114value-114item-115value-115item-116value-116item-117value-117item-118value-118item-119value-119item-120value-120item-121value-121item-122value-122item-123value-123item-124value-124item-125value-125item-126value-126item-127value-127item-128value-128item-129value-129item-130value-130item-131value-131item-132value-132item-133value-133item-134value-134item-135value-135item-136value-136item-137value-137item-138value-138item-139value-139item-140value-140item-141value-141item-142value-142item-143value-143item-144value-144item-145value-145item-146value-146item-147value-147item-148value-148item-149value-149item-150value-150item-151value-151item-152value-152item-153value-153item-154value-154item-155value-155item-156value-156item-157value-157 \ No newline at end of file diff --git a/eksml/test/saxEngine.test.ts b/eksml/test/saxEngine.test.ts index 64d605f..b320daf 100644 --- a/eksml/test/saxEngine.test.ts +++ b/eksml/test/saxEngine.test.ts @@ -734,41 +734,53 @@ describe('saxEngine', () => { // Issue 1 — Prototype pollution via plain {} for attributes // ========================================================================= describe('prototype pollution (issue 1)', () => { - it('attributes objects have null prototype (no Object.prototype properties)', () => { + it('parsing attributes does not pollute Object.prototype', () => { const e = parseEvents('x'); const attrs = e.opens[0]!.attrs; - // Object.create(null) produces an object with no prototype - expect(Object.getPrototypeOf(attrs)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(attrs, 'bar')).toBe(true); + expect(attrs.bar).toBe('baz'); + expect(({} as Record).bar).toBeUndefined(); }); - it('attribute named "constructor" does not shadow Object.prototype.constructor', () => { + it('attribute named "constructor" becomes an own property, not the inherited one', () => { const e = parseEvents('x'); const attrs = e.opens[0]!.attrs; - // With Object.create(null), there is no inherited constructor + expect(Object.prototype.hasOwnProperty.call(attrs, 'constructor')).toBe( + true, + ); expect(attrs.constructor).toBe('bad'); - expect(Object.getPrototypeOf(attrs)).toBeNull(); + // Object.prototype.constructor must be untouched + expect({}.constructor).toBe(Object); }); - it('attribute named "__proto__" is stored as a plain key', () => { + it('attribute named "__proto__" is stored as an own key without mutating the prototype', () => { const e = parseEvents('x'); const attrs = e.opens[0]!.attrs; + expect(Object.prototype.hasOwnProperty.call(attrs, '__proto__')).toBe( + true, + ); expect(attrs['__proto__']).toBe('polluted'); - // Should not actually pollute the prototype chain - expect(Object.getPrototypeOf(attrs)).toBeNull(); + // The record's prototype chain and Object.prototype are unaffected + expect(Object.getPrototypeOf(attrs)).toBe(Object.prototype); + expect( + ({} as Record)['polluted' as string], + ).toBeUndefined(); }); - it('DOCTYPE attributes have null prototype', () => { - const e = parseEvents(''); + it('DOCTYPE attributes with dangerous names do not pollute Object.prototype', () => { + const e = parseEvents(''); const attrs = e.doctypes[0]!.attrs; - expect(Object.getPrototypeOf(attrs)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(attrs, '__proto__')).toBe( + true, + ); + expect(Object.getPrototypeOf(attrs)).toBe(Object.prototype); + expect(({} as Record).html).toBeUndefined(); }); - it('element with no explicit attributes still has null-prototype attributes', () => { - // When a tag opens, attributes = {} is created (line 339 in TAG_OPEN) - // Even if no attributes are parsed, the object should have null prototype + it('element with no explicit attributes gets an empty attributes record', () => { const e = parseEvents('x'); const attrs = e.opens[0]!.attrs; - expect(Object.getPrototypeOf(attrs)).toBeNull(); + expect(Object.keys(attrs)).toEqual([]); }); });