Skip to content

Commit cfccdbf

Browse files
committed
Add NodeIterator for DOMPurify traversal
1 parent 828b6e7 commit cfccdbf

6 files changed

Lines changed: 241 additions & 6 deletions

File tree

‎experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2508,6 +2508,87 @@ struct v8_dom_runtime::implementation final {
25082508
}
25092509
}
25102510
2511+
class WebSceneNodeIterator {
2512+
constructor(root, whatToShow = nodeFilterConstants.SHOW_ALL, filter = null) {
2513+
if (!root || typeof root.nodeType !== 'number') {
2514+
throw new TypeError('NodeIterator root must be a Node');
2515+
}
2516+
if (filter != null && typeof filter !== 'function'
2517+
&& typeof filter.acceptNode !== 'function') {
2518+
throw new TypeError('NodeIterator filter must be callable');
2519+
}
2520+
Object.defineProperties(this, {
2521+
root: { value: root, enumerable: true },
2522+
whatToShow: {
2523+
value: Number(whatToShow) >>> 0, enumerable: true
2524+
},
2525+
filter: { value: filter ?? null, enumerable: true }
2526+
});
2527+
this._nodes = [];
2528+
this._index = -1;
2529+
this._referenceNode = root;
2530+
this._pointerBeforeReferenceNode = true;
2531+
2532+
// Build one bounded document-order snapshot. This makes the
2533+
// DOMPurify traversal O(n), retains at most one reference per
2534+
// node below root, and remains safe when the sanitizer removes
2535+
// a node that the iterator has already returned.
2536+
const visit = node => {
2537+
const mask = node.nodeType > 0 && node.nodeType <= 32
2538+
? (1 << (node.nodeType - 1)) >>> 0
2539+
: 0;
2540+
if ((this.whatToShow & mask) !== 0) {
2541+
const callback = this.filter == null
2542+
? null
2543+
: typeof this.filter === 'function'
2544+
? this.filter
2545+
: this.filter.acceptNode;
2546+
const decision = callback == null
2547+
? nodeFilterConstants.FILTER_ACCEPT
2548+
: Number(callback.call(this.filter, node));
2549+
if (decision === nodeFilterConstants.FILTER_ACCEPT) {
2550+
this._nodes.push(node);
2551+
}
2552+
}
2553+
// NodeIterator treats FILTER_REJECT like FILTER_SKIP: neither
2554+
// result prunes descendants.
2555+
for (let child = node.firstChild; child; child = child.nextSibling) {
2556+
visit(child);
2557+
}
2558+
};
2559+
visit(root);
2560+
}
2561+
get referenceNode() { return this._referenceNode; }
2562+
get pointerBeforeReferenceNode() {
2563+
return this._pointerBeforeReferenceNode;
2564+
}
2565+
nextNode() {
2566+
if (this._pointerBeforeReferenceNode && this._index >= 0) {
2567+
this._pointerBeforeReferenceNode = false;
2568+
return this._referenceNode;
2569+
}
2570+
const nextIndex = this._index + 1;
2571+
if (nextIndex >= this._nodes.length) return null;
2572+
this._index = nextIndex;
2573+
this._referenceNode = this._nodes[this._index];
2574+
this._pointerBeforeReferenceNode = false;
2575+
return this._referenceNode;
2576+
}
2577+
previousNode() {
2578+
if (!this._pointerBeforeReferenceNode && this._index >= 0) {
2579+
this._pointerBeforeReferenceNode = true;
2580+
return this._referenceNode;
2581+
}
2582+
const previousIndex = this._index - 1;
2583+
if (previousIndex < 0) return null;
2584+
this._index = previousIndex;
2585+
this._referenceNode = this._nodes[this._index];
2586+
this._pointerBeforeReferenceNode = true;
2587+
return this._referenceNode;
2588+
}
2589+
detach() {}
2590+
}
2591+
25112592
const installTreeWalkerPlatform = () => {
25122593
const createTreeWalker = function(
25132594
root,
@@ -2520,6 +2601,15 @@ struct v8_dom_runtime::implementation final {
25202601
Object.defineProperty(documentPrototype, 'createTreeWalker', {
25212602
value: createTreeWalker, writable: true, configurable: true
25222603
});
2604+
const createNodeIterator = function(
2605+
root,
2606+
whatToShow = nodeFilterConstants.SHOW_ALL,
2607+
filter = null) {
2608+
return new WebSceneNodeIterator(root, whatToShow, filter);
2609+
};
2610+
Object.defineProperty(documentPrototype, 'createNodeIterator', {
2611+
value: createNodeIterator, writable: true, configurable: true
2612+
});
25232613
};
25242614
)JS",
25252615
R"JS(
@@ -2862,6 +2952,9 @@ struct v8_dom_runtime::implementation final {
28622952
},
28632953
TreeWalker: {
28642954
value: WebSceneTreeWalker, writable: true, configurable: true
2955+
},
2956+
NodeIterator: {
2957+
value: WebSceneNodeIterator, writable: true, configurable: true
28652958
}
28662959
});
28672960
})();

