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-0
- item-1value-1
- item-2value-2
- item-3value-3
- item-4value-4
- item-5value-5
- item-6value-6
- item-7value-7
- item-8value-8
- item-9value-9
- item-10value-10
- item-11value-11
- item-12value-12
- item-13value-13
- item-14value-14
- item-15value-15
- item-16value-16
- item-17value-17
- item-18value-18
- item-19value-19
- item-20value-20
- item-21value-21
- item-22value-22
- item-23value-23
- item-24value-24
- item-25value-25
- item-26value-26
- item-27value-27
- item-28value-28
- item-29value-29
- item-30value-30
- item-31value-31
- item-32value-32
- item-33value-33
- item-34value-34
- item-35value-35
- item-36value-36
- item-37value-37
- item-38value-38
- item-39value-39
- item-40value-40
- item-41value-41
- item-42value-42
- item-43value-43
- item-44value-44
- item-45value-45
- item-46value-46
- item-47value-47
- item-48value-48
- item-49value-49
- item-50value-50
- item-51value-51
- item-52value-52
- item-53value-53
- item-54value-54
- item-55value-55
- item-56value-56
- item-57value-57
- item-58value-58
- item-59value-59
- item-60value-60
- item-61value-61
- item-62value-62
- item-63value-63
- item-64value-64
- item-65value-65
- item-66value-66
- item-67value-67
- item-68value-68
- item-69value-69
- item-70value-70
- item-71value-71
- item-72value-72
- item-73value-73
- item-74value-74
- item-75value-75
- item-76value-76
- item-77value-77
- item-78value-78
- item-79value-79
- item-80value-80
- item-81value-81
- item-82value-82
- item-83value-83
- item-84value-84
- item-85value-85
- item-86value-86
- item-87value-87
- item-88value-88
- item-89value-89
- item-90value-90
- item-91value-91
- item-92value-92
- item-93value-93
- item-94value-94
- item-95value-95
- item-96value-96
- item-97value-97
- item-98value-98
- item-99value-99
- item-100value-100
- item-101value-101
- item-102value-102
- item-103value-103
- item-104value-104
- item-105value-105
- item-106value-106
- item-107value-107
- item-108value-108
- item-109value-109
- item-110value-110
- item-111value-111
- item-112value-112
- item-113value-113
- item-114value-114
- item-115value-115
- item-116value-116
- item-117value-117
- item-118value-118
- item-119value-119
- item-120value-120
- item-121value-121
- item-122value-122
- item-123value-123
- item-124value-124
- item-125value-125
- item-126value-126
- item-127value-127
- item-128value-128
- item-129value-129
- item-130value-130
- item-131value-131
- item-132value-132
- item-133value-133
- item-134value-134
- item-135value-135
- item-136value-136
- item-137value-137
- item-138value-138
- item-139value-139
- item-140value-140
- item-141value-141
- item-142value-142
- item-143value-143
- item-144value-144
- item-145value-145
- item-146value-146
- item-147value-147
- item-148value-148
- item-149value-149
- item-150value-150
- item-151value-151
- item-152value-152
- item-153value-153
- item-154value-154
- item-155value-155
- item-156value-156
- item-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([]);
});
});