‎experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1140,6 +1140,124 @@ void test_node_filter_tree_walker_focus_navigation(webscene_engine* engine)
11401140
"NodeFilter/TreeWalker focus navigation regressed: " + result);
11411141
}
11421142

1143+
void test_node_iterator_dompurify_traversal(webscene_engine* engine)
1144+
{
1145+
const auto result = evaluate(engine, R"JS(
1146+
(() => {
1147+
document.body.innerHTML = '<main id="root">before<!--comment--><section><span>inside</span></section>after</main>';
1148+
const root = document.getElementById('root');
1149+
const mask = NodeFilter.SHOW_ELEMENT
1150+
| NodeFilter.SHOW_TEXT
1151+
| NodeFilter.SHOW_COMMENT;
1152+
let filterCalls = 0;
1153+
const iterator = document.createNodeIterator(root, mask, {
1154+
acceptNode(node) {
1155+
++filterCalls;
1156+
return node.nodeType === Node.ELEMENT_NODE
1157+
&& node.localName === 'section'
1158+
? NodeFilter.FILTER_REJECT
1159+
: NodeFilter.FILTER_ACCEPT;
1160+
}
1161+
});
1162+
const forward = [];
1163+
for (let node = iterator.nextNode(); node; node = iterator.nextNode()) {
1164+
forward.push(node.nodeType === Node.ELEMENT_NODE
1165+
? node.localName
1166+
: node.nodeType === Node.COMMENT_NODE
1167+
? `#comment:${node.data}`
1168+
: `#text:${node.data}`);
1169+
}
1170+
const endState = [
1171+
iterator.referenceNode.nodeName,
1172+
iterator.pointerBeforeReferenceNode
1173+
];
1174+
const backwards = [
1175+
iterator.previousNode()?.nodeName ?? null,
1176+
iterator.previousNode()?.nodeName ?? null,
1177+
iterator.nextNode()?.nodeName ?? null
1178+
];
1179+
1180+
const functionFilter = document.createNodeIterator(
1181+
root,
1182+
NodeFilter.SHOW_ELEMENT,
1183+
node => node.localName === 'span'
1184+
? NodeFilter.FILTER_ACCEPT
1185+
: NodeFilter.FILTER_SKIP);
1186+
const functionFiltered = [];
1187+
for (let node = functionFilter.nextNode(); node;
1188+
node = functionFilter.nextNode()) {
1189+
functionFiltered.push(node.localName);
1190+
}
1191+
1192+
const fragment = document.createDocumentFragment();
1193+
const detached = document.createElement('strong');
1194+
fragment.appendChild(detached);
1195+
const detachedIterator = document.createNodeIterator(
1196+
fragment, NodeFilter.SHOW_ALL);
1197+
1198+
let invalidRoot = false;
1199+
let invalidFilter = false;
1200+
try { document.createNodeIterator(null); } catch (error) {
1201+
invalidRoot = error instanceof TypeError;
1202+
}
1203+
try { document.createNodeIterator(root, mask, {}); } catch (error) {
1204+
invalidFilter = error instanceof TypeError;
1205+
}
1206+
1207+
return {
1208+
instance: iterator instanceof NodeIterator,
1209+
root: iterator.root === root,
1210+
whatToShow: iterator.whatToShow,
1211+
filterCalls,
1212+
forward,
1213+
endState,
1214+
backwards,
1215+
functionFiltered,
1216+
detached: [
1217+
detachedIterator.nextNode() === fragment,
1218+
detachedIterator.nextNode() === detached,
1219+
detachedIterator.nextNode()
1220+
],
1221+
invalidRoot,
1222+
invalidFilter
1223+
};
1224+
})()
1225+
)JS", "native-node-iterator-dompurify.js");
1226+
require(
1227+
result == R"JSON({"instance":true,"root":true,"whatToShow":133,"filterCalls":7,"forward":["main","#text:before","#comment:comment","span","#text:inside","#text:after"],"endState":["#text",false],"backwards":["#text","#text","#text"],"functionFiltered":["span"],"detached":[true,true,null],"invalidRoot":true,"invalidFilter":true})JSON",
1228+
"NodeIterator DOMPurify traversal regressed: " + result);
1229+
}
1230+
1231+
void test_node_iterator_linear_performance_gate(webscene_engine* engine)
1232+
{
1233+
const auto result = evaluate(engine, R"JS(
1234+
(() => {
1235+
document.body.innerHTML = `<div id="iterator-root">${'<i></i>'.repeat(50000)}</div>`;
1236+
const root = document.getElementById('iterator-root');
1237+
let filterCalls = 0;
1238+
const started = performance.now();
1239+
const iterator = document.createNodeIterator(
1240+
root,
1241+
NodeFilter.SHOW_ELEMENT,
1242+
() => {
1243+
++filterCalls;
1244+
return NodeFilter.FILTER_ACCEPT;
1245+
});
1246+
let visited = 0;
1247+
while (iterator.nextNode()) ++visited;
1248+
const elapsed = performance.now() - started;
1249+
return {
1250+
visited,
1251+
filterCalls,
1252+
underFiveSeconds: elapsed < 5000
1253+
};
1254+
})()
1255+
)JS", "native-node-iterator-performance.js");
1256+
require(
1257+
result == R"JSON({"visited":50001,"filterCalls":50001,"underFiveSeconds":true})JSON",
1258+
"50,000-node NodeIterator linear performance gate failed: " + result);
1259+
}
1260+
11431261
void test_animation_frame_dispatch_is_attributed()
11441262
{
11451263
auto* engine = webscene_engine_create(0);

‎experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,8 @@ int main()
818818
test_event_listener_options_reach_native_input_and_resize(engine);
819819
test_listener_added_during_dispatch_waits_for_next_event(engine);
820820
test_node_filter_tree_walker_focus_navigation(engine);
821+
test_node_iterator_dompurify_traversal(engine);
822+
test_node_iterator_linear_performance_gate(engine);
821823
test_table_cell_click_copies_text_to_host();
822824
test_synthetic_window_resize_dispatch_uses_outer_listener_registry(engine);
823825
test_document_create_event_and_init_event(engine);

‎tests/WebPlatformSubset/capabilities.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,11 +302,11 @@
302302
},
303303
{
304304
"family": "dom-tree-traversal",
305-
"required": ["NodeFilter constants", "Document.createTreeWalker", "whatToShow element filtering", "function and object acceptNode filters", "FILTER_ACCEPT inclusion", "FILTER_SKIP descendant traversal", "FILTER_REJECT subtree pruning", "root and mutable currentNode identity", "parent, child, sibling, and document-order navigation"],
305+
"required": ["NodeFilter constants", "Document.createTreeWalker", "Document.createNodeIterator", "whatToShow element filtering", "function and object acceptNode filters", "FILTER_ACCEPT inclusion", "FILTER_SKIP descendant traversal", "FILTER_REJECT subtree pruning", "root and mutable currentNode identity", "parent, child, sibling, and document-order navigation", "root-inclusive NodeIterator element/text/comment traversal", "NodeIterator forward/reverse reference state", "linear bounded DOMPurify traversal snapshot"],
306306
"candidate": [],
307307
"productEvidence": ["order-menu focus management", "fixed-position menu portal activation"],
308-
"coverage": ["contracts/dom-treewalker-nodefilter-focus-navigation.html", "test_node_filter_tree_walker_focus_navigation", "NativeTradingViewTerminal --headless-proof --open-overlay order-menu"],
309-
"gap": "The product-neutral contract passes all four assertions and covers the SHOW_ELEMENT, accept/skip/reject, currentNode/root boundaries, child/sibling/document-order paths used by TradingView's focus-navigation utility. The actual hosted bundle opens its order menu without a script error and retains a visible fixed-position portal. NodeIterator, the complete Web IDL prototype/constructor surface, traversal of non-element node kinds, and mutation-during-traversal conformance remain outside this bounded implementation."
308+
"coverage": ["contracts/dom-treewalker-nodefilter-focus-navigation.html", "test_node_filter_tree_walker_focus_navigation", "test_node_iterator_dompurify_traversal", "test_node_iterator_linear_performance_gate", "NativeTradingViewTerminal --headless-proof --open-overlay order-menu"],
309+
"gap": "The product-neutral contract covers the SHOW_ELEMENT, accept/skip/reject, currentNode/root boundaries, child/sibling/document-order paths used by TradingView's focus-navigation utility, plus the root-inclusive element/text/comment NodeIterator path captured by DOMPurify in Code OSS. The actual hosted bundle opens its order menu without a script error and retains a visible fixed-position portal. NodeIterator uses one O(n), root-bounded snapshot so sanitizer removals cannot make traversal quadratic; live mutation adjustment, the complete Web IDL prototype/constructor surface, and every node-kind edge case remain outside this bounded implementation."
310310
},
311311
{
312312
"family": "system-ui-text-paint",

‎tests/WebPlatformSubset/contracts/dom-treewalker-nodefilter-focus-navigation.html‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,28 @@
7676
assert(walker.nextSibling() === null, "nextSibling escaped the TreeWalker root");
7777
});
7878

79+
test("NodeIterator provides root-bounded DOMPurify traversal", () => {
80+
const iterator = document.createNodeIterator(
81+
root,
82+
NodeFilter.SHOW_ELEMENT,
83+
node => node.matches("button, #deep")
84+
? NodeFilter.FILTER_ACCEPT
85+
: node.classList.contains("reject")
86+
? NodeFilter.FILTER_REJECT
87+
: NodeFilter.FILTER_SKIP);
88+
assert(iterator instanceof NodeIterator,
89+
"createNodeIterator did not return a NodeIterator");
90+
assert(iterator.root === root, "NodeIterator root identity changed");
91+
const ids = [];
92+
for (let node = iterator.nextNode(); node; node = iterator.nextNode()) ids.push(node.id);
93+
assert(ids.join(",") === "first,nested,rejected,last,deep",
94+
`NodeIterator order was ${ids.join(",")}`);
95+
assert(iterator.previousNode()?.id === "deep",
96+
"NodeIterator did not reverse from the end boundary");
97+
assert(iterator.previousNode()?.id === "last",
98+
"NodeIterator reverse order was incorrect");
99+
});
100+
79101
const failure = state.results.find(result => result.status !== "PASS");
80102
state.harness = failure
81103
? { status: 1, message: failure.message, stack: failure.stack }

‎tests/WebPlatformSubset/webscene-component-profile.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -755,9 +755,9 @@
755755
{
756756
"path": "contracts/dom-treewalker-nodefilter-focus-navigation.html",
757757
"type": "contract",
758-
"capabilities": ["nodefilter-constants", "document-create-tree-walker", "treewalker-filter-semantics", "focus-navigation-tree-traversal"],
759-
"evidence": ["browser-authorized-order-menu-focus-management", "native-treewalker-regression", "actual-tradingview-order-menu-proof"],
760-
"reason": "Product-neutral reduction of the focus-navigation primitive used by the TradingView order-menu portal. It verifies SHOW_ELEMENT filtering, FILTER_ACCEPT/SKIP/REJECT subtree semantics, currentNode/root identity and boundaries, document-order traversal, sibling navigation across skipped wrappers, and the lastChild/previousNode pattern used to locate focusable descendants."
758+
"capabilities": ["nodefilter-constants", "document-create-tree-walker", "document-create-node-iterator", "treewalker-filter-semantics", "node-iterator-dompurify-traversal", "focus-navigation-tree-traversal"],
759+
"evidence": ["browser-authorized-order-menu-focus-management", "native-treewalker-regression", "native-node-iterator-regression", "actual-tradingview-order-menu-proof"],
760+
"reason": "Product-neutral reduction of the focus-navigation primitive used by the TradingView order-menu portal and the DOMPurify traversal used by Code OSS chat rendering. It verifies SHOW_ELEMENT filtering, FILTER_ACCEPT/SKIP/REJECT semantics, currentNode/root identity and boundaries, TreeWalker navigation across skipped wrappers, and root-inclusive NodeIterator forward/reverse document-order traversal."
761761
},
762762
{
763763
"path": "contracts/dom-scope-descendant-selector.html",

0 commit comments

Comments
 (0)