From f85c555014ff988698d79c54a9755472c46b8912 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 06:50:32 -0800 Subject: [PATCH 1/8] JavaScript: run E4X XML literals and the XML, XMLList, Namespace and QName objects behind an option With `Options::e4x` set, vectorcraft-js runs ECMA-357 (2nd edition): XML and XMLList literals with embedded expressions, the `..`, `.@`, `.*`, `.()` and `::` selectors, assignment and `delete` on selected nodes, `for each`, `default xml namespace`, and the four objects with their methods and settings. The option is off by default, and without it the syntax and globals do not exist. Parsing, serializing, comparing and copying trees use no native stack, and each takes fuel and counts against the heap limit. Inserting a node into itself or its descendants throws a TypeError before the tree changes. A literal or XML string nested past `Options::max_depth` gives a SyntaxError, and a tree built past that depth stops at the heap limit. `XML.prototype.xpath` is not provided. --- crates/js/src/ast.rs | 109 +- crates/js/src/builtins.rs | 18 +- crates/js/src/bytecode.rs | 37 +- crates/js/src/compiler.rs | 120 +- crates/js/src/heap.rs | 114 ++ crates/js/src/interp.rs | 132 +- crates/js/src/lexer.rs | 22 +- crates/js/src/lib.rs | 17 +- crates/js/src/parser.rs | 59 +- crates/js/src/parser/xml.rs | 435 ++++++ crates/js/src/token.rs | 11 + crates/js/src/xml.rs | 2617 +++++++++++++++++++++++++++++++++ crates/js/src/xml_builtins.rs | 1072 ++++++++++++++ crates/js/src/xml_tests.rs | 731 +++++++++ docs/target-app-parity.md | 1 + 15 files changed, 5442 insertions(+), 53 deletions(-) create mode 100644 crates/js/src/parser/xml.rs create mode 100644 crates/js/src/xml.rs create mode 100644 crates/js/src/xml_builtins.rs create mode 100644 crates/js/src/xml_tests.rs diff --git a/crates/js/src/ast.rs b/crates/js/src/ast.rs index f0dd6b44b..ca991645b 100644 --- a/crates/js/src/ast.rs +++ b/crates/js/src/ast.rs @@ -175,6 +175,61 @@ pub enum Expr { /// A parenthesized expression, kept so a rule about the bare form (a label, a directive) can /// tell them apart. Paren(ExprId), + /// An XML literal (`d`), or with `list` an XMLList literal (`<>…`). + XmlLiteral { + parts: Vec, + list: bool, + }, + /// An E4X name (`@a`, `*`, `ns::a`), the key of an [`Expr::Index`] or the operand of + /// [`Expr::XmlScopeName`] and [`Expr::XmlDescendants`]. + XmlName { + attribute: bool, + qualifier: Option, + name: XmlNamePart, + }, + /// An E4X name standing alone (`@a` in a filter): looked up on the nearest XML value in scope. + XmlScopeName(ExprId), + /// `object..name`. + XmlDescendants { + object: ExprId, + name: ExprId, + }, + /// `object.(predicate)`. + XmlFilter { + object: ExprId, + predicate: ExprId, + }, +} + +/// A piece of an XML literal, in source order. +#[derive(Clone, Debug, PartialEq)] +pub enum XmlPart { + /// Markup kept as written. + Text(Vec), + /// `{expression}` in element content: XML values keep their markup, other values are escaped. + Content(ExprId), + /// `{expression}` as an attribute's value. + Attribute(ExprId), + /// `{expression}` as a name or a run of attributes, its string used as markup. + Raw(ExprId), +} + +/// What stands before `::` in a qualified name. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum XmlQualifier { + /// `*::name`: any namespace. + Any, + Expr(ExprId), +} + +/// The name in an E4X name. +#[derive(Clone, Debug, PartialEq)] +pub enum XmlNamePart { + Ident(String), + /// `*`. + Star, + /// `[expression]`. + Computed(ExprId), } #[derive(Clone, Debug, PartialEq)] @@ -218,19 +273,55 @@ pub enum Stmt { Expr(ExprId), Block(Vec), Empty, - If { test: ExprId, consequent: StmtId, alternate: Option }, - DoWhile { body: StmtId, test: ExprId }, - While { test: ExprId, body: StmtId }, - For { init: Option, test: Option, update: Option, body: StmtId }, - ForIn { target: ForInTarget, object: ExprId, body: StmtId }, + If { + test: ExprId, + consequent: StmtId, + alternate: Option, + }, + DoWhile { + body: StmtId, + test: ExprId, + }, + While { + test: ExprId, + body: StmtId, + }, + For { + init: Option, + test: Option, + update: Option, + body: StmtId, + }, + /// `for (… in …)`, or with `each` the E4X `for each (… in …)`, which assigns the values. + ForIn { + target: ForInTarget, + object: ExprId, + body: StmtId, + each: bool, + }, + /// `default xml namespace = expression;`. + DefaultXmlNamespace(ExprId), Continue(Option), Break(Option), Return(Option), - With { object: ExprId, body: StmtId }, - Switch { discriminant: ExprId, cases: Vec }, - Labeled { label: Name, body: StmtId }, + With { + object: ExprId, + body: StmtId, + }, + Switch { + discriminant: ExprId, + cases: Vec, + }, + Labeled { + label: Name, + body: StmtId, + }, Throw(ExprId), - Try { block: Vec, handler: Option, finalizer: Option> }, + Try { + block: Vec, + handler: Option, + finalizer: Option>, + }, Debugger, } diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 984d3a123..74c7b186d 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -131,7 +131,11 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { crate::string_builtins::install(interp)?; crate::number_builtins::install(interp)?; crate::regexp_builtins::install(interp)?; - crate::date_builtins::install(interp) + crate::date_builtins::install(interp)?; + if interp.options.e4x { + crate::xml_builtins::install(interp)?; + } + Ok(()) } // ---- helpers for the built-ins ---- @@ -218,7 +222,7 @@ const UNITS_PER_FUEL: usize = 16; /// against the longest string the heap limit allows, and has its bytes reserved before it is /// added. The buffer's bytes count toward the heap until `finish` or until the native call that /// made the builder returns. Everything the caller still needs must be reachable from the roots. -struct Builder { +pub(crate) struct Builder { units: Vec, /// Code units already reserved and charged to the heap. charged: usize, @@ -227,7 +231,7 @@ struct Builder { } impl Builder { - fn new() -> Self { + pub(crate) fn new() -> Self { Builder { units: Vec::new(), charged: 0, unpaid: 0 } } @@ -259,7 +263,7 @@ impl Builder { Ok(()) } - fn push_units(&mut self, interp: &mut Interp, units: &[u16]) -> JsResult<()> { + pub(crate) fn push_units(&mut self, interp: &mut Interp, units: &[u16]) -> JsResult<()> { self.make_room(interp, units.len())?; self.units.extend_from_slice(units); Ok(()) @@ -267,7 +271,7 @@ impl Builder { /// Appends `units` `count` times. Nothing is written for an empty `units`, however large /// `count` is. - fn push_repeated(&mut self, interp: &mut Interp, units: &[u16], count: u64) -> JsResult<()> { + pub(crate) fn push_repeated(&mut self, interp: &mut Interp, units: &[u16], count: u64) -> JsResult<()> { if units.is_empty() || count == 0 { return Ok(()); } @@ -279,14 +283,14 @@ impl Builder { Ok(()) } - fn push_str(&mut self, interp: &mut Interp, s: &str) -> JsResult<()> { + pub(crate) fn push_str(&mut self, interp: &mut Interp, s: &str) -> JsResult<()> { let units: Vec = s.encode_utf16().collect(); self.push_units(interp, &units) } /// The string built, whose bytes were charged as it grew. From here on the heap counts them /// as the string's. - fn finish(self, interp: &mut Interp) -> Value { + pub(crate) fn finish(self, interp: &mut Interp) -> Value { interp.held_bytes = interp.held_bytes.saturating_sub(2 * self.charged); Value::String(Str::from_units(self.units)) } diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index b23d9a525..895d91171 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -145,6 +145,35 @@ pub(crate) enum Op { /// Pushes the completion value, which a `finally` block in script and eval code keeps and /// sets back with `SetCompletion` when it ends normally. GetCompletion, + /// `[qualifier] name` → an E4X name object. + XmlMakeName { + attribute: bool, + qualified: bool, + }, + /// `object name` → the descendants that match. + XmlDescendants, + /// `value` → the string an embedded expression puts in a literal (0 content, 1 attribute + /// value, 2 name). + XmlEscape(u8), + /// The `parts` strings → the XML or XMLList they spell. + XmlLiteral { + parts: u32, + list: bool, + }, + /// `name` → the value of the name on the nearest XML value in scope. + XmlScopeGet, + /// `object` → the filter's state. + XmlFilterStart, + /// `state` → `state item`, or jumps with `state` when the items are used up. + XmlFilterNext(u32), + /// `state value` → `state`, the current item kept when the value is truthy. + XmlFilterTest, + /// `state` → the XMLList of the items kept. + XmlFilterEnd, + /// `iterator name` → `iterator value`: the value under the name a `for each` got. + ForEachValue, + /// `value` → nothing: sets the default XML namespace of the running code. + SetDefaultNamespace, } /// What code is: a function body, a script, or eval code. @@ -267,7 +296,13 @@ fn effect(op: Op, finally_tables: &[Vec]) -> Result { let table = finally_tables.get(t as usize).ok_or_else(|| format!("finally table {t} out of range"))?; Effect { pops: 2, next: Some(0), jumps: table.iter().map(|&target| (target, 1)).collect() } } - Op::ForInNext(t) => Effect { pops: 1, next: Some(2), jumps: vec![(t, 1)] }, + Op::ForInNext(t) | Op::XmlFilterNext(t) => Effect { pops: 1, next: Some(2), jumps: vec![(t, 1)] }, + Op::XmlMakeName { qualified, .. } => e(1 + u32::from(qualified), 1), + Op::XmlDescendants | Op::XmlFilterTest => e(2, 1), + Op::XmlEscape(_) | Op::XmlScopeGet | Op::XmlFilterStart | Op::XmlFilterEnd => e(1, 1), + Op::XmlLiteral { parts, .. } => e(parts, 1), + Op::ForEachValue => e(2, 2), + Op::SetDefaultNamespace => e(1, 0), }) } diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index ae470be44..f0f171d59 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -18,7 +18,7 @@ use std::rc::Rc; use crate::ast::{ AssignOp, Ast, BinaryOp, Expr, ExprId, ForInTarget, ForInit, FunctionId, FunctionKind, LogicalOp, PropertyValue, Stmt, StmtId, UnaryOp, UpdateOp, - VarDecl, + VarDecl, XmlNamePart, XmlPart, XmlQualifier, }; use crate::bytecode::{Code, CodeKind, Handler, Op, Source, fallthrough_effect, verify}; use crate::error::{SyntaxError, TOO_DEEP}; @@ -276,7 +276,14 @@ impl<'a> FnCompiler<'a> { } work.extend(block.iter().rev()); } - Stmt::Expr(_) | Stmt::Empty | Stmt::Continue(_) | Stmt::Break(_) | Stmt::Return(_) | Stmt::Throw(_) | Stmt::Debugger => {} + Stmt::Expr(_) + | Stmt::Empty + | Stmt::Continue(_) + | Stmt::Break(_) + | Stmt::Return(_) + | Stmt::Throw(_) + | Stmt::Debugger + | Stmt::DefaultXmlNamespace(_) => {} } } Ok(out) @@ -348,6 +355,11 @@ impl<'a> FnCompiler<'a> { Ok(()) } Stmt::Try { block, handler, finalizer } => self.try_stmt(block, handler.as_ref(), finalizer.as_deref()), + Stmt::DefaultXmlNamespace(e) => { + self.expr(*e)?; + self.emit(Op::SetDefaultNamespace); + Ok(()) + } } } @@ -467,14 +479,14 @@ impl<'a> FnCompiler<'a> { } self.jump(Op::Jump, head); } - Some(Stmt::ForIn { target, object, body }) => return self.for_in(target, *object, *body, labels), + Some(Stmt::ForIn { target, object, body, each }) => return self.for_in(target, *object, *body, labels, *each), _ => return internal("not a loop"), } self.bind(brk); Ok(()) } - fn for_in(&mut self, target: &ForInTarget, object: ExprId, body: StmtId, labels: Vec) -> CResult<()> { + fn for_in(&mut self, target: &ForInTarget, object: ExprId, body: StmtId, labels: Vec, each: bool) -> CResult<()> { if let ForInTarget::Var(d) = target { self.var_decls(std::slice::from_ref(d))?; } @@ -485,6 +497,9 @@ impl<'a> FnCompiler<'a> { let brk = self.new_label(); self.bind(head); self.jump(Op::ForInNext, brk); + if each { + self.emit(Op::ForEachValue); + } match target { ForInTarget::Var(d) => { let n = self.var_name(&d.name.name); @@ -827,10 +842,82 @@ impl<'a> FnCompiler<'a> { } } Expr::Paren(inner) => self.expr(*inner)?, + Expr::XmlLiteral { parts, list } => self.xml_literal(parts, *list)?, + Expr::XmlName { attribute, qualifier, name } => self.xml_name(*attribute, *qualifier, name)?, + Expr::XmlScopeName(name) => { + self.expr(*name)?; + self.at(self.ast.expr_span(id).start); + self.emit(Op::XmlScopeGet); + } + Expr::XmlDescendants { .. } | Expr::XmlFilter { .. } => self.chain(id)?, } Ok(()) } + fn xml_literal(&mut self, parts: &[XmlPart], list: bool) -> CResult<()> { + for part in parts { + match part { + XmlPart::Text(units) => { + let i = self.string(units); + self.emit(Op::String(i)); + } + XmlPart::Content(e) | XmlPart::Attribute(e) | XmlPart::Raw(e) => { + self.expr(*e)?; + self.emit(Op::XmlEscape(match part { + XmlPart::Content(_) => 0, + XmlPart::Attribute(_) => 1, + _ => 2, + })); + } + } + } + self.emit(Op::XmlLiteral { parts: len_u32(parts.len()), list }); + Ok(()) + } + + fn xml_name(&mut self, attribute: bool, qualifier: Option, name: &XmlNamePart) -> CResult<()> { + match qualifier { + Some(XmlQualifier::Any) => self.emit(Op::Null), + Some(XmlQualifier::Expr(e)) => self.expr(e)?, + None => {} + } + match name { + XmlNamePart::Ident(n) => { + let i = self.string(&n.encode_utf16().collect::>()); + self.emit(Op::String(i)); + } + XmlNamePart::Star => { + let i = self.string(&[u16::from(b'*')]); + self.emit(Op::String(i)); + } + XmlNamePart::Computed(e) => self.expr(*e)?, + } + self.emit(Op::XmlMakeName { attribute, qualified: qualifier.is_some() }); + Ok(()) + } + + /// `object.(predicate)` with the object already on the stack: each item is tested with itself + /// as the innermost scope, in list order. + fn xml_filter(&mut self, predicate: ExprId) -> CResult<()> { + self.emit(Op::XmlFilterStart); + let head = self.new_label(); + let done = self.new_label(); + self.bind(head); + let depth = self.depth; + self.jump(Op::XmlFilterNext, done); + self.emit(Op::PushWith); + self.env_depth += 1; + self.expr(predicate)?; + self.emit(Op::PopEnv); + self.env_depth -= 1; + self.emit(Op::XmlFilterTest); + self.jump(Op::Jump, head); + self.bind(done); + self.depth = depth; + self.emit(Op::XmlFilterEnd); + Ok(()) + } + fn args(&mut self, args: &[ExprId]) -> CResult<()> { for a in args { self.expr(*a)?; @@ -869,7 +956,9 @@ impl<'a> FnCompiler<'a> { let mut cur = id; loop { match self.ast.expr(cur) { - Some(Expr::Member { object, .. } | Expr::Index { object, .. }) => { + Some( + Expr::Member { object, .. } | Expr::Index { object, .. } | Expr::XmlDescendants { object, .. } | Expr::XmlFilter { object, .. }, + ) => { suffixes.push(cur); cur = self.ast.unparenthesized(*object); } @@ -910,6 +999,15 @@ impl<'a> FnCompiler<'a> { self.at(span.start); self.emit(if next_is_call { Op::GetIndexThis } else { Op::GetIndex }); } + Some(Expr::XmlDescendants { name, .. }) => { + self.expr(*name)?; + self.at(span.start); + self.emit(Op::XmlDescendants); + } + Some(Expr::XmlFilter { predicate, .. }) => { + self.at(span.start); + self.xml_filter(*predicate)?; + } Some(Expr::Call { args, .. }) => { let innermost = j + 1 == count; let has_this = if innermost { @@ -1163,9 +1261,15 @@ impl<'a> FnCompiler<'a> { for &(i, label) in &self.patches { let Some(target) = resolve(&self.labels, label) else { return internal("jump to an unplaced label") }; match self.ops.get_mut(i) { - Some(Op::Jump(t) | Op::JumpIfFalse(t) | Op::JumpIfTrue(t) | Op::JumpIfFalseKeep(t) | Op::JumpIfTrueKeep(t) | Op::ForInNext(t)) => { - *t = target - } + Some( + Op::Jump(t) + | Op::JumpIfFalse(t) + | Op::JumpIfTrue(t) + | Op::JumpIfFalseKeep(t) + | Op::JumpIfTrueKeep(t) + | Op::ForInNext(t) + | Op::XmlFilterNext(t), + ) => *t = target, _ => return internal("patching an instruction that is not a jump"), } } diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index 1b2099fe1..496d9218d 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -220,12 +220,84 @@ pub(crate) enum Class { Math, /// The `JSON` object (section 15.12). Json, + /// An XML value (ECMA-357 section 9.1). + Xml(Box), + /// An XMLList value (ECMA-357 section 9.2). + XmlList(Box), + Namespace(Box), + /// A QName; `attribute` marks an attribute name (ECMA-357 sections 13.3 and 9.3), and + /// `reference` a name made by E4X syntax, which only an XML value can be accessed with. + QName { + name: Box, + attribute: bool, + reference: bool, + }, + /// A `.( )` filter in progress. + XmlFilter(Box), +} + +/// What an XML value is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum XmlKind { + Element, + Attribute, + Text, + Comment, + Pi, +} + +/// A qualified name. `uri` is `None` for a name that matches any namespace. +#[derive(Clone, Debug)] +pub(crate) struct XmlName { + pub(crate) uri: Option, + pub(crate) local: Key, + pub(crate) prefix: Option, +} + +/// A namespace. `prefix` is `None` when it is undefined. +#[derive(Clone, Debug)] +pub(crate) struct XmlNs { + pub(crate) prefix: Option, + pub(crate) uri: Key, +} + +/// An XML value: a node of a tree, linked by object index. +#[derive(Clone)] +pub(crate) struct XmlNode { + pub(crate) kind: XmlKind, + pub(crate) name: Option, + pub(crate) parent: Option, + pub(crate) value: Str, + pub(crate) attrs: Vec, + pub(crate) children: Vec, + pub(crate) ns: Vec, +} + +/// An XMLList value: its items, and the object and name a write to an empty list targets. +#[derive(Clone)] +pub(crate) struct XmlListData { + pub(crate) items: Vec, + pub(crate) target: Option, + pub(crate) target_name: Option<(XmlName, bool)>, +} + +/// The state of a `.( )` filter: the items to test, the next one's position and those kept. +#[derive(Clone)] +pub(crate) struct XmlFilterState { + pub(crate) input: Vec, + pub(crate) pos: usize, + pub(crate) kept: Vec, } impl Class { /// The `[[Class]]` name `Object.prototype.toString` reports. pub(crate) fn name(&self) -> &'static str { match self { + Class::Xml(_) => "XML", + Class::XmlList(_) => "XMLList", + Class::Namespace(_) => "Namespace", + Class::QName { .. } => "QName", + Class::XmlFilter(_) => "Object", Class::Math => "Math", Class::Json => "JSON", Class::Ordinary | Class::ForIn(_) => "Object", @@ -303,6 +375,14 @@ pub(crate) const ENV_BYTES: usize = 64; pub(crate) const ROPE_BYTES: usize = 48; /// The estimated bytes of each argument a bound function holds. pub(crate) const BOUND_ARG_BYTES: usize = 24; +/// The estimated bytes of an XML node without its names and text. +pub(crate) const XML_NODE_BYTES: usize = 160; +/// The estimated bytes of an XML list without its items. +pub(crate) const XML_LIST_BYTES: usize = 96; +/// The estimated bytes of a reference from an XML node or list to another. +pub(crate) const XML_REF_BYTES: usize = 8; +/// The estimated bytes of a namespace an XML node holds. +pub(crate) const XML_NS_BYTES: usize = 48; /// The estimated bytes of a piece of code without its instructions, tables and strings. const CODE_BYTES: usize = 384; @@ -564,11 +644,45 @@ fn trace_object(o: &Object, work: &mut Vec, strings: &mut StringSizes) -> u work.extend(state.object.iter().map(|o| o.0)); strings.bytes += state.keys.iter().map(|k| string_bytes(k.len())).sum::(); } + Class::Xml(node) => { + work.extend(node.parent.iter().chain(&node.attrs).chain(&node.children).map(|o| o.0)); + strings.add(&node.value); + if let Some(name) = &node.name { + add_name(name, strings); + } + for ns in &node.ns { + add_ns(ns, strings); + } + extra = XML_NODE_BYTES + XML_REF_BYTES * (node.attrs.len() + node.children.len()) + XML_NS_BYTES * node.ns.len(); + } + Class::XmlList(list) => { + work.extend(list.target.iter().chain(&list.items).map(|o| o.0)); + if let Some((name, _)) = &list.target_name { + add_name(name, strings); + } + extra = XML_LIST_BYTES + XML_REF_BYTES * list.items.len(); + } + Class::Namespace(ns) => add_ns(ns, strings), + Class::QName { name, .. } => add_name(name, strings), + Class::XmlFilter(state) => { + work.extend(state.input.iter().chain(&state.kept).map(|o| o.0)); + extra = XML_LIST_BYTES + XML_REF_BYTES * (state.input.len() + state.kept.len()); + } _ => {} } OBJECT_BYTES + PROP_BYTES * o.props.len() + extra } +fn add_name(name: &XmlName, strings: &mut StringSizes) { + strings.add_key(&name.local); + name.uri.iter().chain(&name.prefix).for_each(|k| strings.add_key(k)); +} + +fn add_ns(ns: &XmlNs, strings: &mut StringSizes) { + strings.add_key(&ns.uri); + ns.prefix.iter().for_each(|k| strings.add_key(k)); +} + fn trace_env(e: &Env, work: &mut Vec, strings: &mut StringSizes) -> usize { match e { Env::Decl { bindings, outer } => { diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index b110a955b..a28ffe46c 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -72,6 +72,12 @@ pub(crate) struct Intrinsics { pub(crate) error_protos: [ObjId; 7], pub(crate) eval: ObjId, pub(crate) throw_type_error: ObjId, + /// The `XML` constructor, which holds the XML settings. + pub(crate) xml_ctor: ObjId, + pub(crate) xml_proto: ObjId, + pub(crate) xml_list_proto: ObjId, + pub(crate) namespace_proto: ObjId, + pub(crate) qname_proto: ObjId, } impl Intrinsics { @@ -89,6 +95,11 @@ impl Intrinsics { error_protos: [o; 7], eval: o, throw_type_error: o, + xml_ctor: o, + xml_proto: o, + xml_list_proto: o, + namespace_proto: o, + qname_proto: o, } } @@ -117,6 +128,11 @@ impl Intrinsics { self.date_proto, self.eval, self.throw_type_error, + self.xml_ctor, + self.xml_proto, + self.xml_list_proto, + self.namespace_proto, + self.qname_proto, ]; v.extend(self.error_protos); v @@ -128,8 +144,8 @@ pub(crate) struct Frame { pub(crate) pc: usize, /// Where this frame's operands start on the operand stack. base: usize, - env: EnvId, - var_env: EnvId, + pub(crate) env: EnvId, + pub(crate) var_env: EnvId, /// `with` and catch environments entered and not yet left. env_depth: u32, this: Value, @@ -179,6 +195,8 @@ pub(crate) struct Interp { pub(crate) date_host: crate::date_builtins::DateHost, /// The bytes native code holds outside the heap's cells through [`HeldBytes`]. pub(crate) held: Rc, + /// The global code's default XML namespace URI (ECMA-357 section 12.1). + pub(crate) xml_default_uri: Key, } /// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's @@ -367,6 +385,7 @@ impl Interp { install_error: None, date_host: crate::date_builtins::DateHost::default(), held: Rc::default(), + xml_default_uri: Rc::from(Vec::new()), }; crate::builtins::install(&mut interp); interp.fuel = limits.fuel; @@ -405,16 +424,16 @@ impl Interp { // ---- the operand stack ---- - fn push(&mut self, v: Value) -> JsResult<()> { + pub(crate) fn push(&mut self, v: Value) -> JsResult<()> { self.stack.push(v); Ok(()) } - fn frame(&self) -> JsResult<&Frame> { + pub(crate) fn frame(&self) -> JsResult<&Frame> { self.frames.last().ok_or_else(|| Abrupt::Internal("no frame".into())) } - fn frame_mut(&mut self) -> JsResult<&mut Frame> { + pub(crate) fn frame_mut(&mut self) -> JsResult<&mut Frame> { self.frames.last_mut().ok_or_else(|| Abrupt::Internal("no frame".into())) } @@ -422,7 +441,7 @@ impl Interp { self.frames.last().map_or(0, |f| f.base) } - fn pop(&mut self) -> JsResult { + pub(crate) fn pop(&mut self) -> JsResult { if self.stack.len() <= self.base() { return internal("operand stack underflow"); } @@ -430,7 +449,7 @@ impl Interp { } /// The value `n` places below the top (0 is the top). - fn peek(&self, n: usize) -> JsResult { + pub(crate) fn peek(&self, n: usize) -> JsResult { let len = self.stack.len(); match len.checked_sub(n + 1).filter(|&i| i >= self.base()) { Some(i) => self.stack.get(i).cloned().ok_or_else(|| Abrupt::Internal("operand stack underflow".into())), @@ -450,7 +469,7 @@ impl Interp { } /// Replaces the top `n` values with `v`. - fn replace(&mut self, n: usize, v: Value) -> JsResult<()> { + pub(crate) fn replace(&mut self, n: usize, v: Value) -> JsResult<()> { self.drop_n(n)?; self.push(v) } @@ -638,7 +657,7 @@ impl Interp { Ok(None) } - fn jump_to(&mut self, target: u32) -> JsResult<()> { + pub(crate) fn jump_to(&mut self, target: u32) -> JsResult<()> { self.frame_mut()?.pc = target as usize; Ok(()) } @@ -730,6 +749,17 @@ impl Interp { let v = self.frame()?.completion.clone(); self.push(v) } + Op::XmlMakeName { attribute, qualified } => self.op_xml_make_name(attribute, qualified), + Op::XmlDescendants => self.op_xml_descendants(), + Op::XmlEscape(kind) => self.op_xml_escape(kind), + Op::XmlLiteral { parts, list } => self.op_xml_literal(parts, list), + Op::XmlScopeGet => self.op_xml_scope_get(), + Op::XmlFilterStart => self.op_xml_filter_start(), + Op::XmlFilterNext(t) => self.op_xml_filter_next(t), + Op::XmlFilterTest => self.op_xml_filter_test(), + Op::XmlFilterEnd => self.op_xml_filter_end(), + Op::ForEachValue => self.op_for_each_value(), + Op::SetDefaultNamespace => self.op_set_default_namespace(), Op::Return | Op::ReturnCompletion => internal("return reached the dispatcher"), } } @@ -798,7 +828,7 @@ impl Interp { // ---- variables ---- - fn resolve(&self, name: &[u16]) -> JsResult { + fn resolve(&mut self, name: &[u16]) -> JsResult { let mut cur = Some(self.frame()?.env); while let Some(e) = cur { match self.heap.env(e) { @@ -808,11 +838,11 @@ impl Interp { } cur = *outer; } - Some(Env::Obj { object, with_this, outer }) => { - if self.has_property(*object, name)? { - return Ok(Resolved::Obj { object: *object, with_this: *with_this }); + Some(&Env::Obj { object, with_this, outer }) => { + if self.scope_has(object, name)? { + return Ok(Resolved::Obj { object, with_this }); } - cur = *outer; + cur = outer; } None => return internal("environment out of range"), } @@ -842,7 +872,7 @@ impl Interp { match r { Resolved::Decl(e) => Ok((self.binding_value(e, name)?, Value::Undefined)), Resolved::Obj { object, with_this } => { - let v = self.get_from(object, name, &Value::Object(object))?; + let v = self.get_value(&Value::Object(object), name)?; Ok((v, if with_this { Value::Object(object) } else { Value::Undefined })) } Resolved::Unresolved => Err(self.not_defined(name)), @@ -869,6 +899,9 @@ impl Interp { _ => Ok(()), } } + Resolved::Obj { object, .. } if self.options.e4x && self.is_xml(&Value::Object(object)) => { + self.put_value(&Value::Object(object), name.clone(), v) + } Resolved::Obj { object, .. } => self.put(object, name.clone(), v, strict), Resolved::Unresolved if strict => Err(self.not_defined(name)), Resolved::Unresolved => { @@ -880,6 +913,13 @@ impl Interp { fn op_get_var(&mut self, n: u32, with_this: bool) -> JsResult<()> { let name = self.name(n)?; + if with_this + && self.options.e4x + && let Some((f, this)) = self.xml_scope_method(&name)? + { + self.push(f)?; + return self.push(this); + } let (v, this) = self.get_var(&name)?; self.push(v)?; if with_this { self.push(this) } else { Ok(()) } @@ -1056,6 +1096,9 @@ impl Interp { /// `base[k]` for any value (GetValue, section 8.7.1). pub(crate) fn get_value(&mut self, base: &Value, k: &[u16]) -> JsResult { + if self.options.e4x && self.is_xml(base) { + return self.xml_get(base, &Value::String(Str::from_units(k.to_vec()))); + } if let Value::String(s) = base { if is(k, "length") { return Ok(Value::Number(s.len() as f64)); @@ -1402,6 +1445,9 @@ impl Interp { /// PutValue on a property reference (section 8.7.2), whose base may be a primitive. fn put_value(&mut self, base: &Value, k: Key, v: Value) -> JsResult<()> { + if self.options.e4x && self.is_xml(base) { + return self.xml_put(base, &Value::String(Str::from_rc(k)), &v); + } let strict = self.strict(); match base { Value::Object(o) => self.put(*o, k, v, strict), @@ -1427,6 +1473,11 @@ impl Interp { fn op_get_member(&mut self, n: u32, with_this: bool) -> JsResult<()> { let name = self.name(n)?; let base = self.peek(0)?; + if with_this && self.options.e4x && self.is_xml(&base) { + let (f, this) = self.xml_method(&base, &name)?; + self.replace(1, f)?; + return self.push(this); + } let v = self.get_value(&base, &name)?; self.replace(1, v)?; if with_this { self.push(base) } else { Ok(()) } @@ -1455,6 +1506,9 @@ impl Interp { fn op_to_key(&mut self) -> JsResult<()> { let k = self.peek(0)?; let base = self.peek(1)?; + if self.is_xml_access(&base, &k) { + return Ok(()); + } self.check_coercible(&base, &[])?; let name = self.to_key(&k)?; self.replace(1, Value::String(Str::from_rc(name))) @@ -1463,6 +1517,16 @@ impl Interp { fn op_get_index(&mut self, with_this: bool) -> JsResult<()> { let k = self.peek(0)?; let base = self.peek(1)?; + if self.is_xml_access(&base, &k) { + if with_this { + let name = self.xml_method_key(&k)?; + let (f, this) = self.xml_method(&base, &name)?; + self.replace(2, f)?; + return self.push(this); + } + let v = self.xml_get(&base, &k)?; + return self.replace(2, v); + } self.check_coercible(&base, &[])?; let name = self.to_key(&k)?; let v = self.get_value(&base, &name)?; @@ -1474,6 +1538,10 @@ impl Interp { let v = self.peek(0)?; let k = self.peek(1)?; let base = self.peek(2)?; + if self.is_xml_access(&base, &k) { + self.xml_put(&base, &k, &v)?; + return self.replace(3, v); + } let name = self.to_key(&k)?; self.put_value(&base, name, v.clone())?; self.replace(3, v) @@ -1482,6 +1550,10 @@ impl Interp { fn op_delete_member(&mut self, n: u32) -> JsResult<()> { let name = self.name(n)?; let base = self.peek(0)?; + if self.options.e4x && self.is_xml(&base) { + let r = self.xml_delete(&base, &Value::String(Str::from_rc(name)))?; + return self.replace(1, Value::Bool(r)); + } let o = self.to_object(&base)?; let strict = self.strict(); let r = self.delete(o, &name, strict)?; @@ -1491,6 +1563,10 @@ impl Interp { fn op_delete_index(&mut self) -> JsResult<()> { let k = self.peek(0)?; let base = self.peek(1)?; + if self.is_xml_access(&base, &k) { + let r = self.xml_delete(&base, &k)?; + return self.replace(2, Value::Bool(r)); + } self.check_coercible(&base, &[])?; let name = self.to_key(&k)?; let o = self.to_object(&base)?; @@ -1517,6 +1593,13 @@ impl Interp { fn op_inc_index(&mut self, prefix: bool, dec: bool) -> JsResult<()> { let k = self.peek(0)?; let base = self.peek(1)?; + if self.is_xml_access(&base, &k) { + let v = self.xml_get(&base, &k)?; + let old = self.to_number(&v)?; + let new = if dec { old - 1.0 } else { old + 1.0 }; + self.xml_put(&base, &k, &Value::Number(new))?; + return self.replace(2, Value::Number(if prefix { new } else { old })); + } self.check_coercible(&base, &[])?; let name = self.to_key(&k)?; let r = self.inc_property(&base, name, prefix, dec)?; @@ -1682,6 +1765,9 @@ impl Interp { /// For a String object, the heap budget for all its index names is checked before any is /// built. Each index name and each property visited takes a unit of fuel. fn enumerable_keys(&mut self, id: ObjId) -> JsResult> { + if self.options.e4x && self.is_xml(&Value::Object(id)) { + return self.xml_enumerate(id); + } let mut seen = std::collections::HashSet::new(); let mut out = Vec::new(); let mut cur = Some(id); @@ -1748,7 +1834,7 @@ impl Interp { state.pos += 1; let object = state.object; if let Some(o) = object - && self.has_property(o, &k)? + && self.scope_has(o, &k)? { return self.push(Value::String(Str::from_rc(k))); } @@ -2238,6 +2324,7 @@ impl Interp { Value::Bool(_) => "boolean", Value::Number(_) => "number", Value::String(_) => "string", + Value::Object(_) if self.options.e4x && self.is_xml(v) => "xml", Value::Object(_) if self.is_callable(v) => "function", Value::Object(_) => "object", } @@ -2399,6 +2486,9 @@ impl Interp { BinaryOp::StrictNotEq => Ok(Value::Bool(!strict_eq(a, b))), BinaryOp::Instanceof => self.instance_of(a, b).map(Value::Bool), BinaryOp::In => { + if self.options.e4x && self.is_xml(b) { + return self.xml_has(b, a).map(Value::Bool); + } let Value::Object(o) = b else { return Err(self.type_error("the right side of 'in' is not an object")) }; let k = self.to_key(a)?; self.has_property(*o, &k).map(Value::Bool) @@ -2411,6 +2501,9 @@ impl Interp { if let (Value::Number(x), Value::Number(y)) = (a, b) { return Ok(Value::Number(x + y)); } + if self.options.e4x && self.is_xml(a) && self.is_xml(b) { + return self.xml_concat(a, b); + } let pa = self.to_primitive(a, Hint::Default)?; let pb = self.to_primitive(b, Hint::Default)?; if matches!(pa, Value::String(_)) || matches!(pb, Value::String(_)) { @@ -2443,7 +2536,12 @@ impl Interp { } /// The abstract equality comparison (section 11.9.3). - fn loose_eq(&mut self, a: &Value, b: &Value) -> JsResult { + pub(crate) fn loose_eq(&mut self, a: &Value, b: &Value) -> JsResult { + if self.options.e4x + && let Some(r) = self.xml_loose_eq(a, b)? + { + return Ok(r); + } let (mut x, mut y) = (a.clone(), b.clone()); loop { match (&x, &y) { diff --git a/crates/js/src/lexer.rs b/crates/js/src/lexer.rs index 8b82570a7..c47db56e8 100644 --- a/crates/js/src/lexer.rs +++ b/crates/js/src/lexer.rs @@ -47,13 +47,30 @@ pub struct Lexer<'s> { preprocessor_lines: bool, /// The deepest nesting of groups a regular expression literal may have. max_depth: usize, + /// Read the E4X punctuators `..`, `::` and `@`. + e4x: bool, } impl<'s> Lexer<'s> { /// A lexer of `src`. `preprocessor_lines` turns on the reading of preprocessor lines; /// `max_depth` limits how deeply a regular expression literal's groups may nest. pub fn new(src: &'s [u16], preprocessor_lines: bool, max_depth: usize) -> Self { - Self { src, pos: 0, line: 1, line_start: 0, blank_line: true, preprocessor_lines, max_depth } + Self { src, pos: 0, line: 1, line_start: 0, blank_line: true, preprocessor_lines, max_depth, e4x: false } + } + + /// The same lexer, reading the E4X punctuators when `e4x` is true. + pub fn with_e4x(mut self, e4x: bool) -> Self { + self.e4x = e4x; + self + } + + /// Moves to source offset `pos`, at the given line and column (both from 1), after the parser + /// has read an XML literal up to there. + pub(crate) fn seek(&mut self, pos: usize, line: u32, column: u32) { + self.pos = pos; + self.line = line; + self.line_start = pos.saturating_sub(column.saturating_sub(1) as usize); + self.blank_line = false; } /// The code unit at `pos`, as a code point value. @@ -578,6 +595,9 @@ impl<'s> Lexer<'s> { fn punct(&mut self) -> Option { let rest = &self.src[self.pos..]; for (spelling, punct) in Punct::ALL { + if !self.e4x && matches!(punct, Punct::DotDot | Punct::ColonColon | Punct::At) { + continue; + } let bytes = spelling.as_bytes(); if rest.len() >= bytes.len() && rest.iter().zip(bytes).all(|(&u, &b)| u == u16::from(b)) { self.pos += bytes.len(); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index b883be432..decf0315e 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -37,6 +37,13 @@ //! by [`Limits`]: instructions run (fuel), heap bytes, frames, nested host calls, and an //! [`Interrupt`] flag another thread can set. A run that reaches a limit ends with //! [`RunErrorKind::Limit`], which a script's `try` cannot catch. +//! +//! With [`Options::e4x`], the parser also reads E4X (ECMA-357, second edition, December 2005): +//! XML and XMLList literals, the `.@`, `..`, `::` and `.( )` operators, `for each … in` and +//! `default xml namespace`; the realm then provides `XML`, `XMLList`, `Namespace`, `QName`, +//! `isXMLName` and `setDefaultXMLNamespace`. Without the option, the language and the built-ins are +//! those of ECMAScript 5.1 above. XML trees live in the heap, and parsing, copying, comparing and +//! serializing them use no native recursion and take fuel for each node. #![forbid(unsafe_code)] pub mod ast; @@ -59,10 +66,12 @@ mod regexp_vm; mod string_builtins; mod token; mod value; +mod xml; +mod xml_builtins; pub use ast::{ AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property, - PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, + PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, XmlNamePart, XmlPart, XmlQualifier, }; pub use error::{SyntaxError, TOO_DEEP, TOO_LONG}; pub use lexer::{Goal, Lexer}; @@ -82,11 +91,13 @@ pub struct Options { pub max_depth: usize, /// The most UTF-16 code units a source may hold. pub max_source_units: usize, + /// Read and run E4X (ECMA-357). Off, XML syntax is an error and a realm has no XML built-ins. + pub e4x: bool, } impl Default for Options { fn default() -> Self { - Self { strict: false, preprocessor_lines: false, max_depth: DEFAULT_MAX_DEPTH, max_source_units: 64 << 20 } + Self { strict: false, preprocessor_lines: false, max_depth: DEFAULT_MAX_DEPTH, max_source_units: 64 << 20, e4x: false } } } @@ -190,3 +201,5 @@ mod interp_tests; mod tests; #[cfg(test)] mod text_tests; +#[cfg(test)] +mod xml_tests; diff --git a/crates/js/src/parser.rs b/crates/js/src/parser.rs index d56b75617..3c2ab3ed3 100644 --- a/crates/js/src/parser.rs +++ b/crates/js/src/parser.rs @@ -7,6 +7,8 @@ //! stack, and prefix operators, assignment chains, `else if` chains and labels with loops, so long //! runs of them do not nest at all. +mod xml; + use std::collections::{HashMap, HashSet}; use crate::LineTable; @@ -69,11 +71,13 @@ pub(crate) struct Parser<'s> { /// The next statement stands in a statement list, where a label's body may be a function /// declaration. Read and cleared as each statement starts. in_list: bool, + /// Read E4X syntax. + e4x: bool, } impl<'s> Parser<'s> { pub(crate) fn new(src: &'s [u16], options: &crate::Options, keep_tokens: bool) -> Result { - let lex = Lexer::new(src, options.preprocessor_lines, options.max_depth); + let lex = Lexer::new(src, options.preprocessor_lines, options.max_depth).with_e4x(options.e4x); let placeholder = Token { kind: TokenKind::Eof, span: Span::default(), line: 1, column: 1, newline_before: false, escaped: false, legacy_octal: false }; let mut parser = Self { @@ -90,6 +94,7 @@ impl<'s> Parser<'s> { tokens: keep_tokens.then(Vec::new), function_allowed: false, in_list: false, + e4x: options.e4x, }; parser.tok = parser.next_significant()?; Ok(parser) @@ -391,6 +396,7 @@ impl<'s> Parser<'s> { self.semicolon()?; Ok(self.ast.push_stmt(Stmt::Debugger, self.span_from(start))) } + Keyword::Default if self.at_default_xml_namespace() => self.parse_default_xml_namespace(start), _ => { let expr = self.parse_expression(false)?; self.semicolon()?; @@ -488,7 +494,14 @@ impl<'s> Parser<'s> { fn parse_for(&mut self, start: Span) -> Result { self.bump()?; + let each = self.e4x && self.tok.ident() == Some("each"); + if each { + self.bump()?; + } self.expect(Punct::LParen)?; + if each { + return self.parse_for_each(start); + } let init = if self.at(Punct::Semicolon) { None } else if self.at_keyword(Keyword::Var) { @@ -501,14 +514,14 @@ impl<'s> Parser<'s> { if self.ctx.strict && decl.init.is_some() { return Err(self.error_at_token(&self.tok, "a for-in variable cannot have an initializer in strict mode code")); } - return self.parse_for_in(ForInTarget::Var(decl), start); + return self.parse_for_in(ForInTarget::Var(decl), start, false); } Some(ForInit::Var(decls)) } else { let expr = self.parse_expression(true)?; if self.at_keyword(Keyword::In) { self.check_target(expr, "invalid left-hand side in for-in loop")?; - return self.parse_for_in(ForInTarget::Expr(expr), start); + return self.parse_for_in(ForInTarget::Expr(expr), start, false); } Some(ForInit::Expr(expr)) }; @@ -522,12 +535,27 @@ impl<'s> Parser<'s> { } /// The rest of `for (target in object) body`, from `in`. - fn parse_for_in(&mut self, target: ForInTarget, start: Span) -> Result { + fn parse_for_in(&mut self, target: ForInTarget, start: Span, each: bool) -> Result { self.expect_keyword(Keyword::In)?; let object = self.parse_expression(false)?; self.expect(Punct::RParen)?; let body = self.parse_loop_body()?; - Ok(self.ast.push_stmt(Stmt::ForIn { target, object, body }, self.span_from(start))) + Ok(self.ast.push_stmt(Stmt::ForIn { target, object, body, each }, self.span_from(start))) + } + + /// `for each (target in object) body`, from the target (ECMA-357 section 12.3). + fn parse_for_each(&mut self, start: Span) -> Result { + if self.at_keyword(Keyword::Var) { + self.bump()?; + let mut decls = self.parse_var_decls(true)?; + let (Some(decl), true) = (decls.pop(), decls.is_empty()) else { + return Err(self.error_at_token(&self.tok, "for each takes one variable")); + }; + return self.parse_for_in(ForInTarget::Var(decl), start, true); + } + let expr = self.parse_expression(true)?; + self.check_target(expr, "invalid left-hand side in for each loop")?; + self.parse_for_in(ForInTarget::Expr(expr), start, true) } /// `break` or `continue`, with an optional label on the same line, which must name a label @@ -608,7 +636,7 @@ impl<'s> Parser<'s> { let test = if self.at_keyword(Keyword::Case) { self.bump()?; Some(self.parse_expression(false)?) - } else if self.at_keyword(Keyword::Default) { + } else if self.at_keyword(Keyword::Default) && !self.at_default_xml_namespace() { if has_default { return Err(self.error_at_token(&self.tok, "more than one default clause in switch statement")); } @@ -620,7 +648,10 @@ impl<'s> Parser<'s> { }; self.expect(Punct::Colon)?; let mut body = Vec::new(); - while !(self.at_keyword(Keyword::Case) || self.at_keyword(Keyword::Default) || self.at(Punct::RBrace)) { + while !(self.at_keyword(Keyword::Case) + || (self.at_keyword(Keyword::Default) && !self.at_default_xml_namespace()) + || self.at(Punct::RBrace)) + { if self.tok.kind == TokenKind::Eof { return Err(self.unexpected()); } @@ -953,8 +984,13 @@ impl<'s> Parser<'s> { let mut expr = self.parse_primary()?; loop { match &self.tok.kind { + TokenKind::Punct(Punct::DotDot) => expr = self.parse_xml_descendants(expr)?, TokenKind::Punct(Punct::Dot) => { self.bump()?; + if self.e4x && self.at_e4x_member() { + expr = self.parse_xml_member(expr)?; + continue; + } let Some(name) = self.tok.identifier_name().map(str::to_string) else { return Err(self.error_at_token(&self.tok, format!("expected a property name but found {}", describe(&self.tok)))); }; @@ -1050,10 +1086,16 @@ impl<'s> Parser<'s> { self.expect(Punct::RParen)?; return Ok(self.ast.push_expr(Expr::Paren(inner), self.span_from(start))); } + TokenKind::Punct(Punct::Lt) if self.e4x => return self.parse_xml_literal(), + TokenKind::Punct(Punct::At | Punct::Star) if self.e4x => return self.parse_xml_scope_name(), _ => return Err(self.unexpected()), }; let token = self.bump()?; - Ok(self.ast.push_expr(expr, token.span)) + let id = self.ast.push_expr(expr, token.span); + if self.e4x && self.at(Punct::ColonColon) && matches!(token.kind, TokenKind::Ident(_)) { + return self.parse_xml_qualified_primary(id); + } + Ok(id) } fn parse_function_expression(&mut self) -> Result { @@ -1232,5 +1274,6 @@ fn describe(token: &Token) -> String { TokenKind::String(_) => "string".into(), TokenKind::RegExp { .. } => "regular expression".into(), TokenKind::Directive(_) => "preprocessor line".into(), + TokenKind::XmlLiteral => "XML literal".into(), } } diff --git a/crates/js/src/parser/xml.rs b/crates/js/src/parser/xml.rs new file mode 100644 index 000000000..0b29bf879 --- /dev/null +++ b/crates/js/src/parser/xml.rs @@ -0,0 +1,435 @@ +//! E4X syntax (ECMA-357 sections 11.1 and 12.1): literals, names and the operators on them. +//! +//! A literal is read by scanning the source units with an explicit stack of open tags, so tag +//! nesting uses no native recursion and is held to [`crate::Options::max_depth`]. Only an embedded +//! `{expression}` re-enters the expression parser, and that recursion is counted as any other. + +use super::{Parser, Result, describe}; +use crate::ast::{Expr, ExprId, Stmt, StmtId, XmlNamePart, XmlPart, XmlQualifier}; +use crate::error::TOO_DEEP; +use crate::token::{Keyword, Punct, Span, TokenKind}; +use crate::xml::{is_name_part, is_name_start, is_xml_space, scan_reference, valid_xml_units}; + +const LT: u16 = b'<' as u16; +const GT: u16 = b'>' as u16; +const SLASH: u16 = b'/' as u16; +const AMP: u16 = b'&' as u16; +const LBRACE: u16 = b'{' as u16; +const EQ: u16 = b'=' as u16; +const DQUOTE: u16 = b'"' as u16; +const SQUOTE: u16 = b'\'' as u16; + +/// A literal being read: the markup since the last embedded expression, and the finished parts. +#[derive(Default)] +struct Scan { + text: Vec, + parts: Vec, +} + +impl Scan { + fn flush(&mut self) { + if !self.text.is_empty() { + self.parts.push(XmlPart::Text(std::mem::take(&mut self.text))); + } + } +} + +impl Parser<'_> { + pub(super) fn at_default_xml_namespace(&self) -> bool { + self.e4x && self.peek().is_some_and(|t| t.ident() == Some("xml")) + } + + /// `default xml namespace = expression;`, from `default`. + pub(super) fn parse_default_xml_namespace(&mut self, start: Span) -> Result { + self.expect_keyword(Keyword::Default)?; + self.bump()?; + if self.tok.ident() != Some("namespace") { + return Err(self.error_at_token(&self.tok, format!("expected 'namespace' but found {}", describe(&self.tok)))); + } + self.bump()?; + self.expect(Punct::Eq)?; + let value = self.parse_expression(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::DefaultXmlNamespace(value), self.span_from(start))) + } + + /// After a `.`: the next token starts an E4X name or a filter rather than a property name. + pub(super) fn at_e4x_member(&self) -> bool { + match &self.tok.kind { + TokenKind::Punct(Punct::At | Punct::Star | Punct::LParen) => true, + _ => self.tok.identifier_name().is_some() && self.peek().is_some_and(|t| t.is_punct(Punct::ColonColon)), + } + } + + /// `object.@name`, `object.*`, `object.ns::name` or `object.(predicate)`, from the token after + /// the `.`. + pub(super) fn parse_xml_member(&mut self, object: ExprId) -> Result { + let start = self.ast.expr_span(object); + if self.at(Punct::LParen) { + self.bump()?; + let predicate = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + return Ok(self.ast.push_expr(Expr::XmlFilter { object, predicate }, self.span_from(start))); + } + let index = self.parse_xml_name()?; + Ok(self.ast.push_expr(Expr::Index { object, index }, self.span_from(start))) + } + + /// `object..name`, from the `..`. + pub(super) fn parse_xml_descendants(&mut self, object: ExprId) -> Result { + let start = self.ast.expr_span(object); + self.bump()?; + let name = self.parse_xml_name()?; + Ok(self.ast.push_expr(Expr::XmlDescendants { object, name }, self.span_from(start))) + } + + /// `@name`, `*` or `@*` standing alone as a primary expression. + pub(super) fn parse_xml_scope_name(&mut self) -> Result { + let start = self.tok.span; + let name = self.parse_xml_name()?; + Ok(self.ast.push_expr(Expr::XmlScopeName(name), self.span_from(start))) + } + + /// `qualifier::name` as a primary expression, from the `::`; `qualifier` is already read. + pub(super) fn parse_xml_qualified_primary(&mut self, qualifier: ExprId) -> Result { + let start = self.ast.expr_span(qualifier); + self.bump()?; + let name = self.parse_xml_name_part()?; + let qualifier = Some(XmlQualifier::Expr(qualifier)); + let id = self.ast.push_expr(Expr::XmlName { attribute: false, qualifier, name }, self.span_from(start)); + Ok(self.ast.push_expr(Expr::XmlScopeName(id), self.span_from(start))) + } + + /// An E4X name: `@`, then `*`, a name or `[expression]`, with an optional `qualifier::`. + fn parse_xml_name(&mut self) -> Result { + let start = self.tok.span; + let attribute = self.eat(Punct::At)?; + let first_is_star = self.at(Punct::Star); + let qualifier_ident = self.tok.identifier_name().map(str::to_string).filter(|_| matches!(self.tok.kind, TokenKind::Ident(_))); + let first = self.parse_xml_name_part()?; + let (qualifier, name) = if self.at(Punct::ColonColon) { + let qualifier = if first_is_star { + XmlQualifier::Any + } else if let Some(name) = qualifier_ident { + let span = self.span_from(start); + XmlQualifier::Expr(self.ast.push_expr(Expr::Ident(name), span)) + } else { + return Err(self.error_at_token(&self.tok, "expected a namespace name before '::'")); + }; + self.bump()?; + (Some(qualifier), self.parse_xml_name_part()?) + } else { + (None, first) + }; + Ok(self.ast.push_expr(Expr::XmlName { attribute, qualifier, name }, self.span_from(start))) + } + + fn parse_xml_name_part(&mut self) -> Result { + if self.eat(Punct::Star)? { + return Ok(XmlNamePart::Star); + } + if self.eat(Punct::LBracket)? { + let e = self.parse_expression(false)?; + self.expect(Punct::RBracket)?; + return Ok(XmlNamePart::Computed(e)); + } + let Some(name) = self.tok.identifier_name().map(str::to_string) else { + return Err(self.error_at_token(&self.tok, format!("expected an XML name but found {}", describe(&self.tok)))); + }; + self.bump()?; + Ok(XmlNamePart::Ident(name)) + } + + /// An XML or XMLList literal, from its `<` (the current token). + pub(super) fn parse_xml_literal(&mut self) -> Result { + let start = self.tok.span; + let token_slot = self.tokens.as_ref().map(Vec::len); + if let Some(tokens) = &mut self.tokens { + tokens.push(self.tok.clone()); + } + let mut pos = start.start as usize; + let list = self.unit(pos + 1) == Some(GT); + if list { + pos += 2; + } + let mut scan = Scan::default(); + // The name of each open element; `None` when it is computed. + let mut open: Vec>> = Vec::new(); + loop { + if !list && open.is_empty() && !scan.text.is_empty() | !scan.parts.is_empty() { + break; + } + let Some(c) = self.unit(pos) else { return Err(self.xml_error(pos, "unterminated XML literal")) }; + if c == LT { + if self.unit(pos + 1) == Some(SLASH) { + if open.is_empty() { + if list && self.unit(pos + 2) == Some(GT) { + pos += 3; + break; + } + return Err(self.xml_error(pos, "a closing tag has no matching open tag")); + } + pos = self.xml_close_tag(pos, &mut open, &mut scan)?; + } else if self.starts_with(pos, ""); + } + XmlKind::Pi => { + let target = self.xml_node(x)?.name.as_ref().map(|n| n.local.clone()).unwrap_or_else(|| Rc::from(Vec::new())); + out.push_str(self, ""); + } + XmlKind::Element => {} + } + let ns_mark = scope.len(); + let node = self.xml_node(x)?; + let declared = node.ns.clone(); + let attrs = node.attrs.clone(); + let name = node.name.clone().ok_or_else(|| Abrupt::Internal("element without a name".into()))?; + self.xml_take_fuel(declared.len().saturating_mul(scope.len() + 1))?; + let mut unnamed = Vec::new(); + for ns in declared { + if ns.prefix.is_none() { + unnamed.push(ns); + } else if !scope.iter().any(|a| a.uri == ns.uri && a.prefix == ns.prefix) { + self.xml_charge(XML_NS_BYTES)?; + scope.push(ns); + } + } + for ns in unnamed { + if !scope.iter().any(|a| a.uri == ns.uri) { + let prefix = fresh_prefix(scope); + self.xml_charge(XML_NS_BYTES)?; + scope.push(XmlNs { prefix: Some(prefix), uri: ns.uri }); + } + } + let prefix = self.out_prefix(&name, scope, false)?; + let mut attr_prefixes = Vec::new(); + for &a in &attrs { + self.use_fuel()?; + let an = self.xml_node(a)?.name.clone().ok_or_else(|| Abrupt::Internal("attribute without a name".into()))?; + attr_prefixes.push(self.out_prefix(&an, scope, true)?); + } + out.push_units(self, &[LT])?; + self.write_name(out, &prefix, &name.local)?; + for (&a, attr_prefix) in attrs.iter().zip(&attr_prefixes) { + let an = self.xml_node(a)?; + let local = an.name.as_ref().map(|q| q.local.clone()).unwrap_or_else(|| Rc::from(Vec::new())); + let value = an.value.flat(); + out.push_units(self, &[SPACE])?; + self.write_name(out, attr_prefix, &local)?; + out.push_units(self, &[EQ, DQUOTE])?; + self.write_escaped(out, &value, true)?; + out.push_units(self, &[DQUOTE])?; + } + for at in ns_mark..scope.len() { + let Some(ns) = scope.get(at).cloned() else { break }; + let ns_prefix = ns.prefix.unwrap_or_else(|| Rc::from(Vec::new())); + out.push_str(self, " xmlns")?; + if !ns_prefix.is_empty() { + out.push_units(self, &[COLON])?; + out.push_units(self, &ns_prefix)?; + } + out.push_units(self, &[EQ, DQUOTE])?; + self.write_escaped(out, &ns.uri, true)?; + out.push_units(self, &[DQUOTE])?; + } + let count = self.children_len(x)?; + if count == 0 { + scope.truncate(ns_mark); + return out.push_str(self, "/>"); + } + out.push_units(self, &[GT])?; + let first_is_text = match self.child_at(x, 0)? { + Some(c) => self.xml_kind(c)? == XmlKind::Text, + None => false, + }; + let pretty_children = pretty && (count > 1 || !first_is_text); + let child_indent = if pretty_children { indent.saturating_add(u64::from(settings.indent)) } else { 0 }; + stack.push(OutFrame { node: x, next: 0, indent, child_indent, pretty_children, ns_mark, prefix, local: name.local }); + Ok(()) + } + + fn write_node(&mut self, out: &mut Builder, root: ObjId, indent: u64, settings: &Settings, pretty: bool) -> JsResult<()> { + let mut scope: Vec = Vec::new(); + let mut stack: Vec = Vec::new(); + self.write_enter(out, root, indent, settings, pretty, &mut scope, &mut stack)?; + while let Some(frame) = stack.last_mut() { + let (node, next, child_indent, pretty_children) = (frame.node, frame.next, frame.child_indent, frame.pretty_children); + frame.next += 1; + match self.child_at(node, next)? { + Some(child) => { + if pretty_children { + out.push_units(self, &[LF])?; + } + self.write_enter(out, child, child_indent, settings, pretty && pretty_children, &mut scope, &mut stack)?; + } + None => { + let Some(frame) = stack.pop() else { break }; + if frame.pretty_children { + out.push_units(self, &[LF])?; + out.push_repeated(self, &[SPACE], frame.indent)?; + } + out.push_units(self, &[LT, SLASH])?; + self.write_name(out, &frame.prefix, &frame.local)?; + out.push_units(self, &[GT])?; + scope.truncate(frame.ns_mark); + } + } + } + Ok(()) + } + + /// ToXMLString (ECMA-357 section 10.2). `pretty` overrides the setting when it is given. + pub(crate) fn xml_string_of(&mut self, v: &Value, pretty: Option) -> JsResult { + let settings = self.xml_settings()?; + let pretty = pretty.unwrap_or(settings.pretty); + let mut out = Builder::new(); + match self.classify(v) { + Xv::Node(n) => self.write_node(&mut out, n, 0, &settings, pretty)?, + Xv::List(l) => { + for i in 0..self.list_len(l)? { + self.use_fuel()?; + let Some(item) = self.list_at(l, i)? else { break }; + if i > 0 && pretty { + out.push_units(self, &[LF])?; + } + self.write_node(&mut out, item, 0, &settings, pretty)?; + } + } + Xv::Other => return Err(self.not_xml()), + } + let Value::String(s) = out.finish(self) else { return Err(Abrupt::Internal("not a string".into())) }; + Ok(s) + } + + // ---- the parser ---- + + fn syntax(&mut self, message: &str) -> Abrupt { + self.throw(ErrorType::Syntax, message) + } + + fn decode_into(&mut self, src: &[u16], from: usize, to: usize, out: &mut Vec, attribute: bool) -> JsResult<()> { + self.xml_text_fuel(to.saturating_sub(from))?; + let mut pos = from; + while pos < to { + let Some(&c) = src.get(pos) else { break }; + if c == AMP { + let Some((after, ch)) = scan_reference(src, pos).filter(|(after, _)| *after <= to) else { + return Err(self.syntax("invalid XML character or entity reference")); + }; + push_char(out, ch); + pos = after; + continue; + } + if pos + 1 < to && starts_pair(c, src.get(pos + 1).copied()) { + out.extend_from_slice(src.get(pos..pos + 2).unwrap_or_default()); + pos += 2; + continue; + } + if !is_xml_char(u32::from(c)) { + return Err(self.syntax("invalid character in XML text")); + } + pos += 1; + match (c, attribute) { + (CR, _) => { + if src.get(pos) == Some(&LF) { + pos += 1; + } + out.push(if attribute { SPACE } else { LF }); + } + (TAB | LF, true) => out.push(SPACE), + _ => out.push(c), + } + } + Ok(()) + } + + fn find_from(src: &[u16], from: usize, needle: &[u16]) -> Option { + src.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| from + i) + } + + fn read_name(&mut self, src: &[u16], pos: usize) -> JsResult { + if !src.get(pos).copied().is_some_and(is_name_start) { + return Err(self.syntax("invalid XML name")); + } + let mut end = pos + 1; + while src.get(end).copied().is_some_and(is_name_part) { + end += 1; + } + if !valid_xml_units(src.get(pos..end).unwrap_or_default()) { + return Err(self.syntax("invalid XML name")); + } + Ok(end) + } + + fn check_units(&mut self, src: &[u16], from: usize, to: usize) -> JsResult<()> { + if valid_xml_units(src.get(from..to).unwrap_or_default()) { + return Ok(()); + } + Err(self.syntax("invalid character in XML text")) + } + + /// Splits `prefix:local`. + fn split_name<'a>(&mut self, raw: &'a [u16]) -> JsResult<(Option<&'a [u16]>, &'a [u16])> { + let Some(colon) = raw.iter().position(|&c| c == COLON) else { return Ok((None, raw)) }; + let (prefix, rest) = raw.split_at(colon); + let local = rest.get(1..).unwrap_or_default(); + if !is_ncname(prefix) || !is_ncname(local) { + return Err(self.syntax("invalid XML qualified name")); + } + Ok((Some(prefix), local)) + } + + fn resolve_prefix(&mut self, prefix: &[u16], own: &[XmlNs], open: &[ObjId]) -> JsResult> { + if prefix == key("xml").as_ref() { + return Ok(Some(key(XML_URI))); + } + self.xml_take_fuel(own.len() + open.len() + 1)?; + if let Some(ns) = prefix_in_scope(own, prefix) { + return Ok(Some(ns.uri.clone())); + } + for &e in open.iter().rev() { + let node = self.xml_node(e)?; + let len = node.ns.len(); + if let Some(ns) = prefix_in_scope(&node.ns, prefix) { + return Ok(Some(ns.uri.clone())); + } + self.xml_take_fuel(len / UNITS_PER_FUEL)?; + } + Ok(None) + } + + fn place_parsed(&mut self, node: ObjId, open: &[ObjId], roots: &mut Vec) -> JsResult<()> { + if let Some(&top) = open.last() { + self.xml_charge_link(node)?; + self.xml_node_mut(top)?.children.push(node); + self.xml_node_mut(node)?.parent = Some(top); + } else { + self.xml_keep(node); + self.xml_charge(XML_REF_BYTES)?; + roots.push(node); + } + Ok(()) + } + + fn flush_text(&mut self, text: &mut Vec, ignore_whitespace: bool, open: &[ObjId], roots: &mut Vec) -> JsResult<()> { + if text.is_empty() { + return Ok(()); + } + if ignore_whitespace && text.iter().all(|&c| is_xml_space(c)) { + text.clear(); + return Ok(()); + } + let t = self.new_text_node(Str::from_units(std::mem::take(text)), None)?; + self.place_parsed(t, open, roots) + } + + fn parse_open_tag( + &mut self, + src: &[u16], + pos: usize, + open: &mut Vec, + names: &mut Vec<(usize, usize)>, + roots: &mut Vec, + ) -> JsResult { + if open.len() >= self.options.max_depth { + return Err(self.syntax(TOO_DEEP)); + } + let name_start = pos + 1; + let name_end = self.read_name(src, name_start)?; + let raw_name = src.get(name_start..name_end).unwrap_or_default(); + let (elem_prefix, elem_local) = self.split_name(raw_name)?; + let mut at = name_end; + let mut raw_attrs: Vec<(usize, usize, Vec)> = Vec::new(); + let closes = loop { + let before = at; + while src.get(at).copied().is_some_and(is_xml_space) { + at += 1; + } + match src.get(at).copied() { + None => return Err(self.syntax("unterminated XML tag")), + Some(SLASH) if src.get(at + 1) == Some(>) => { + at += 2; + break true; + } + Some(GT) => { + at += 1; + break false; + } + Some(_) if at == before => return Err(self.syntax("expected white space before an attribute")), + Some(_) => { + self.use_fuel()?; + let end = self.read_name(src, at)?; + let mut p = end; + while src.get(p).copied().is_some_and(is_xml_space) { + p += 1; + } + if src.get(p) != Some(&EQ) { + return Err(self.syntax("expected '=' after an attribute name")); + } + p += 1; + while src.get(p).copied().is_some_and(is_xml_space) { + p += 1; + } + let quote = match src.get(p).copied() { + Some(q @ (DQUOTE | SQUOTE)) => q, + _ => return Err(self.syntax("expected a quoted attribute value")), + }; + let value_start = p + 1; + let Some(value_end) = Self::find_from(src, value_start, &[quote]) else { + return Err(self.syntax("unterminated attribute value")); + }; + if src.get(value_start..value_end).is_some_and(|v| v.contains(<)) { + return Err(self.syntax("'<' cannot appear in an attribute value")); + } + let mut value = Vec::new(); + self.decode_into(src, value_start, value_end, &mut value, true)?; + raw_attrs.push((at, end, value)); + at = value_end + 1; + } + } + }; + self.xml_charge(XML_NS_BYTES.saturating_mul(raw_attrs.len()))?; + let mut ns: Vec = Vec::new(); + let mut seen_decl: HashSet> = HashSet::new(); + for (start, end, value) in &raw_attrs { + let raw = src.get(*start..*end).unwrap_or_default(); + if raw == key("xmlns").as_ref() { + if !seen_decl.insert(Vec::new()) { + return Err(self.syntax("a namespace is declared twice")); + } + ns.push(XmlNs { prefix: Some(Rc::from(Vec::new())), uri: Rc::from(value.as_slice()) }); + } else if let (Some(p), local) = self.split_name(raw)? + && p == key("xmlns").as_ref() + { + if value.is_empty() { + return Err(self.syntax("a namespace prefix cannot be declared with an empty URI")); + } + if !seen_decl.insert(local.to_vec()) { + return Err(self.syntax("a namespace is declared twice")); + } + ns.push(XmlNs { prefix: Some(Rc::from(local)), uri: Rc::from(value.as_slice()) }); + } + } + let uri = match elem_prefix { + Some(p) => match self.resolve_prefix(p, &ns, open)? { + Some(u) => u, + None => return Err(self.syntax("an XML name uses an undeclared namespace prefix")), + }, + None => match self.resolve_prefix(&[], &ns, open)? { + Some(u) => u, + None => self.xml_default_uri()?, + }, + }; + let name = XmlName { uri: Some(uri), local: Rc::from(elem_local), prefix: Some(Rc::from(elem_prefix.unwrap_or_default())) }; + let element = self.new_xml_node(XmlNode { + kind: XmlKind::Element, + name: Some(name), + parent: None, + value: Str::from(""), + attrs: Vec::new(), + children: Vec::new(), + ns, + })?; + self.place_parsed(element, open, roots)?; + let mut unique: HashSet<(Key, Vec)> = HashSet::new(); + for (start, end, value) in raw_attrs { + self.use_fuel()?; + let raw = src.get(start..end).unwrap_or_default(); + let (prefix, local) = self.split_name(raw)?; + if raw == key("xmlns").as_ref() || prefix.is_some_and(|p| p == key("xmlns").as_ref()) { + continue; + } + let (auri, stored_prefix): (Key, Key) = match prefix { + Some(p) => { + let own = self.xml_node(element)?.ns.clone(); + match self.resolve_prefix(p, &own, open)? { + Some(u) => (u, Rc::from(p)), + None => return Err(self.syntax("an XML name uses an undeclared namespace prefix")), + } + } + None => (Rc::from(Vec::new()), Rc::from(Vec::new())), + }; + if !unique.insert((auri.clone(), local.to_vec())) { + return Err(self.type_error("an XML element has two attributes with the same name")); + } + let a = self.new_xml_node(XmlNode { + kind: XmlKind::Attribute, + name: Some(XmlName { uri: Some(auri), local: Rc::from(local), prefix: Some(stored_prefix) }), + parent: Some(element), + value: Str::from_units(value), + attrs: Vec::new(), + children: Vec::new(), + ns: Vec::new(), + })?; + self.xml_charge_link(a)?; + self.xml_node_mut(element)?.attrs.push(a); + } + if !closes { + open.push(element); + names.push((name_start, name_end)); + } + Ok(at) + } + + fn parse_close_tag(&mut self, src: &[u16], pos: usize, open: &mut Vec, names: &mut Vec<(usize, usize)>) -> JsResult { + let name_start = pos + 2; + let name_end = self.read_name(src, name_start)?; + let mut at = name_end; + while src.get(at).copied().is_some_and(is_xml_space) { + at += 1; + } + if src.get(at) != Some(>) { + return Err(self.syntax("expected '>' at the end of a closing tag")); + } + let Some((open_start, open_end)) = names.pop() else { + return Err(self.syntax("a closing tag has no matching open tag")); + }; + if src.get(open_start..open_end) != src.get(name_start..name_end) { + return Err(self.syntax("the closing tag does not match the open tag")); + } + open.pop(); + Ok(at + 1) + } + + /// Parses XML text into nodes, kept reachable through the temporary roots until the caller's + /// scope ends. + pub(crate) fn xml_parse(&mut self, source: &[u16]) -> JsResult> { + let mut held = HeldBytes::new(self); + held.add(self, string_bytes(source.len()).saturating_mul(2))?; + let settings = self.xml_settings()?; + let mut src = source; + let mut offset_start = 0; + while src.get(offset_start).copied().is_some_and(is_xml_space) { + offset_start += 1; + } + if src.get(offset_start..).is_some_and(|r| r.starts_with(&parser_units("")) else { + return Err(self.syntax("unterminated XML declaration")); + }; + src = src.get(end + 2..).unwrap_or_default(); + } + let mut pos = 0; + let mut roots = Vec::new(); + let mut open: Vec = Vec::new(); + let mut names: Vec<(usize, usize)> = Vec::new(); + let mut text: Vec = Vec::new(); + while let Some(&c) = src.get(pos) { + self.use_fuel()?; + if c != LT { + let end = Self::find_from(src, pos, &[LT]).unwrap_or(src.len()); + let section_end = parser_units("]]>"); + if src.get(pos..end).is_some_and(|run| run.windows(3).any(|w| w == section_end)) { + return Err(self.syntax("']]>' cannot appear in XML text")); + } + self.decode_into(src, pos, end, &mut text, false)?; + pos = end; + continue; + } + let rest = src.get(pos..).unwrap_or_default(); + if rest.starts_with(&parser_units("")) else { + return Err(self.syntax("unterminated CDATA section")); + }; + self.xml_text_fuel(end - pos)?; + self.check_units(src, pos + 9, end)?; + text.extend_from_slice(src.get(pos + 9..end).unwrap_or_default()); + pos = end + 3; + continue; + } + self.flush_text(&mut text, settings.ignore_whitespace, &open, &mut roots)?; + if rest.starts_with(&parser_units("&y]]>');"; + truth(&format!("{src} t.children().length() === 5")); + truth(&format!("{src} t.children()[0].nodeKind() === 'text' && t.children()[0].toString() === 'x'")); + truth(&format!("{src} t.children()[1].nodeKind() === 'comment' && t.comments().length() === 1")); + truth(&format!("{src} t.children()[2].nodeKind() === 'processing-instruction' && t.children()[2].name().localName === 'pi'")); + truth(&format!("{src} t.children()[3].nodeKind() === 'element'")); + truth(&format!("{src} t.children()[4].toString() === '&y'")); + truth(&format!("{src} t.@p == '1' && t.@q == '<' && t.attributes().length() === 2 && t.@p.nodeKind() === 'attribute'")); +} + +#[test] +fn serialization_matches_the_fixed_markup() { + assert_eq!(string("(1).toXMLString()"), "\n 1\n \n"); + assert_eq!(string("XML.prettyPrinting = false; (1).toXMLString()"), "1"); + assert_eq!(string("XML.prettyIndent = 4; ().toXMLString()"), "\n \n"); + assert_eq!(string("(<&>).toXMLString()"), "<&>"); + assert_eq!(string("().toXMLString()"), "\n \n"); + assert_eq!(string("(<>).toXMLString()"), "\n"); +} + +#[test] +fn embedded_expressions_are_escaped_by_position() { + assert_eq!(string("var v = '<&>'; ({v}).toXMLString()"), "<&>"); + assert_eq!(string("var v = 'a\"<'; ().toXMLString()"), ""); + assert_eq!(string("var n = 'tag'; (<{n} x=\"1\">t).toXMLString()"), "t"); + assert_eq!(string("var c = 1; ({c}).toXMLString()"), "\n 1\n"); + assert_eq!(string("var a = 'p=\"1\"'; ().toXMLString()"), ""); + truth("var i = 0; var x = {i++}{i++}; x == '01' && i === 2"); + truth("var o = {t: 1}; ({o.t ? 'y' : 'n'}) == 'y'"); + truth("({'}'}) == '}'"); + truth("({1 < 2}) == 'true'"); +} + +// ---- selectors and updates ---- + +#[test] +fn selectors_return_the_documented_lists() { + let s = |rest: &str| truth(&format!("{BOOKS} {rest}")); + s("xml.book.length() === 4 && xml.book[0].author == 'Farmer'"); + s("xml.book[0].@category == 'COOKING' && xml.book.@category.length() === 4"); + s("xml.book[1].@category.toString() === 'CHILDREN'"); + s("xml.*.length() === 4 && xml.book[0].*.length() === 3"); + s("xml.book[0].@*.length() === 1"); + s("xml.nothing.length() === 0 && xml.@nothing.length() === 0"); + s("xml..title.length() === 4 && xml..title == xml.descendants('title')"); + s("xml..@lang.length() === 4 && xml..@category.length() === 4"); + s("xml.book.(@category == 'CHILDREN').length() === 2"); + s("xml.book.(year > 1899).title.length() === 2"); + s("xml.book.(author == 'Baum').title == 'Oz'"); + s("xml.*::book.length() === 4"); + s("var n = null; xml.n::book.length() === 4"); + s("xml['book'].length() === 4 && xml.book[0]['author'] == 'Farmer' && xml.book[0]['@category'] == 'COOKING'"); + s("var k = 'category'; xml.book[0].@[k] == 'COOKING'"); +} + +#[test] +fn the_top_sample_has_three_children_two_elements_and_seven_descendants() { + truth(&format!("{TOP} x.children().length() === 3")); + truth(&format!("{TOP} x.elements().length() === 2")); + truth(&format!("{TOP} x.descendants().length() === 7")); +} + +#[test] +fn a_filter_tests_each_item_once_in_order() { + truth( + "var seen = []; var l = <>123; var r = l.(seen.push(toString()), true); seen.join() === '1,2,3' && r.length() === 3", + ); + truth("().(false).length() === 0"); + assert_eq!(thrown_name("({}).(1)"), "TypeError"); + assert_eq!(thrown_name("var o = {}; o.@a"), "TypeError"); + assert_eq!(thrown_name("var o = {}; o..a"), "TypeError"); +} + +#[test] +fn assignment_and_delete_update_the_tree() { + let s = |rest: &str| truth(&format!("{BOOKS} {rest}")); + s( + "xml.book[2].author = 'Dodgeson'; xml.book[2].author.length() === 1 && xml.book[2].author == 'Dodgeson' && xml.book[2].*[1].localName() === 'author'", + ); + s("xml.book[2].author[0] = 'First'; xml.book[2].author.length() === 3 && xml.book[2].author[0] == 'First'"); + s("xml.book[1].year = 1901; xml.book[1].year == '1901'"); + s("xml.book[1].rating = '*****'; xml.book[1].*[3].localName() === 'rating' && xml.book[1].rating == '*****'"); + s("xml.book[1].@category = 'FANTASY'; xml.book[1].@category == 'FANTASY'"); + s("xml.book[1].@added = 'y'; xml.book[1].@added == 'y' && xml.book[1].attributes().length() === 2"); + s("delete xml.book[2].author; xml.book[2].author.length() === 0"); + s("delete xml.book[2].author[1]; xml.book[2].author.length() === 2 && xml.book[2].author[1] == 'C'"); + s("delete xml.book[2].@category; xml.book[2].@category.length() === 0"); + s("xml.book[0] = ; xml.book.length() === 4 && xml.book[0].@id == 'new' && xml.book[0].parent() === xml"); + assert_eq!(thrown_name("var x = ; x[0] = 1"), "TypeError"); + assert_eq!(thrown_name("var x = ; delete x[0]"), "TypeError"); + truth("var x = ; x.b.c = 'd'; x.b.c == 'd'"); + truth("var x = ; x.b += 'v'; x.b == 'v'"); + truth("var x = 1; x.n++; x.n == '2'"); + truth("var x = ; x.@t++; x.@t == '2'"); +} + +#[test] +fn operators_follow_the_standard() { + truth("var l = + ; typeof l === 'xml' && l.length() === 2"); + truth("var l = <> + ; l.length() === 2"); + truth("typeof === 'xml' && typeof <> === 'xml' && typeof new Namespace() === 'object'"); + truth("1 == 1 && !(1 == 2)"); + truth(" == "); + truth("1 == '1' && '1' == 1 && 1 == 1"); + truth("!(01 == 1) && true == true && null == null && undefined == undefined && !(x == null)"); + truth("<> == <> && !(<> == <>)"); + truth("new XMLList() == undefined"); + truth("new Namespace('p', 'u') == new Namespace('q', 'u') && new QName('u', 'a') == new QName('u', 'a')"); + truth("var s = ''; for (var i in <>) s += i; s === '01'"); + truth("var s = ''; for each (var v in <>12) s += v; s === '12'"); + truth("var s = ''; for each (var v in [3, 4]) s += v; s === '34'"); + truth("var s = ''; for each (var v in 7) s += v; s === '7'"); + truth("var x = 5; x.length == '5' && x.length() === 1"); + truth("var x = abc; x.charAt(1) === 'b'"); + truth("var l = <>abc; l.toUpperCase() === 'ABC'"); + assert_eq!(thrown_name("().noSuchMethod()"), "TypeError"); + assert_eq!(thrown_name("(<>).nodeKind()"), "TypeError"); + truth("with (1) { b == '1' }"); +} + +#[test] +fn the_default_namespace_is_scoped_to_the_function() { + truth( + "var ns = new Namespace('u'); default xml namespace = ns; var x = ; x.name().uri === 'u' && x.b.length() === 1 && x.b.name().uri === 'u'", + ); + truth("default xml namespace = 'u'; ().@p.name().uri === ''"); + truth( + "function f() { default xml namespace = 'u'; return ().name().uri; } \ + f() === 'u' && ().name().uri === ''", + ); + truth("function f() { default xml namespace = 'u'; return function () { return ().name().uri; }; } f()() === 'u'"); + truth("setDefaultXMLNamespace(new Namespace('p', 'v')); ().name().uri === 'v' && new QName('b').uri === 'v'"); + truth("default xml namespace = 'u'; new QName('a').uri === 'u' && new QName('*').uri === null"); + truth("var x = ; x.b.length() === 0"); + truth("var x = ; var n = new Namespace('u'); x.n::b.length() === 1"); + truth( + "var soap = new Namespace('http://schemas.xmlsoap.org/soap/envelope/'); default xml namespace = soap; \ + var m = ; \ + m.@soap::encodingStyle == 'e' && m.Body.length() === 1", + ); +} + +// ---- built-ins ---- + +#[test] +fn the_constructors_keep_identity_or_copy_as_specified() { + truth("XML().nodeKind() === 'text' && XML(null) == '' && XML(undefined) == ''"); + truth("var x = ; XML(x) === x && new XML(x) !== x && new XML(x) == x"); + truth("var l = <>; XML(l) === l[0]"); + assert_eq!(thrown_name("XML(<>)"), "TypeError"); + assert_eq!(thrown_name("XML('')"), "SyntaxError"); + assert_eq!(thrown_name("XML('')"), "SyntaxError"); + truth("XMLList().length() === 0 && XMLList('').length() === 2"); + truth("var l = <>; XMLList(l) === l && new XMLList(l) !== l && new XMLList(l)[0] === l[0]"); + truth("var n = new Namespace('p', 'u'); Namespace(n) === n && new Namespace(n) !== n && new Namespace(n).prefix === 'p'"); + truth("var n = new Namespace(); n.prefix === '' && n.uri === '' && n.toString() === ''"); + truth("var n = new Namespace('u'); n.prefix === undefined && n.uri === 'u'"); + truth("new Namespace('1bad', 'u').prefix === undefined"); + assert_eq!(thrown_name("new Namespace('p', '')"), "TypeError"); + truth("var q = new QName('u', 'a'); QName(q) === q && new QName(q) !== q && q.uri === 'u' && q.localName === 'a'"); + truth("var q = new QName(new Namespace('p', 'u'), 'a'); q.uri === 'u' && q.toString() === 'u::a'"); + truth("new QName('*').toString() === '*::*' && new QName(null, 'a').uri === null && new QName().localName === ''"); + truth("isXMLName('abc') && isXMLName('a-b') && !isXMLName('a.b') && !isXMLName('$a') && !isXMLName('1a') && !isXMLName('')"); + truth("().localName() === 'a.b'"); +} + +#[test] +fn the_settings_are_per_realm_and_restore() { + truth( + "var d = XML.defaultSettings(); !d.ignoreComments && !d.ignoreProcessingInstructions && d.ignoreWhitespace && d.prettyPrinting && d.prettyIndent === 2", + ); + truth("var s = XML.settings(); XML.prettyIndent = 7; XML.settings().prettyIndent === 7 && (XML.setSettings(s), XML.prettyIndent === 2)"); + truth("XML.prettyIndent = 7; XML.setSettings(); XML.prettyIndent === 2"); + truth("XML.setSettings({ignoreWhitespace: false, prettyIndent: 'bad'}); !XML.ignoreWhitespace && XML.prettyIndent === 2"); + truth("(new XML('')).children().length() === 2"); + truth("XML.ignoreComments = true; XML.ignoreProcessingInstructions = true; (new XML('')).children().length() === 0"); + truth("XML.ignoreWhitespace = false; (new XML(' ')).children().length() === 3"); + truth("(new XML(' ')).children().length() === 1"); + truth("(new XML(' t ')).toString() === ' t '"); + let mut first = realm(); + first.run("XML.prettyIndent = 9").unwrap(); + assert_eq!(realm().run("XML.prettyIndent"), Ok(JsValue::Number(2.0))); +} + +#[test] +fn the_xml_methods_behave_as_specified() { + truth("var x = ; x.appendChild() === x && x.*[1].localName() === 'c' && x.c.parent() === x"); + truth("var x = ; x.appendChild('t'); x.text() == 't'"); + truth("var x = ; x.prependChild(); x.*[0].localName() === 'c'"); + truth("var x = ; x.insertChildAfter(x.b[0], ); x.*[1].localName() === 'c'"); + truth("var x = ; x.insertChildBefore(x.d[0], ); x.*[1].localName() === 'c'"); + truth("var x = ; x.insertChildAfter(null, ); x.*[0].localName() === 'c'"); + truth("var x = ; x.insertChildBefore(null, ); x.*[1].localName() === 'c'"); + truth("var x = ; x.insertChildAfter(, ) === undefined && x.*.length() === 1"); + truth("var x = ; x.c[0].childIndex() === 1 && x.childIndex() === -1"); + truth("var x = ; x.attribute('p') == '1' && x.attributes().length() === 1"); + truth("var x = t; x.child('b').length() === 1 && x.child(1) == 't' && x.child(9).length() === 0"); + truth("var x = ; x.copy() !== x && x.copy() == x && x.copy().parent() === null && x.b.copy().parent() === undefined"); + truth("var c = ().b[0].copy(); c.parent() === null"); + truth("var x = ; x.contains()"); + truth("var x = ; x.hasOwnProperty('b') && !x.hasOwnProperty('c') && x.hasComplexContent() && !x.hasSimpleContent()"); + truth("var x = t; !x.hasComplexContent() && x.hasSimpleContent() && x.length() === 1"); + truth( + "var x = ; x.b[0].inScopeNamespaces().length === 1 && x.namespaceDeclarations()[0].prefix === 'p' && x.b[0].namespaceDeclarations().length === 0", + ); + truth("var x = ; x.namespace('p').uri === 'u' && x.namespace('q') === undefined && x.namespace().uri === ''"); + truth("var x = ; x.addNamespace(new Namespace('p', 'u')); x.namespace('p').uri === 'u'"); + truth("var x = ; x.removeNamespace(new Namespace('p', 'u')); x.namespace('p') === undefined"); + truth("var x = ; x.removeNamespace(new Namespace('p', 'u')); x.namespace('p').uri === 'u'"); + truth("var x = ; x.name().localName === 'a' && x.localName() === 'a'"); + truth("(t).*[0].name() === null && (t).*[0].localName() === null"); + truth("var x = 1; x.b[0].appendChild('2'); x.b[0].*.length() === 2 && (x.normalize(), x.b[0].*.length() === 1) && x.b == '12'"); + truth("var x = ; x.b[0].parent() === x && x.parent() === null"); + truth("var x = ; x.processingInstructions().length() === 2 && x.processingInstructions('r').length() === 1"); + truth("var x = ; x.propertyIsEnumerable(0) && !x.propertyIsEnumerable(1)"); + truth("var x = ; x.replace('b', ); x.*.length() === 2 && x.*[0].localName() === 'z' && x.*[1].localName() === 'c'"); + truth("var x = ; x.replace(0, 't'); x.* == 't'"); + truth("var x = ; x.replace('nothing', ) === x && x.b.length() === 1"); + truth("var x = ; x.setChildren( + ) === x && x.*.length() === 2 && x.c.length() === 1"); + truth("var x = ; x.setLocalName('b'); x.localName() === 'b'"); + truth("var x = ; x.setName('b'); x.localName() === 'b'"); + truth("var x = ; x.setNamespace(new Namespace('p', 'u')); x.name().uri === 'u' && x.toXMLString() === ''"); + assert_eq!(thrown_name("().setLocalName('1 x')"), "TypeError"); + assert_eq!(thrown_name("().setName('a b')"), "TypeError"); + assert_eq!(string("(1).toString()"), "1"); + assert_eq!(string("().toString()"), "\n \n"); + truth("var x = ; x.valueOf() === x"); + truth("().xpath === undefined || ().xpath.length() === 0"); + truth("var x = ; x.b.attributes !== undefined"); +} + +#[test] +fn the_xml_list_methods_work_item_by_item() { + let l = "var l = <>s;"; + truth(&format!("{l} l.length() === 2 && l.attribute('p').length() === 2 && l.attributes().length() === 2")); + truth(&format!("{l} l.child('x').length() === 1 && l.children().length() === 4 && l.child(0).length() === 2")); + truth(&format!("{l} l.comments().length() === 1 && l.elements().length() === 2 && l.text() == 's'")); + truth(&format!("{l} l.descendants().length() === 4")); + truth(&format!("{l} l.contains(s) && !l.contains()")); + truth(&format!("{l} l.copy()[0] !== l[0] && l.copy() == l")); + truth(&format!("{l} l.hasOwnProperty(0) && !l.hasOwnProperty(5) && l.propertyIsEnumerable(1) && !l.propertyIsEnumerable(2)")); + truth(&format!("{l} l.hasComplexContent() && !l.hasSimpleContent()")); + truth(&format!("{l} l.parent() === undefined && l.valueOf() === l")); + truth("var x = ; x.*.parent() === x"); + truth("new XMLList().hasSimpleContent() && !new XMLList().hasComplexContent() && new XMLList().parent() === undefined"); + truth("var l = <>12; l.normalize() === l && l.toString() === '1\\n2'"); + truth("var r = 1; var l = r.a; l[0] = 2; r.a == '2' && l[0] == '2'"); + truth("var r = 1; r.a[1] = 'n'; r.a.length() === 2 && r.a[1] == 'n' && r.*[1].parent() === r"); + truth("var r = 1; var list = r.b; list.c = 'v'; r.b.c == 'v'"); + truth("var r = ; r.a.@t[0] = '2'; r.a.@t == '2'"); + truth("var r = 1; r.a[0] = <>

; r.*.length() === 3 && r.*[0].localName() === 'p'"); +} + +#[test] +fn inserting_a_node_moves_it_out_of_its_old_parent() { + truth("var b = ; var a = ; var c = b.c[0]; a.appendChild(c); b.c.length() === 0 && a.c.length() === 1 && c.parent() === a"); + truth("var a = ; a.appendChild(a.b[0]); a.*.length() === 2 && a.*[0].localName() === 'c'"); + truth("var b = ; var a = ; var c = b.c[0]; a.appendChild(c); c.appendChild(b); a..*.length() === 2 && b.parent() === c"); +} + +#[test] +fn namespaces_survive_serialization() { + assert_eq!(string("var x = ; x.toXMLString()"), "\n \n"); + assert_eq!(string("var x = ; x.toXMLString()"), ""); + truth("var x = ; var y = new XML(x.toXMLString()); x == y && y.*[0].name().uri === 'u'"); + truth( + "var q = new QName(new Namespace('u'), 'a'); var x = new XML(''); x.appendChild(); x.*[0].setName(q); (new XML(x.toXMLString())).*[0].name().uri === 'u'", + ); + truth( + "var x = ; x.@[new QName('u', 'b')] = '1'; var y = new XML(x.toXMLString()); y.attributes()[0].name().uri === 'u' && y.attributes()[0] == '1'", + ); +} + +#[test] +fn the_garbage_collector_keeps_trees_and_lists_alive() { + let mut r = realm(); + r.run("var x = text; var l = x.b; var ns = new Namespace('p', 'u'); var q = new QName('u', 'n');").unwrap(); + r.collect_garbage(); + assert_eq!(r.run("x.b.@t == '1' && l[0] == 'text' && l[0].parent() === x && ns.uri === 'u' && q.localName === 'n'"), Ok(JsValue::Bool(true))); + r.run("x = l = ns = q = null;").unwrap(); + let live = r.collect_garbage(); + assert!(live < 100_000, "{live}"); +} + +// ---- hostile input ---- + +#[test] +fn malformed_literals_are_located_syntax_errors() { + let cases = [ + "var a = ", + "", + "", + "", + "", + "", + "&bad;", + "�", + "{1", + "{", + "<1x/>", + "", + "", + ",<", + "", + "", + "<>", + "", + ]; + for src in cases { + let e = parse(src, &e4x()).expect_err(src); + assert!(e.line >= 1 && e.column >= 1, "{src}: {e:?}"); + } + let e = parse("var a =\n ;", &e4x()).unwrap_err(); + assert_eq!(e.line, 2); +} + +#[test] +fn bad_xml_text_is_a_catchable_syntax_error() { + for s in [ + "", + "", + "&nope;", + "", + "", + "", + "", + "]]>", + "", + ] { + assert_eq!(thrown_name(&format!("new XML({s:?})")), "SyntaxError", "{s}"); + } + assert_eq!(thrown_name("new XML('')"), "TypeError"); +} + +#[test] +fn unpaired_surrogates_are_rejected_in_xml_text() { + let lone = "String.fromCharCode(0xD800)"; + let low = "String.fromCharCode(0xDC00)"; + for value in [ + format!("'' + {lone} + ''"), + format!("'' + {low} + ''"), + format!("''"), + format!("''"), + format!("''"), + format!("''"), + format!("'<' + {lone} + '/>'"), + ] { + assert_eq!(thrown_name(&format!("new XML({value})")), "SyntaxError", "{value}"); + } + assert_eq!(thrown_name(&format!("().setLocalName({lone})")), "TypeError"); + let e = parse("var a = <\u{FFFD}/>;", &e4x()).unwrap_err(); + assert_eq!((e.line, e.column), (1, 10)); + truth("var p = String.fromCharCode(0xD83D, 0xDE00); var x = new XML('' + p + ''); x == p && x.@b == p"); + truth("var p = String.fromCharCode(0xD83D, 0xDE00); new XML('<' + p + '/>').localName() === p"); +} + +#[test] +fn collections_during_parsing_and_copying_keep_new_nodes() { + let src = "for (var i = 0; i < 200; i++) { \ + var x = new XML('t'); var c = x.copy(); \ + if (c.@p != '1' || c.b.@r != '3' || c.*.length() !== 3 || x.@q != '2') throw new Error('lost'); \ + } 'done'"; + for spare in (16_000..20_000).step_by(37) { + let mut r = realm(); + let live = r.collect_garbage(); + r.set_limits(Limits { heap_bytes: live + spare, ..Limits::default() }); + assert_eq!(r.run(src), Ok(JsValue::String("done".into())), "{spare}"); + } +} + +#[test] +fn collections_during_updates_and_queries_keep_new_values() { + let src = "for (var i = 0; i < 60; i++) { \ + var x = new XML('t'); var c = x.copy(); \ + var l = <>{x.b}{'t'}; \ + c.appendChild(); c.prependChild('s'); c.insertChildAfter(c.b[0], ); c.insertChildBefore(c.b[0], 'n'); \ + c.replace(0, 1); c.n.o = 'v'; c.@t = 'w'; c.z[0] = ; c.g.h = 'deep'; \ + var f = x.*.(nodeKind() === 'element'); var d = x..*; var j = x + c; var m = x.b + ; \ + var ns = new Namespace('p', 'u'); var qn = new QName(ns, 'a'); c.addNamespace(ns); c.setNamespace(ns); c.setName(qn); \ + var scope = c.inScopeNamespaces(); var decl = c.namespaceDeclarations(); c.normalize(); \ + var text = c.toXMLString(); var again = new XML(text); var children = c.children(); var el = c.elements(); var attrs = c.attributes(); \ + var filled = new XMLList(l); var copied = l.copy(); c.setChildren(); \ + if (x.@p != '1' || x.*.length() !== 3 || f.length() !== 1 || j.length() !== 2 || m.length() !== 2) throw new Error('lost'); \ + if (l.length() !== 3 || copied.length() !== 3 || c.*.length() !== 1 || scope.length < 1 || d.length() !== 3) throw new Error('lost'); \ + } 'done'"; + let mut first_error = None; + let mut completed = 0; + for spare in (40_000..56_000).step_by(211) { + let mut r = realm(); + let live = r.collect_garbage(); + r.set_limits(Limits { heap_bytes: live + spare, ..Limits::default() }); + let got = r.run(src); + if got == Ok(JsValue::String("done".into())) { + completed += 1; + } else if !matches!(&got, Err(e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) { + first_error = Some(format!("{spare}: {got:?}")); + break; + } + } + assert_eq!(first_error, None); + assert!(completed > 0); +} + +#[test] +fn a_scope_name_skips_a_list_without_that_name() { + truth("var outer = ; var inner = <>; with (outer) with (inner) { @id == '1' }"); + truth("var outer = ; var inner = <>; with (outer) with (inner) { @id == '2' }"); +} + +#[test] +fn a_bracket_key_converts_to_a_string_before_it_names_a_child() { + truth("var x = 1; x[true] == '1' && (true in x) && !(false in x)"); + truth("var x = 1; x[null] == '1' && x[undefined].length() === 0 && x[1.5].length() === 0 && x[-1].length() === 0"); + truth("var x = ; x[false] = 'v'; x['false'] == 'v' && (delete x[false], x.*.length() === 0)"); + assert_eq!(thrown_name("().attribute(true)"), "TypeError"); + assert_eq!(thrown_name("().descendants(null)"), "TypeError"); +} + +fn kind_of(limits: Limits, src: &str) -> RunErrorKind { + match limited(limits).run(src) { + Ok(v) => panic!("{} completed with {v:?}", &src[..src.len().min(80)]), + Err(e) => e.kind, + } +} + +fn on_small_stack(f: impl FnOnce() -> T + Send + 'static) -> T { + std::thread::Builder::new().stack_size(512 << 10).spawn(f).unwrap().join().unwrap() +} + +#[test] +fn deep_trees_end_in_errors_on_a_small_stack() { + on_small_stack(|| { + let tags = 100_000; + let literal = format!("{}{}", "".repeat(tags), "".repeat(tags)); + let e = parse(&literal, &e4x()).unwrap_err(); + assert_eq!(e.message, crate::TOO_DEEP); + let text = format!("new XML('{literal}')"); + assert_eq!(limited(Limits::default()).run(&text).map_err(|e| e.message.contains(crate::TOO_DEEP)), Err(true)); + let chain = "var x = ; for (var i = 0; i < 100000; i++) { var y = ; y.appendChild(x); x = y; }"; + assert_eq!(kind_of(Limits::default(), chain), RunErrorKind::Limit(LimitKind::Heap)); + let within = "var x = ; for (var i = 0; i < 150; i++) { var y = ; y.appendChild(x); x = y; } \ + var c = x.copy(); [c == x, x.descendants().length(), x.toXMLString().length > 0, (x.normalize(), 1)].join()"; + assert_eq!(limited(Limits::default()).run(within), Ok(JsValue::String("true,150,true,1".into()))); + }); +} + +#[test] +fn cyclic_insertion_throws_and_leaves_the_tree_alone() { + for src in [ + "x.appendChild(x)", + "x.b[0].appendChild(x)", + "x.b[0].c[0].appendChild(x.b[0])", + "x.b[0].prependChild(x)", + "x.b[0].insertChildAfter(null, x)", + "x.b[0].insertChildBefore(null, x)", + ] { + let setup = "var x = ; var before = x.toXMLString();"; + let script = format!("{setup} var n; try {{ {src}; n = 'none' }} catch (e) {{ n = e.name }} x.toXMLString() === before ? n : 'changed'"); + assert_eq!(string(&script), "TypeError", "{src}"); + } +} + +#[test] +fn replace_inserts_a_copy_so_an_ancestor_can_be_the_value() { + truth("var x = ; x.b[0].replace(0, x); x.b.a.b.c.length() === 1 && x.b.a[0] !== x && x.descendants().length() === 4"); +} + +const TIGHT: Limits = Limits { fuel: 200_000, heap_bytes: 1 << 20, frames: 200, reentry: 8 }; + +#[test] +fn big_values_end_with_the_heap_limit() { + let attrs = format!("var x = new XML('');", (0..50_000).map(|i| format!("a{i}=\"1\"")).collect::>().join(" ")); + assert_eq!(kind_of(Limits { fuel: 10_000_000, ..TIGHT }, &attrs), RunErrorKind::Limit(LimitKind::Heap)); + let wide = "var l = <>; for (;;) { l = l + l; }"; + assert_eq!(kind_of(Limits { fuel: 10_000_000, ..TIGHT }, wide), RunErrorKind::Limit(LimitKind::Heap)); + let giant = "var s = 'x'; for (var i = 0; i < 40; i++) s += s; new XML('' + s + '')"; + assert_eq!(kind_of(Limits { fuel: 10_000_000, ..TIGHT }, giant), RunErrorKind::Limit(LimitKind::Heap)); + let escaped = "var s = '<'; for (var i = 0; i < 17; i++) s += s; ({s}).toXMLString().length"; + assert_eq!(kind_of(Limits { fuel: 10_000_000, ..TIGHT }, escaped), RunErrorKind::Limit(LimitKind::Heap)); +} + +#[test] +fn catastrophic_queries_end_with_the_fuel_limit() { + let build = "var x = ; for (var i = 0; i < 2000; i++) x.appendChild();"; + let limits = Limits { fuel: 1_500_000, heap_bytes: 64 << 20, frames: 200, reentry: 8 }; + let filter = format!("{build} for (;;) x.a.(true);"); + assert_eq!(kind_of(limits.clone(), &filter), RunErrorKind::Limit(LimitKind::Fuel)); + let descendants = format!("{build} for (;;) x..a;"); + assert_eq!(kind_of(limits.clone(), &descendants), RunErrorKind::Limit(LimitKind::Fuel)); + let serialize = format!("{build} for (;;) x.toXMLString();"); + assert_eq!(kind_of(limits, &serialize), RunErrorKind::Limit(LimitKind::Fuel)); +} + +#[test] +fn limits_cannot_be_caught_by_the_script() { + assert_eq!(kind_of(TIGHT, "try { for (;;) ().toXMLString(); } catch (e) { 1 }"), RunErrorKind::Limit(LimitKind::Fuel)); +} + +#[test] +fn an_interrupt_stops_serialization() { + let mut r = realm(); + r.run("var x = ; for (var i = 0; i < 500; i++) x.appendChild();").unwrap(); + let interrupt = r.interrupt(); + r.define_function("stop", move |_| { + interrupt.set(); + Ok(JsValue::Undefined) + }); + assert_eq!(r.run("stop(); x.toXMLString()").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Interrupted))); +} + +// ---- generated input ---- + +const PIECES: &[&str] = &[ + "<", + ">", + "/", + "", + "", + "", + "", + "", + "<>", + "", + "{", + "}", + "{1}", + "'", + "\"", + "&", + "&", + "A", + "&bad;", + "", + "", + "", + "@", + "::", + "..", + ".(", + ")", + "*", + "/x/", + " ", + "\n", + "a", + "1", + "=", + "ns", + ":", + "xmlns", + "x.@a", + "x..y", + "default xml namespace", +]; + +fn tree_markup(shape: &[u8]) -> String { + let mut out = String::new(); + let mut open: Vec = Vec::new(); + out.push_str(""); + for (i, &b) in shape.iter().enumerate() { + match b % 6 { + 0 if open.len() < 20 => { + out.push_str(&format!("")); + open.push(i); + } + 1 => { + if let Some(e) = open.pop() { + out.push_str(&format!("")); + } + } + 2 => out.push_str(&format!("")), + 3 => out.push_str(&format!("t{b}&")), + 4 => out.push_str(&format!("")), + _ => out.push_str(&format!("")), + } + } + while let Some(e) = open.pop() { + out.push_str(&format!("")); + } + out.push_str(""); + out +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn source_mixes_parse_or_give_a_located_error(parts in proptest::collection::vec(0..PIECES.len(), 0..40)) { + let src: String = parts.iter().map(|&i| PIECES[i]).collect(); + if let Err(e) = parse(&src, &e4x()) { + prop_assert!(e.line >= 1 && e.column >= 1, "{src:?}: {e:?}"); + } + } + + #[test] + fn random_programs_never_reach_an_internal_error(parts in proptest::collection::vec(0..PIECES.len(), 0..40)) { + let src: String = format!("var x = ; {}", parts.iter().map(|&i| PIECES[i]).collect::()); + let mut r = limited(Limits { fuel: 20_000, heap_bytes: 1 << 20, frames: 200, reentry: 8 }); + if let Err(e) = r.run(&src) { + prop_assert!(e.kind != RunErrorKind::Internal, "{src:?}: {e:?}"); + } + } + + #[test] + fn xml_text_parses_or_throws(parts in proptest::collection::vec(0..PIECES.len(), 0..40)) { + let text: String = parts.iter().map(|&i| PIECES[i]).collect(); + let quoted = format!("{text:?}"); + let mut r = limited(Limits { fuel: 200_000, heap_bytes: 4 << 20, frames: 200, reentry: 8 }); + if let Err(e) = r.run(&format!("new XML({quoted})")) { + prop_assert!(e.kind == RunErrorKind::Thrown, "{quoted}: {e:?}"); + } + } + + #[test] + fn serialized_trees_parse_to_an_equal_tree(shape in proptest::collection::vec(any::(), 0..80)) { + let markup = tree_markup(&shape); + let quoted = format!("{markup:?}"); + let plain = format!("XML.prettyPrinting = false; XML.ignoreWhitespace = false; var x = new XML({quoted}); var y = new XML(x.toXMLString()); x.normalize(); y.normalize(); x == y"); + prop_assert_eq!(limited(Limits::default()).run(&plain), Ok(JsValue::Bool(true)), "{}", markup); + let pretty = format!("var x = new XML({quoted}); var y = new XML(x.toXMLString()); x.toXMLString() === y.toXMLString()"); + prop_assert_eq!(limited(Limits::default()).run(&pretty), Ok(JsValue::Bool(true)), "{}", markup); + } + + #[test] + fn insert_sequences_stay_acyclic(steps in proptest::collection::vec((0usize..8, 0usize..8), 0..40)) { + let mut src = String::from("var n = []; for (var i = 0; i < 8; i++) n.push(new XML('')); var bad = 0;"); + for (a, b) in steps { + src.push_str(&format!("try {{ n[{a}].appendChild(n[{b}]); }} catch (e) {{ if (e.name !== 'TypeError') bad++; }}")); + } + src.push_str("var ok = bad === 0; for (var j = 0; j < 8; j++) { ok = ok && n[j].toXMLString().length > 0 && n[j].descendants().length() < 8; } ok"); + let got = limited(Limits::default()).run(&src); + prop_assert_eq!(got, Ok(JsValue::Bool(true)), "{}", src); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d13cd142e..10f9b939f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -708,6 +708,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. + - `vectorcraft-js` runs ECMAScript for XML (ECMA-357, 2nd edition, https://ecma-international.org/publications-and-standards/standards/ecma-357/) when `Options::e4x` is set, which it is not by default: XML and XMLList literals with embedded `{}` expressions, the `..`, `.@`, `.*`, `.()` and `::` selectors, assignment and `delete` on selected nodes, `for each`, `default xml namespace`, and the `XML`, `XMLList`, `Namespace` and `QName` objects with their methods and settings (`xpath` is not provided). `` in an XML string gives a `SyntaxError`. Parsing, serializing, comparing and copying trees use no native stack, and each takes fuel and counts against the heap limit. Inserting a node into itself or its descendants throws a `TypeError` before the tree changes, a literal or XML string nested past `Options::max_depth` gives a `SyntaxError`, and a tree built past that depth stops at the heap limit. Tests: `crates/js/src/xml_tests.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. From 237ba7a483667ced37d071c3684af26e01c47d27 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 07:09:12 -0800 Subject: [PATCH 2/8] JavaScript: a limit during an XML insert, delete or put leaves the tree intact insertChild, prependChild, replace and list assignment take their heap and fuel before moving a node out of its old parent. Replace past the last child charges before the push, so a child is never left without its parent link. Deleting a child or attribute by name, assigning an attribute or assigning text to an element take the fuel for the whole step before emptying the node. normalize removes the next text node before it stores the joined text. --- crates/js/src/xml.rs | 65 ++++++++++++++++------- crates/js/src/xml_tests.rs | 103 +++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 20 deletions(-) diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 7d70fa689..1277f5402 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -616,22 +616,22 @@ impl Interp { } fn node_delete_at(&mut self, x: ObjId, i: usize) -> JsResult<()> { - let node = self.xml_node_mut(x)?; - if i < node.children.len() { - let c = node.children.remove(i); - let len = node.children.len(); - self.xml_node_mut(c)?.parent = None; + let len = self.children_len(x)?; + if i < len { self.xml_text_fuel(len)?; + let c = self.xml_node_mut(x)?.children.remove(i); + self.xml_node_mut(c)?.parent = None; } Ok(()) } fn node_delete_name(&mut self, x: ObjId, n: &NameRef) -> JsResult<()> { if n.attribute { + let count = self.xml_node(x)?.attrs.len(); + self.xml_take_fuel(count)?; let attrs = std::mem::take(&mut self.xml_node_mut(x)?.attrs); let mut kept = Vec::with_capacity(attrs.len()); for a in attrs { - self.use_fuel()?; let hit = self.xml_node(a)?.name.as_ref().is_some_and(|q| n.matches(q)); if hit { self.xml_node_mut(a)?.parent = None; @@ -642,10 +642,11 @@ impl Interp { self.xml_node_mut(x)?.attrs = kept; return Ok(()); } + let count = self.children_len(x)?; + self.xml_take_fuel(count)?; let children = std::mem::take(&mut self.xml_node_mut(x)?.children); let mut kept = Vec::with_capacity(children.len()); for c in children { - self.use_fuel()?; if n.matches_child(self.xml_node(c)?) { self.xml_node_mut(c)?.parent = None; } else { @@ -842,15 +843,21 @@ impl Interp { Ok(if parent == x && q < at { at - 1 } else { at }) } + /// Charges for one more child of `x`. `x` and `v` are held as roots across any collection. + fn xml_charge_child(&mut self, x: ObjId, v: ObjId) -> JsResult<()> { + self.rooted(&[Value::Object(x), Value::Object(v)], |i| i.xml_charge(XML_REF_BYTES)) + } + + /// Takes the fuel and heap for the insertion first, so a limit leaves `v` where it was. fn insert_node(&mut self, x: ObjId, at: usize, v: ObjId) -> JsResult { + self.xml_charge_child(x, v)?; + let len = self.children_len(x)?; + self.xml_text_fuel(len + 1)?; let at = self.detach(x, v, at)?; - self.xml_charge(XML_REF_BYTES)?; let node = self.xml_node_mut(x)?; let at = at.min(node.children.len()); node.children.insert(at, v); - let len = node.children.len(); self.xml_node_mut(v)?.parent = Some(x); - self.xml_text_fuel(len)?; Ok(at) } @@ -883,6 +890,15 @@ impl Interp { Ok(()) } + /// Charges for a [`Self::set_child`] of `v` at `at` that adds a child. + fn charge_set_child(&mut self, x: ObjId, at: usize, v: ObjId) -> JsResult<()> { + if at < self.children_len(x)? { + return Ok(()); + } + self.xml_charge_child(x, v) + } + + /// Puts `v` at `at`, or last when `at` is past the end, after [`Self::charge_set_child`]. fn set_child(&mut self, x: ObjId, at: usize, v: ObjId) -> JsResult<()> { let node = self.xml_node_mut(x)?; let old = match node.children.get_mut(at) { @@ -892,9 +908,6 @@ impl Interp { None } }; - if old.is_none() { - self.xml_charge(XML_REF_BYTES)?; - } if let Some(old) = old { self.xml_node_mut(old)?.parent = None; } @@ -914,6 +927,7 @@ impl Interp { if self.child_at(x, at)? == Some(*v) { return Ok(()); } + self.charge_set_child(x, at, *v)?; let at = self.detach(x, *v, at)?; self.set_child(x, at, *v) } @@ -926,11 +940,13 @@ impl Interp { let s = self.xml_node(*v)?.value.clone(); let t = self.new_text_node(s, Some(x))?; self.xml_keep(t); + self.charge_set_child(x, at, t)?; self.set_child(x, at, t) } Content::Text(s) => { let t = self.new_text_node(s.clone(), Some(x))?; self.xml_keep(t); + self.charge_set_child(x, at, t)?; self.set_child(x, at, t) } } @@ -983,10 +999,11 @@ impl Interp { self.content_to_str(c)? }; let mut found = None; + let count = self.xml_node(x)?.attrs.len(); + self.xml_take_fuel(count)?; let attrs = std::mem::take(&mut self.xml_node_mut(x)?.attrs); let mut kept = Vec::with_capacity(attrs.len()); for a in attrs { - self.use_fuel()?; let hit = self.xml_node(a)?.name.as_ref().is_some_and(|q| n.matches(q)); if hit && found.is_some() { self.xml_node_mut(a)?.parent = None; @@ -1054,6 +1071,7 @@ impl Interp { })?; self.xml_keep(y); self.check_attach_node(x, y)?; + self.charge_set_child(x, at, y)?; self.set_child(x, at, y)?; } at @@ -1061,15 +1079,22 @@ impl Interp { }; if primitive { let Some(target) = self.child_at(x, at)? else { return Ok(()) }; + let s = self.content_to_str(c)?; + let count = self.children_len(target)?; + self.xml_take_fuel(count)?; + let text = if s.len() > 0 { + let t = self.new_text_node(s, Some(target))?; + self.xml_keep(t); + self.xml_charge_child(target, t)?; + Some(t) + } else { + None + }; let children = std::mem::take(&mut self.xml_node_mut(target)?.children); for child in children { - self.use_fuel()?; self.xml_node_mut(child)?.parent = None; } - let s = self.content_to_str(c)?; - if s.len() > 0 { - self.xml_replace(target, 0, &Content::Text(s))?; - } + self.xml_node_mut(target)?.children.extend(text); Ok(()) } else { self.xml_replace(x, at, c) @@ -1621,8 +1646,8 @@ impl Interp { let (p, q) = (self.xml_node(child)?.value.clone(), self.xml_node(next)?.value.clone()); self.concat(&p, &q)? }; - self.xml_node_mut(child)?.value = joined; self.node_delete_at(x, i + 1)?; + self.xml_node_mut(child)?.value = joined; } if self.xml_node(child)?.value.len() == 0 { self.node_delete_at(x, i)?; diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index e4e7a09fc..804234bef 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -589,6 +589,109 @@ fn limits_cannot_be_caught_by_the_script() { assert_eq!(kind_of(TIGHT, "try { for (;;) ().toXMLString(); } catch (e) { 1 }"), RunErrorKind::Limit(LimitKind::Fuel)); } +/// Collects garbage and sets the heap limit to the live size plus the argument, so a script can +/// run out of heap at a chosen charge. A negative argument puts the heap over its limit, which +/// also happens when a lowered limit meets a larger heap. +fn squeeze( + i: &mut crate::interp::Interp, + _: crate::value::Value, + args: &[crate::value::Value], + _: bool, +) -> crate::interp::JsResult { + i.collect_garbage(); + if let Some(crate::value::Value::Number(spare)) = args.first() { + i.limits.heap_bytes = (i.heap.bytes as f64 + *spare) as usize; + } + Ok(crate::value::Value::Undefined) +} + +/// Runs `setup`, then the action for each step from 0 until one completes, and after each stop +/// asserts that `check` is true with the limits lifted. Returns how many runs stopped at `kind`. +fn stops_with_the_tree_intact(setup: &str, action: impl Fn(usize) -> String, check: &str, kind: LimitKind, limit: impl Fn(usize) -> Limits) -> usize { + let mut r = realm(); + let interp = r.interp(); + let global = interp.global; + assert!(crate::builtins::method(interp, global, "squeeze", squeeze, 1).is_ok()); + let mut stops = 0; + for step in 0..200_000 { + r.set_limits(Limits::default()); + r.run(setup).unwrap(); + r.set_limits(limit(step)); + let action = action(step); + let got = r.run(&action); + r.set_limits(Limits::default()); + assert_eq!(r.run(check), Ok(JsValue::Bool(true)), "{action}: {got:?}"); + match got { + Ok(_) => return stops, + Err(e) if e.kind == RunErrorKind::Limit(kind) => stops += 1, + Err(_) => {} + } + } + panic!("{check} never completed"); +} + +/// Runs `action`, whose `SPARE` is replaced by a call that leaves each number of bytes free in +/// turn, from 512 too few. Actions put it after the steps whose allocations a collection could +/// free: an argument is evaluated after the callee is looked up. +fn heap_stops(setup: &str, action: &str, check: &str) -> usize { + let action = |spare: usize| action.replace("SPARE", &format!("squeeze({})", spare as i64 - 512)); + stops_with_the_tree_intact(setup, action, check, LimitKind::Heap, |_| Limits::default()) +} + +fn fuel_stops(setup: &str, action: &str, check: &str) -> usize { + stops_with_the_tree_intact(setup, |_| action.to_string(), check, LimitKind::Fuel, |fuel| Limits { fuel: fuel as u64, ..Limits::default() }) +} + +const MOVE_SETUP: &str = "var o = ; var t = ; var n = o.b[0];"; +const MOVE_CHECK: &str = + "o.b.length() + t.b.length() === 1 && (o.b.length() === 0 || o.b[0].parent() === o) && (t.b.length() === 0 || t.b[0].parent() === t)"; + +#[test] +fn a_heap_limit_during_a_move_leaves_the_node_in_its_old_parent() { + for action in + ["t.appendChild((SPARE, n))", "t.prependChild((SPARE, n))", "t.insertChildAfter(null, (SPARE, n))", "t.insertChildBefore(null, (SPARE, n))"] + { + assert!(heap_stops(MOVE_SETUP, action, MOVE_CHECK) > 0, "{action}"); + } +} + +#[test] +fn a_fuel_limit_during_a_move_leaves_the_node_in_its_old_parent() { + for action in ["t.appendChild(n)", "t.prependChild(n)"] { + assert!(fuel_stops(MOVE_SETUP, action, MOVE_CHECK) > 0, "{action}"); + } + let replaced = "var l = t.*; l[0] = o.b[0]"; + let setup = "var o = ; var t = ;"; + assert!(fuel_stops(setup, replaced, MOVE_CHECK) > 0); +} + +#[test] +fn a_heap_limit_on_replace_past_the_end_leaves_no_child_without_a_parent() { + let check = "var n = x.*.length(); if (n === 0) { true } else { \ + var v = x.*[n - 1]; var name; try { v.appendChild(x); name = 'none' } catch (e) { name = e.name } v.parent() === x && name === 'TypeError' }"; + let action = "x.replace({ toString: function () { SPARE; return '5'; } }, v)"; + assert!(heap_stops("var x = ; var v = ;", action, check) > 0); +} + +#[test] +fn limits_during_deletes_and_puts_keep_the_other_nodes() { + let setup = "var x = old;"; + let others = "x.c.length() === 1 && x.@q == '2'"; + for action in ["delete x.b", "delete x.@p", "x.@p = 'v'", "x.@n = 'v'", "x.b = 'v'"] { + assert!(fuel_stops(setup, action, others) > 0, "{action}"); + } + let held = format!("{others} && x.b.length() === 1 && (x.b == 'old' || x.b == 'v') && x.b.*[0].parent() === x.b[0]"); + assert!(fuel_stops(setup, "x.b = 'v'", &held) > 0); + assert!(heap_stops(setup, "x.b = (SPARE, 'v')", &held) > 0); +} + +#[test] +fn a_limit_during_normalize_does_not_repeat_text() { + let setup = "var x = ; x.appendChild('p'); x.appendChild('q'); x.appendChild('r');"; + let check = "x.toString() === 'pqr'"; + assert!(fuel_stops(setup, "x.normalize()", check) > 0); +} + #[test] fn an_interrupt_stops_serialization() { let mut r = realm(); From dbfd3f8a803100ecde46e849c000ee63872ee78a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 07:22:20 -0800 Subject: [PATCH 3/8] JavaScript: XML rejects reserved namespace bindings and names The XML parser now throws SyntaxError when markup binds the xml or xmlns prefix or either fixed URI against the Namespaces in XML rules. addNamespace, setName, setLocalName and setNamespace throw TypeError for the same bindings and leave the node unchanged. A new element or attribute cannot be in the xmlns namespace, and an attribute in no namespace cannot be named xmlns. The xmlns URI cannot be the default XML namespace. These assignments throw TypeError. --- crates/js/src/xml.rs | 41 ++++++++++++++++++ crates/js/src/xml_builtins.rs | 30 ++++++++++++-- crates/js/src/xml_tests.rs | 78 +++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 3 deletions(-) diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 1277f5402..475870beb 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -27,6 +27,8 @@ fn parser_units(s: &str) -> Vec { pub(crate) const STAR: &[u16] = &[0x2A]; const XML_URI: &str = "http://www.w3.org/XML/1998/namespace"; +const XMLNS_URI: &str = "http://www.w3.org/2000/xmlns/"; +pub(crate) const RESERVED_NS_BINDING: &str = "a namespace declaration uses a reserved prefix or URI"; /// The hidden binding that holds a function's default XML namespace URI. const DEFAULT_NS_BINDING: &[u16] = &[0, 0x64, 0x78, 0x6E, 0x73]; @@ -221,6 +223,30 @@ fn prefix_in_scope<'a>(stack: &'a [XmlNs], prefix: &[u16]) -> Option<&'a XmlNs> stack.iter().rev().find(|ns| ns.prefix.as_deref() == Some(prefix)) } +/// Whether Namespaces in XML allows declaring `prefix` for `uri`: `xml` only with its fixed URI, +/// `xmlns` never, and neither fixed URI with another prefix or as the default namespace. +pub(crate) fn ns_binding_allowed(prefix: &[u16], uri: &[u16]) -> bool { + if units_are(prefix, "xmlns") { + return false; + } + let is_xml_uri = units_are(uri, XML_URI); + if units_are(prefix, "xml") { + return is_xml_uri; + } + !is_xml_uri && !units_are(uri, XMLNS_URI) +} + +/// Whether a node named `name` can be written as XML: no name is in the `xmlns` namespace, and +/// no attribute in no namespace is named `xmlns`. +pub(crate) fn xml_name_writable(name: &XmlName, attribute: bool) -> bool { + let uri = name.uri.as_deref().unwrap_or_default(); + !units_are(uri, XMLNS_URI) && !(attribute && uri.is_empty() && units_are(&name.local, "xmlns")) +} + +fn units_are(units: &[u16], text: &str) -> bool { + units.iter().copied().eq(text.encode_utf16()) +} + /// A prefix no entry of `stack` uses. fn fresh_prefix(stack: &[XmlNs]) -> Key { let mut n = 0u32; @@ -361,6 +387,12 @@ impl Interp { /// A new node. What `node` refers to must be reachable from the roots. pub(crate) fn new_xml_node(&mut self, node: XmlNode) -> JsResult { + if let Some(name) = &node.name + && matches!(node.kind, XmlKind::Element | XmlKind::Attribute) + && !xml_name_writable(name, node.kind == XmlKind::Attribute) + { + return Err(self.type_error(RESERVED_NS_BINDING)); + } let extra = XML_NODE_BYTES + node.name.as_ref().map_or(0, name_bytes) + string_bytes(node.value.len()); self.reserve_heap(OBJECT_BYTES + extra)?; self.heap.bytes = self.heap.bytes.saturating_add(extra); @@ -432,6 +464,9 @@ impl Interp { /// Sets the default XML namespace of the running function, or of the global code. pub(crate) fn xml_set_default_uri(&mut self, uri: Key) -> JsResult<()> { + if units_are(&uri, XMLNS_URI) { + return Err(self.type_error(RESERVED_NS_BINDING)); + } let env = self.frame()?.var_env; if let Some(Env::Decl { bindings, .. }) = self.heap.env_mut(env) { let value = Value::String(Str::from_rc(uri)); @@ -2151,6 +2186,9 @@ impl Interp { for (start, end, value) in &raw_attrs { let raw = src.get(*start..*end).unwrap_or_default(); if raw == key("xmlns").as_ref() { + if !ns_binding_allowed(&[], value) { + return Err(self.syntax(RESERVED_NS_BINDING)); + } if !seen_decl.insert(Vec::new()) { return Err(self.syntax("a namespace is declared twice")); } @@ -2161,6 +2199,9 @@ impl Interp { if value.is_empty() { return Err(self.syntax("a namespace prefix cannot be declared with an empty URI")); } + if !ns_binding_allowed(local, value) { + return Err(self.syntax(RESERVED_NS_BINDING)); + } if !seen_decl.insert(local.to_vec()) { return Err(self.syntax("a namespace is declared twice")); } diff --git a/crates/js/src/xml_builtins.rs b/crates/js/src/xml_builtins.rs index 0076dbe94..a5cac2912 100644 --- a/crates/js/src/xml_builtins.rs +++ b/crates/js/src/xml_builtins.rs @@ -10,7 +10,9 @@ use crate::heap::{ use crate::interp::{Abrupt, Interp, JsResult}; use crate::string_builtins::{new_array, set_element}; use crate::value::{Str, Value}; -use crate::xml::{Access, Content, NameRef, STAR, Xv, canonical_index, is_ncname, is_script_xml_name}; +use crate::xml::{ + Access, Content, NameRef, RESERVED_NS_BINDING, STAR, Xv, canonical_index, is_ncname, is_script_xml_name, ns_binding_allowed, xml_name_writable, +}; const SETTINGS: [(&str, Value); 5] = [ ("ignoreComments", Value::Bool(false)), @@ -807,6 +809,9 @@ fn add_in_scope(interp: &mut Interp, x: ObjId, ns: XmlNs) -> JsResult<()> { return Ok(()); } let Some(prefix) = ns.prefix.clone() else { return Ok(()) }; + if !ns_binding_allowed(&prefix, &ns.uri) { + return Err(interp.type_error(RESERVED_NS_BINDING)); + } let node = interp.xml_node(x)?; if prefix.is_empty() && node.name.as_ref().and_then(|n| n.uri.as_ref()).is_some_and(|u| u.is_empty()) { return Ok(()); @@ -998,7 +1003,8 @@ fn checked_local(interp: &mut Interp, local: &Key) -> JsResult<()> { fn set_local_name(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { let x = this_node(interp, &this)?; - if matches!(interp.xml_kind(x)?, XmlKind::Text | XmlKind::Comment) { + let kind = interp.xml_kind(x)?; + if matches!(kind, XmlKind::Text | XmlKind::Comment) { return Ok(Value::Undefined); } let v = arg(args, 0); @@ -1007,12 +1013,26 @@ fn set_local_name(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> None => interp.to_str(&v)?.flat(), }; checked_local(interp, &local)?; + if kind == XmlKind::Attribute + && let Some(old) = interp.xml_node(x)?.name.clone() + { + check_name_binding(interp, &XmlName { local: local.clone(), ..old }, true)?; + } if let Some(n) = &mut interp.xml_node_mut(x)?.name { n.local = local; } Ok(Value::Undefined) } +/// Throws when an element or attribute named `name` could not be written as XML. +fn check_name_binding(interp: &mut Interp, name: &XmlName, attribute: bool) -> JsResult<()> { + let uri = name.uri.as_deref().unwrap_or_default(); + if !name.prefix.as_ref().is_none_or(|prefix| ns_binding_allowed(prefix, uri)) || !xml_name_writable(name, attribute) { + return Err(interp.type_error(RESERVED_NS_BINDING)); + } + Ok(()) +} + fn name_set_in_scope(interp: &mut Interp, x: ObjId, name: &XmlName) -> JsResult<()> { let ns = XmlNs { prefix: name.prefix.clone(), uri: name.uri.clone().unwrap_or_else(empty) }; match interp.xml_kind(x)? { @@ -1040,6 +1060,8 @@ fn set_name(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResu checked_local(interp, &name.local)?; if kind == XmlKind::Pi { name.uri = Some(empty()); + } else { + check_name_binding(interp, &name, kind == XmlKind::Attribute)?; } interp.xml_node_mut(x)?.name = Some(name.clone()); name_set_in_scope(interp, x, &name)?; @@ -1048,12 +1070,14 @@ fn set_name(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResu fn set_namespace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { let x = this_node(interp, &this)?; - if matches!(interp.xml_kind(x)?, XmlKind::Text | XmlKind::Comment | XmlKind::Pi) { + let kind = interp.xml_kind(x)?; + if matches!(kind, XmlKind::Text | XmlKind::Comment | XmlKind::Pi) { return Ok(Value::Undefined); } let ns = build_namespace(interp, std::slice::from_ref(&arg(args, 0)))?; let Some(old) = interp.xml_node(x)?.name.clone() else { return Ok(Value::Undefined) }; let name = XmlName { uri: Some(ns.uri), local: old.local, prefix: ns.prefix }; + check_name_binding(interp, &name, kind == XmlKind::Attribute)?; interp.xml_node_mut(x)?.name = Some(name.clone()); name_set_in_scope(interp, x, &name)?; Ok(Value::Undefined) diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 804234bef..f6cf4d0ef 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -360,6 +360,84 @@ fn namespaces_survive_serialization() { ); } +const XML_NS: &str = "http://www.w3.org/XML/1998/namespace"; +const XMLNS_NS: &str = "http://www.w3.org/2000/xmlns/"; + +#[test] +fn markup_cannot_declare_a_reserved_namespace_binding() { + let declarations = [ + "xmlns:xml='wrong'".to_string(), + "xmlns:xmlns='u'".to_string(), + format!("xmlns:xmlns='{XMLNS_NS}'"), + format!("xmlns:p='{XML_NS}'"), + format!("xmlns:p='{XMLNS_NS}'"), + format!("xmlns='{XML_NS}'"), + format!("xmlns='{XMLNS_NS}'"), + ]; + for declaration in declarations { + assert_eq!(thrown_name(&format!("")), "SyntaxError", "{declaration}"); + assert_eq!(thrown_name(&format!("new XML(\"\")")), "SyntaxError", "{declaration}"); + } + assert_eq!(string(&format!("().toXMLString()")), format!("")); + assert_eq!(string("().toXMLString()"), ""); +} + +#[test] +fn add_namespace_rejects_a_reserved_binding_and_keeps_the_element() { + for (prefix, uri) in [("xmlns", "u"), ("xmlns", XMLNS_NS), ("xml", "u"), ("p", XML_NS), ("p", XMLNS_NS)] { + let src = + format!("var x = ; var name = 'none'; try {{ x.addNamespace(new Namespace('{prefix}', '{uri}')); }} catch (e) {{ name = e.name; }}"); + assert_eq!(string(&format!("{src} name")), "TypeError", "{prefix} {uri}"); + assert_eq!(string(&format!("{src} x.toXMLString()")), "", "{prefix} {uri}"); + } + assert_eq!( + string(&format!("var x = ; x.addNamespace(new Namespace('xml', '{XML_NS}')); x.toXMLString()")), + format!("") + ); +} + +#[test] +fn names_cannot_write_a_reserved_namespace_binding() { + for setter in [ + "setNamespace(new Namespace('xmlns', 'u'))", + "setName(new QName(new Namespace('xml', 'u'), 'b'))", + "setNamespace(new Namespace('p', 'http://www.w3.org/2000/xmlns/'))", + "setNamespace('http://www.w3.org/2000/xmlns/')", + ] { + let src = format!("var x = ; var name = 'none'; try {{ x.{setter}; }} catch (e) {{ name = e.name; }}"); + assert_eq!(string(&format!("{src} name")), "TypeError", "{setter}"); + assert_eq!(string(&format!("{src} x.toXMLString()")), "", "{setter}"); + } + let q = |prefix: &str| format!("new QName(new Namespace('{prefix}', 'u'), 'b')"); + assert_eq!(string(&format!("var x = ; x[{}] = '1'; x.toXMLString()", q("xmlns"))), "\n 1\n"); + assert_eq!(string(&format!("var x = ; x.@[{}] = '1'; x.toXMLString()", q("xml"))), ""); +} + +#[test] +fn an_attribute_cannot_be_renamed_xmlns() { + for setter in ["setName('xmlns')", "setLocalName('xmlns')"] { + let src = format!("var x = ; var name = 'none'; try {{ x.@b[0].{setter}; }} catch (e) {{ name = e.name; }}"); + assert_eq!(string(&format!("{src} name")), "TypeError", "{setter}"); + assert_eq!(string(&format!("{src} x.toXMLString()")), "", "{setter}"); + } +} + +#[test] +fn new_nodes_cannot_take_a_reserved_name() { + let q = format!("new QName('{XMLNS_NS}', 'b')"); + for update in ["x.@xmlns = 'u'".to_string(), format!("x[{q}] = '1'"), format!("x.@[{q}] = '1'"), format!("x[{q}][0] = '1'")] { + let src = format!("var x = ; var name = 'none'; try {{ {update}; }} catch (e) {{ name = e.name; }}"); + assert_eq!(string(&format!("{src} name")), "TypeError", "{update}"); + assert_eq!(string(&format!("{src} x.toXMLString()")), "", "{update}"); + } +} + +#[test] +fn the_xmlns_uri_cannot_be_the_default_namespace() { + assert_eq!(thrown_name(&format!("default xml namespace = '{XMLNS_NS}'")), "TypeError"); + assert_eq!(thrown_name(&format!("setDefaultXMLNamespace('{XMLNS_NS}')")), "TypeError"); +} + #[test] fn the_garbage_collector_keeps_trees_and_lists_alive() { let mut r = realm(); From 80ef5d5f53a0f9e8037e33fd36c98f04c370ea18 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 07:37:24 -0800 Subject: [PATCH 4/8] JavaScript: a limit while a list or named match replaces XML children leaves the tree intact Replacing a child with a list, inserting a list, and assigning or replacing by name now check the depth and take all fuel and heap before the first change. A limit no longer leaves the old child removed, part of a list inserted or one of several matches gone. appendChild and assignment past the end of a list remove their empty placeholder when the assignment stops. A list that takes an earlier sibling at an index keeps that node in its slot. --- crates/js/src/xml.rs | 319 ++++++++++++++++++++++------------ crates/js/src/xml_builtins.rs | 18 +- crates/js/src/xml_tests.rs | 94 ++++++++++ 3 files changed, 309 insertions(+), 122 deletions(-) diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 475870beb..6cfe962a1 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -654,7 +654,30 @@ impl Interp { let len = self.children_len(x)?; if i < len { self.xml_text_fuel(len)?; - let c = self.xml_node_mut(x)?.children.remove(i); + self.remove_child(x, i)?; + } + Ok(()) + } + + /// Takes the child at `i` out of `x`. The caller has checked `i` and taken the fuel. + fn remove_child(&mut self, x: ObjId, i: usize) -> JsResult<()> { + let c = self.xml_node_mut(x)?.children.remove(i); + self.xml_node_mut(c)?.parent = None; + Ok(()) + } + + /// Takes `gone`, children of `x` in document order, out of `x` in one pass. The caller has + /// taken the fuel. + fn remove_children(&mut self, x: ObjId, gone: &[ObjId]) -> JsResult<()> { + let mut next = gone.iter().peekable(); + self.xml_node_mut(x)?.children.retain(|c| { + let hit = next.peek() == Some(&c); + if hit { + next.next(); + } + !hit + }); + for &c in gone { self.xml_node_mut(c)?.parent = None; } Ok(()) @@ -867,17 +890,43 @@ impl Interp { } /// Removes `v` from its parent, so a node is never in two lists of children. Returns the - /// position in `x` to use after the removal. + /// position in `x` to use after the removal. The caller has taken [`Self::place_fuel`]. fn detach(&mut self, x: ObjId, v: ObjId, at: usize) -> JsResult { let Some(parent) = self.xml_node(v)?.parent else { return Ok(at) }; if self.xml_kind(v)? == XmlKind::Attribute { return Ok(at); } - let Some(q) = self.index_in_parent(parent, v)? else { return Ok(at) }; - self.node_delete_at(parent, q)?; + let Some(q) = self.xml_node(parent)?.children.iter().position(|&c| c == v) else { return Ok(at) }; + self.remove_child(parent, q)?; Ok(if parent == x && q < at { at - 1 } else { at }) } + /// Takes the fuel to put `v` among `len` children: the insertion and, when `v` has a parent, + /// the scan and removal that take it out. + fn place_fuel(&mut self, len: usize, v: ObjId) -> JsResult<()> { + self.xml_text_fuel(len + 1)?; + let node = self.xml_node(v)?; + if node.kind != XmlKind::Attribute + && let Some(parent) = node.parent + { + let old = self.children_len(parent)?; + self.xml_text_fuel(old)?; + self.xml_text_fuel(old)?; + } + Ok(()) + } + + /// Moves `v` to position `at` of `x` and returns where it went. The caller has taken the fuel + /// and heap. + fn place_child(&mut self, x: ObjId, at: usize, v: ObjId) -> JsResult { + let at = self.detach(x, v, at)?; + let node = self.xml_node_mut(x)?; + let at = at.min(node.children.len()); + node.children.insert(at, v); + self.xml_node_mut(v)?.parent = Some(x); + Ok(at) + } + /// Charges for one more child of `x`. `x` and `v` are held as roots across any collection. fn xml_charge_child(&mut self, x: ObjId, v: ObjId) -> JsResult<()> { self.rooted(&[Value::Object(x), Value::Object(v)], |i| i.xml_charge(XML_REF_BYTES)) @@ -887,13 +936,30 @@ impl Interp { fn insert_node(&mut self, x: ObjId, at: usize, v: ObjId) -> JsResult { self.xml_charge_child(x, v)?; let len = self.children_len(x)?; - self.xml_text_fuel(len + 1)?; - let at = self.detach(x, v, at)?; - let node = self.xml_node_mut(x)?; - let at = at.min(node.children.len()); - node.children.insert(at, v); - self.xml_node_mut(v)?.parent = Some(x); - Ok(at) + self.place_fuel(len, v)?; + self.place_child(x, at, v) + } + + /// Takes the fuel and heap to put every item of `l` under `x` and returns the items. The tree + /// does not change here, so a limit leaves it as it was. + fn reserve_list_insert(&mut self, x: ObjId, l: ObjId) -> JsResult> { + let items = self.xml_list(l)?.items.clone(); + let bytes = XML_REF_BYTES.saturating_mul(items.len()); + self.rooted(&[Value::Object(x), Value::Object(l)], |i| i.xml_charge(bytes))?; + let len = self.children_len(x)?; + for (j, &item) in items.iter().enumerate() { + self.place_fuel(len + j, item)?; + } + Ok(items) + } + + /// Puts `items` at `at` in order, after [`Self::reserve_list_insert`]. + fn place_children(&mut self, x: ObjId, at: usize, items: &[ObjId]) -> JsResult<()> { + let mut at = at; + for &item in items { + at = self.place_child(x, at, item)? + 1; + } + Ok(()) } /// [[Insert]] (ECMA-357 section 9.1.1.11). @@ -915,11 +981,8 @@ impl Interp { self.insert_node(x, at, *v)?; } Content::List(l) => { - let mut at = at; - for j in 0..self.list_len(*l)? { - let Some(item) = self.list_at(*l, j)? else { break }; - at = self.insert_node(x, at, item)? + 1; - } + let items = self.reserve_list_insert(x, *l)?; + self.place_children(x, at, &items)?; } } Ok(()) @@ -963,13 +1026,20 @@ impl Interp { return Ok(()); } self.charge_set_child(x, at, *v)?; + let len = self.children_len(x)?; + self.place_fuel(len, *v)?; let at = self.detach(x, *v, at)?; self.set_child(x, at, *v) } - Content::List(_) => { + Content::List(l) => { self.check_attach(x, c)?; - self.node_delete_at(x, at)?; - self.xml_insert(x, at, c) + let items = self.reserve_list_insert(x, *l)?; + let len = self.children_len(x)?; + self.xml_text_fuel(len)?; + if at < len { + self.remove_child(x, at)?; + } + self.place_children(x, at, &items) } Content::Node(v) => { let s = self.xml_node(*v)?.value.clone(); @@ -987,6 +1057,21 @@ impl Interp { } } + /// Replaces the first of `matches`, children of `x` in document order, with `c` and removes + /// the rest; appends `c` when there are none. A limit leaves `x` as it was. The value in `c` + /// is never one of `matches`. + pub(crate) fn xml_replace_matches(&mut self, x: ObjId, matches: &[ObjId], c: &Content) -> JsResult<()> { + let len = self.children_len(x)?; + self.xml_text_fuel(len)?; + self.xml_text_fuel(len)?; + let at = match matches.first() { + Some(first) => self.xml_node(x)?.children.iter().position(|c| c == first).unwrap_or(len), + None => len, + }; + self.xml_replace(x, at, c)?; + self.remove_children(x, matches.get(1..).unwrap_or_default()) + } + // ---- [[Put]] ---- /// A value to assign into a tree, copied when it is XML (steps 3 and 4 of section 9.1.1.2). @@ -1074,66 +1159,68 @@ impl Interp { if !is_ncname(&n.local) && !n.is_star() { return Ok(()); } - let primitive = matches!(c, Content::Text(_)) && !n.is_star(); - let mut found: Option = None; - for k in (0..self.children_len(x)?).rev() { - self.use_fuel()?; - let Some(child) = self.child_at(x, k)? else { continue }; + let len = self.children_len(x)?; + self.xml_take_fuel(len)?; + let mut matches = Vec::new(); + for k in 0..len { + let Some(child) = self.child_at(x, k)? else { break }; if n.matches_child(self.xml_node(child)?) { - if let Some(i) = found { - self.node_delete_at(x, i)?; - } - found = Some(k); + matches.push(child); } } - let at = match found { - Some(i) => i, + match c { + Content::Text(s) if !n.is_star() => self.put_element_text(x, n, &matches, s), + _ => self.xml_replace_matches(x, &matches, c), + } + } + + /// Makes `s` the only content of the first of `matches`, or of a new element named `n` when + /// there are none, and removes the other matches. A limit leaves `x` as it was. + fn put_element_text(&mut self, x: ObjId, n: &NameRef, matches: &[ObjId], s: &Str) -> JsResult<()> { + let at = self.children_len(x)?; + self.xml_text_fuel(at)?; + let target = match matches.first() { + Some(&first) => first, None => { - let at = self.children_len(x)?; - if primitive { - let uri = match &n.uri { - Some(u) => u.clone(), - None => self.xml_default_uri()?, - }; - let y = self.new_xml_node(XmlNode { - kind: XmlKind::Element, - name: Some(XmlName { uri: Some(uri), local: n.local.clone(), prefix: None }), - parent: Some(x), - value: Str::from(""), - attrs: Vec::new(), - children: Vec::new(), - ns: Vec::new(), - })?; - self.xml_keep(y); - self.check_attach_node(x, y)?; - self.charge_set_child(x, at, y)?; - self.set_child(x, at, y)?; - } - at + let uri = match &n.uri { + Some(u) => u.clone(), + None => self.xml_default_uri()?, + }; + let y = self.new_xml_node(XmlNode { + kind: XmlKind::Element, + name: Some(XmlName { uri: Some(uri), local: n.local.clone(), prefix: None }), + parent: Some(x), + value: Str::from(""), + attrs: Vec::new(), + children: Vec::new(), + ns: Vec::new(), + })?; + self.xml_keep(y); + self.check_attach_node(x, y)?; + self.charge_set_child(x, at, y)?; + y } }; - if primitive { - let Some(target) = self.child_at(x, at)? else { return Ok(()) }; - let s = self.content_to_str(c)?; - let count = self.children_len(target)?; - self.xml_take_fuel(count)?; - let text = if s.len() > 0 { - let t = self.new_text_node(s, Some(target))?; - self.xml_keep(t); - self.xml_charge_child(target, t)?; - Some(t) - } else { - None - }; - let children = std::mem::take(&mut self.xml_node_mut(target)?.children); - for child in children { - self.xml_node_mut(child)?.parent = None; - } - self.xml_node_mut(target)?.children.extend(text); - Ok(()) + let count = self.children_len(target)?; + self.xml_take_fuel(count)?; + let text = if s.len() > 0 { + let t = self.new_text_node(s.clone(), Some(target))?; + self.xml_keep(t); + self.xml_charge_child(target, t)?; + Some(t) } else { - self.xml_replace(x, at, c) + None + }; + if matches.is_empty() { + self.set_child(x, at, target)?; } + self.remove_children(x, matches.get(1..).unwrap_or_default())?; + let children = std::mem::take(&mut self.xml_node_mut(target)?.children); + for child in children { + self.xml_node_mut(child)?.parent = None; + } + self.xml_node_mut(target)?.children.extend(text); + Ok(()) } fn node_put(&mut self, x: ObjId, n: &NameRef, v: &Value) -> JsResult<()> { @@ -1210,7 +1297,6 @@ impl Interp { } fn list_put_index(&mut self, x: ObjId, index: usize, v: &Value) -> JsResult<()> { - let mut i = index; let data = self.xml_list(x)?; let target = data.target; let target_name = data.target_name.clone(); @@ -1221,7 +1307,7 @@ impl Interp { }, None => None, }; - if i >= self.list_len(x)? { + if index >= self.list_len(x)? { let r = match r { Some(r) => match self.classify(&Value::Object(r)) { Xv::List(rl) => { @@ -1262,29 +1348,56 @@ impl Interp { let y = self.new_xml_node(XmlNode { kind, name, parent: r, value: Str::from(""), attrs: Vec::new(), children: Vec::new(), ns: Vec::new() })?; self.xml_keep(y); - i = self.list_len(x)?; - if kind == XmlKind::Attribute { - if let Some(r) = r { - self.xml_charge(XML_REF_BYTES)?; - self.xml_node_mut(r)?.attrs.push(y); - } - } else if let Some(r) = r { - let len = self.children_len(r)?; - let at = if i > 0 { - let prev = self.list_at(x, i - 1)?; - let position = match prev { - Some(prev) => self.index_in_parent(r, prev)?, - None => None, - }; - position.map_or(len, |j| j + 1) - } else { - len - }; - self.check_attach_node(r, y)?; - self.insert_node(r, at, y)?; + let i = self.list_len(x)?; + let put = self.list_append_new(x, i, r, kind, y).and_then(|()| self.list_put_at(x, i, v)); + if put.is_err() { + self.undo_list_append(x, y)?; } - self.list_push(x, y)?; + return put; } + self.list_put_at(x, index, v) + } + + /// Adds the new node `y` as item `i` of `x` and, when there is a parent `r`, to the tree. + fn list_append_new(&mut self, x: ObjId, i: usize, r: Option, kind: XmlKind, y: ObjId) -> JsResult<()> { + if kind == XmlKind::Attribute { + if let Some(r) = r { + self.xml_charge(XML_REF_BYTES)?; + self.xml_node_mut(r)?.attrs.push(y); + } + } else if let Some(r) = r { + let len = self.children_len(r)?; + let at = if i > 0 { + let prev = self.list_at(x, i - 1)?; + let position = match prev { + Some(prev) => self.index_in_parent(r, prev)?, + None => None, + }; + position.map_or(len, |j| j + 1) + } else { + len + }; + self.check_attach_node(r, y)?; + self.insert_node(r, at, y)?; + } + self.list_push(x, y) + } + + /// Takes `y` out of its parent and of `x` after an assignment that added it stopped. It takes + /// no fuel or heap, so it runs after a limit. + fn undo_list_append(&mut self, x: ObjId, y: ObjId) -> JsResult<()> { + if let Some(parent) = self.xml_node(y)?.parent { + let node = self.xml_node_mut(parent)?; + node.children.retain(|&c| c != y); + node.attrs.retain(|&a| a != y); + self.xml_node_mut(y)?.parent = None; + } + self.xml_list_mut(x)?.items.retain(|&item| item != y); + Ok(()) + } + + /// Assigns `v` to item `i` of `x`. + fn list_put_at(&mut self, x: ObjId, i: usize, v: &Value) -> JsResult<()> { let Some(item) = self.list_at(x, i)? else { return Ok(()) }; let xv = self.classify(v); let value = match xv { @@ -1313,21 +1426,13 @@ impl Interp { Content::List(l) => { let c = self.list_shallow_copy(l)?; self.xml_keep(c); - let count = self.list_len(c)?; + let items = self.xml_list(c)?.items.clone(); + self.xml_charge(XML_REF_BYTES.saturating_mul(items.len()))?; if let Some(parent) = parent && let Some(q) = self.index_in_parent(parent, item)? { self.xml_replace(parent, q, &Content::List(c))?; - for j in 0..count { - if let Some(child) = self.child_at(parent, q + j)? - && let Some(slot) = self.xml_list_mut(c)?.items.get_mut(j) - { - *slot = child; - } - } } - let items = self.xml_list(c)?.items.clone(); - self.xml_charge(XML_REF_BYTES.saturating_mul(items.len()))?; self.xml_list_mut(x)?.items.splice(i..=i, items); Ok(()) } @@ -1348,14 +1453,14 @@ impl Interp { replaced = self.child_at(parent, q)?; } let new_item = match (replaced, value) { - (Some(r), _) => r, - (None, Content::Node(n)) => *n, + (_, Content::Node(n)) => *n, + (Some(r), Content::Text(_)) => r, (None, Content::Text(s)) => { let t = self.new_text_node(s.clone(), None)?; self.xml_keep(t); t } - (None, Content::List(_)) => return Ok(()), + (_, Content::List(_)) => return Ok(()), }; if let Some(slot) = self.xml_list_mut(x)?.items.get_mut(i) { *slot = new_item; diff --git a/crates/js/src/xml_builtins.rs b/crates/js/src/xml_builtins.rs index a5cac2912..1662dc5db 100644 --- a/crates/js/src/xml_builtins.rs +++ b/crates/js/src/xml_builtins.rs @@ -967,23 +967,11 @@ fn replace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResul }; let matched = i.xml_select(x, &NameRef { attribute: false, ..n })?; i.xml_keep(matched); - let count = i.list_len(matched)?; - if count == 0 { + let items = i.xml_list(matched)?.items.clone(); + if items.is_empty() { return Ok(()); } - let Some(first) = i.list_at(matched, 0)? else { return Ok(()) }; - for at in (1..count).rev() { - let Some(item) = i.list_at(matched, at)? else { continue }; - i.use_fuel()?; - let position = i.xml_node(x)?.children.iter().position(|&c| c == item); - if let Some(q) = position { - let removed = i.xml_node_mut(x)?.children.remove(q); - i.xml_node_mut(removed)?.parent = None; - } - } - i.use_fuel()?; - let at = i.xml_node(x)?.children.iter().position(|&c| c == first).unwrap_or(0); - i.xml_replace(x, at, &c) + i.xml_replace_matches(x, &items, &c) })?; Ok(this) } diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index f6cf4d0ef..86b813d25 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -763,6 +763,100 @@ fn limits_during_deletes_and_puts_keep_the_other_nodes() { assert!(heap_stops(setup, "x.b = (SPARE, 'v')", &held) > 0); } +/// True when the markup of `tree` is `before` or `after`, ignoring white space. +fn tree_is(tree: &str, before: &str, after: &str) -> String { + format!("(function () {{ var s = {tree}.toXMLString().replace(/\\s/g, ''); return s === '{before}' || s === '{after}'; }})()") +} + +#[test] +fn a_limit_while_a_list_replaces_a_child_leaves_the_children() { + let setup = "var x = ; var v = <>

;"; + let replaced = tree_is("x", "", "

"); + for action in ["x.replace(0, v)", "x.b = v"] { + assert!(fuel_stops(setup, action, &replaced) > 0, "{action}"); + } + let spare = "x.replace({ toString: function () { SPARE; return '0'; } }, v)"; + assert!(heap_stops(setup, spare, &replaced) > 0); + let set = tree_is("x", "", "

"); + assert!(fuel_stops(setup, "x.setChildren(v)", &set) > 0); +} + +#[test] +fn a_limit_while_a_list_is_inserted_leaves_the_children() { + let setup = "var o =

; var x = ; var v = o.*;"; + let check = |after: &str| { + format!("{} && (o.*.length() === 0 ? x.*.length() === 3 : o.*.length() === 2 && x.*.length() === 1)", tree_is("x", "", after)) + }; + for (action, after) in [ + ("x.appendChild(v)", "

"), + ("x.prependChild(v)", "

"), + ("x.insertChildAfter(null, v)", "

"), + ("x.insertChildBefore(null, v)", "

"), + ] { + assert!(fuel_stops(setup, action, &check(after)) > 0, "{action}"); + } + assert!(heap_stops(setup, "x.appendChild((SPARE, v))", &check("

")) > 0); +} + +#[test] +fn a_heap_limit_on_a_list_put_at_an_index_keeps_the_list_and_tree_in_step() { + let setup = "var x = ; var l = x.*; var v = <>

;"; + let check = "var s = x.toXMLString().replace(/\\s/g, ''); \ + (s === '' && l.length() === 2 && l[0].name() == 'b') \ + || (s === '

' && l.length() === 3 && l[0].name() == 'p')"; + assert!(heap_stops(setup, "l[(SPARE, 0)] = v", check) > 0); +} + +#[test] +fn an_index_put_of_an_earlier_sibling_keeps_that_node_in_the_list() { + let setup = "var x = ; var l = x.*;"; + truth(&format!("{setup} l[2] = x.b; l[2] === x.b[0] && l[3] === x.e[0]")); + truth(&format!("{setup} l[2] = x.b[0]; l[2] === x.b[0] && l[3] === x.e[0]")); +} + +/// A realm whose trees may be 32 levels deep, and a script line making `deep`, a tree of 32 levels +/// that cannot go under any element. +fn shallow_with_deep_value() -> (Realm, &'static str) { + let deep = "var s = ''; var e = ''; for (var i = 0; i < 32; i++) { s += ''; e += ''; } var deep = new XML(s + e);"; + (Realm::with_options(Options { max_depth: 32, ..e4x() }, Limits::default()), deep) +} + +#[test] +fn a_too_deep_value_leaves_every_match_in_place() { + for action in ["x.replace('b', deep)", "x.b = deep"] { + let (mut r, deep) = shallow_with_deep_value(); + r.run(deep).unwrap(); + r.run("var x = ;").unwrap(); + assert_eq!(r.run(action).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Heap)), "{action}"); + assert_eq!(r.run("x.b.length() === 2 && x.*.length() === 3"), Ok(JsValue::Bool(true)), "{action}"); + } +} + +#[test] +fn a_limit_while_matches_are_replaced_keeps_every_match() { + let setup = "var x = ; var n = ; var l = <>

;"; + let two = ""; + for (action, after) in [ + ("x.replace('b', n)", ""), + ("x.replace('b', l)", "

"), + ("x.b = n", ""), + ("x.b = l", "

"), + ("x.b = 'v'", "v"), + ] { + assert!(fuel_stops(setup, action, &tree_is("x", two, after)) > 0, "{action}"); + } + let by_name = "x.replace({ toString: function () { SPARE; return 'b'; } }, n)"; + assert!(heap_stops(setup, by_name, &tree_is("x", two, "")) > 0); + assert!(heap_stops(setup, "x.b = (SPARE, 'v')", &tree_is("x", two, "v")) > 0); +} + +#[test] +fn matches_are_replaced_in_document_order() { + truth("var x = 123; x.replace('b', ); x.*.length() === 3 && x.*[0].name() == 'n' && x.d.length() === 1"); + truth("var x = 12; var old = x.b[1]; x.b = 'v'; x.b.length() === 1 && x.b == 'v' && old.parent() === null"); + truth("var x = ; x.b = 'v'; x.b == 'v' && x.*.length() === 2 && x.b[0].parent() === x"); +} + #[test] fn a_limit_during_normalize_does_not_repeat_text() { let setup = "var x = ; x.appendChild('p'); x.appendChild('q'); x.appendChild('r');"; From 01673695afb3e9d4e9cc3d1999548168f4617e38 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 07:47:29 -0800 Subject: [PATCH 5/8] JavaScript: a computed XML name or attribute run cannot add markup An embedded expression used as a tag or attribute name must now be one XML name, and one used as a run of attributes must hold only name="value" attributes separated by white space. Anything else throws SyntaxError, so a script string cannot add an attribute, close the tag or add a child. {name}={value} in a tag now reads as a computed attribute name, as in the example in ECMA-357 section 11.1.4. --- crates/js/src/ast.rs | 6 ++- crates/js/src/bytecode.rs | 2 +- crates/js/src/compiler.rs | 5 ++- crates/js/src/parser/xml.rs | 38 ++++++++++--------- crates/js/src/xml.rs | 74 ++++++++++++++++++++++++++++++------- crates/js/src/xml_tests.rs | 74 +++++++++++++++++++++++++++++++++++++ 6 files changed, 164 insertions(+), 35 deletions(-) diff --git a/crates/js/src/ast.rs b/crates/js/src/ast.rs index ca991645b..5a4363108 100644 --- a/crates/js/src/ast.rs +++ b/crates/js/src/ast.rs @@ -210,8 +210,10 @@ pub enum XmlPart { Content(ExprId), /// `{expression}` as an attribute's value. Attribute(ExprId), - /// `{expression}` as a name or a run of attributes, its string used as markup. - Raw(ExprId), + /// `{expression}` as an element or attribute name, its string checked as an XML name. + Name(ExprId), + /// `{expression}` as a run of attributes, its string checked as `name="value"` attributes. + Attributes(ExprId), } /// What stands before `::` in a qualified name. diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index 895d91171..ed99aa95d 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -153,7 +153,7 @@ pub(crate) enum Op { /// `object name` → the descendants that match. XmlDescendants, /// `value` → the string an embedded expression puts in a literal (0 content, 1 attribute - /// value, 2 name). + /// value, 2 name, 3 run of attributes). XmlEscape(u8), /// The `parts` strings → the XML or XMLList they spell. XmlLiteral { diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index f0f171d59..9c07113cc 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -861,12 +861,13 @@ impl<'a> FnCompiler<'a> { let i = self.string(units); self.emit(Op::String(i)); } - XmlPart::Content(e) | XmlPart::Attribute(e) | XmlPart::Raw(e) => { + XmlPart::Content(e) | XmlPart::Attribute(e) | XmlPart::Name(e) | XmlPart::Attributes(e) => { self.expr(*e)?; self.emit(Op::XmlEscape(match part { XmlPart::Content(_) => 0, XmlPart::Attribute(_) => 1, - _ => 2, + XmlPart::Name(_) => 2, + _ => 3, })); } } diff --git a/crates/js/src/parser/xml.rs b/crates/js/src/parser/xml.rs index 0b29bf879..e91691a58 100644 --- a/crates/js/src/parser/xml.rs +++ b/crates/js/src/parser/xml.rs @@ -8,7 +8,7 @@ use super::{Parser, Result, describe}; use crate::ast::{Expr, ExprId, Stmt, StmtId, XmlNamePart, XmlPart, XmlQualifier}; use crate::error::TOO_DEEP; use crate::token::{Keyword, Punct, Span, TokenKind}; -use crate::xml::{is_name_part, is_name_start, is_xml_space, scan_reference, valid_xml_units}; +use crate::xml::{is_name_start, is_xml_space, scan_name, scan_reference}; const LT: u16 = b'<' as u16; const GT: u16 = b'>' as u16; @@ -277,17 +277,7 @@ impl Parser<'_> { /// Reads a static name at `pos`, which must be one, and returns the offset after it. fn xml_static_name(&self, pos: usize) -> Result { - if !self.unit(pos).is_some_and(is_name_start) { - return Err(self.xml_error(pos, "invalid XML name")); - } - let mut at = pos + 1; - while self.unit(at).is_some_and(is_name_part) { - at += 1; - } - if !valid_xml_units(self.src.get(pos..at).unwrap_or_default()) { - return Err(self.xml_error(pos, "invalid XML name")); - } - Ok(at) + scan_name(self.src, pos).ok_or_else(|| self.xml_error(pos, "invalid XML name")) } fn xml_skip_space(&self, mut pos: usize, scan: &mut Scan) -> usize { @@ -308,7 +298,7 @@ impl Parser<'_> { let name = if self.unit(at) == Some(LBRACE) { scan.flush(); let (e, after) = self.xml_embedded(at)?; - scan.parts.push(XmlPart::Raw(e)); + scan.parts.push(XmlPart::Name(e)); at = after; None } else { @@ -338,8 +328,17 @@ impl Parser<'_> { } scan.flush(); let (e, after) = self.xml_embedded(at)?; - scan.parts.push(XmlPart::Raw(e)); - at = after; + let mut next = after; + while self.unit(next).is_some_and(is_xml_space) { + next += 1; + } + if self.unit(next) == Some(EQ) { + scan.parts.push(XmlPart::Name(e)); + at = self.xml_attribute_value(after, scan)?; + } else { + scan.parts.push(XmlPart::Attributes(e)); + at = after; + } } Some(_) if at == before => return Err(self.xml_error(at, "expected white space before an attribute")), Some(_) => at = self.xml_attribute(at, scan)?, @@ -351,7 +350,12 @@ impl Parser<'_> { fn xml_attribute(&mut self, pos: usize, scan: &mut Scan) -> Result { let end = self.xml_static_name(pos)?; self.xml_copy(pos, end, scan); - let at = self.xml_skip_space(end, scan); + self.xml_attribute_value(end, scan) + } + + /// Reads the `=` and value of an attribute whose name ends at `pos` and returns the offset after them. + fn xml_attribute_value(&mut self, pos: usize, scan: &mut Scan) -> Result { + let at = self.xml_skip_space(pos, scan); if self.unit(at) != Some(EQ) { return Err(self.xml_error(at, "expected '=' after an attribute name")); } @@ -397,7 +401,7 @@ impl Parser<'_> { let name = if self.unit(at) == Some(LBRACE) { scan.flush(); let (e, after) = self.xml_embedded(at)?; - scan.parts.push(XmlPart::Raw(e)); + scan.parts.push(XmlPart::Name(e)); at = after; None } else { diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 6cfe962a1..5a02ccdc1 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -18,7 +18,7 @@ use crate::heap::{ XmlListData, XmlName, XmlNode, XmlNs, key, string_bytes, }; use crate::interp::{Abrupt, ErrorType, HeldBytes, Interp, JsResult}; -use crate::string_builtins::UNITS_PER_FUEL; +use crate::string_builtins::{UNITS_PER_FUEL, flat_charged}; use crate::value::{Str, Value, index_key}; fn parser_units(s: &str) -> Vec { @@ -123,6 +123,53 @@ pub(crate) fn is_name_part(c: u16) -> bool { is_name_start(c) || matches!(c, 0x2D | 0x2E | 0xB7) || (c != 0x24 && is_id_part(u32::from(c))) } +/// The offset after the XML name that starts at `src[pos]`, when one does. +pub(crate) fn scan_name(src: &[u16], pos: usize) -> Option { + if !src.get(pos).copied().is_some_and(is_name_start) { + return None; + } + let mut end = pos + 1; + while src.get(end).copied().is_some_and(is_name_part) { + end += 1; + } + valid_xml_units(src.get(pos..end).unwrap_or_default()).then_some(end) +} + +fn skip_xml_space(src: &[u16], mut pos: usize) -> usize { + while src.get(pos).copied().is_some_and(is_xml_space) { + pos += 1; + } + pos +} + +/// Whether `units` holds only `name="value"` attributes separated by white space. +fn is_attribute_run(units: &[u16]) -> bool { + let mut at = skip_xml_space(units, 0); + while at < units.len() { + let Some(name_end) = scan_name(units, at) else { return false }; + let eq = skip_xml_space(units, name_end); + if units.get(eq) != Some(&EQ) { + return false; + } + let open = skip_xml_space(units, eq + 1); + let quote = match units.get(open).copied() { + Some(q @ (DQUOTE | SQUOTE)) => q, + _ => return false, + }; + let value = units.get(open + 1..).unwrap_or_default(); + let Some(len) = value.iter().position(|&c| c == quote || c == LT) else { return false }; + if value.get(len) != Some("e) { + return false; + } + let end = open + len + 2; + at = skip_xml_space(units, end); + if at == end && at < units.len() { + return false; + } + } + true +} + /// An XML name without a prefix (ECMA-357 section 13.1.2.1). pub(crate) fn is_ncname(units: &[u16]) -> bool { let Some((&first, rest)) = units.split_first() else { return false }; @@ -2145,17 +2192,7 @@ impl Interp { } fn read_name(&mut self, src: &[u16], pos: usize) -> JsResult { - if !src.get(pos).copied().is_some_and(is_name_start) { - return Err(self.syntax("invalid XML name")); - } - let mut end = pos + 1; - while src.get(end).copied().is_some_and(is_name_part) { - end += 1; - } - if !valid_xml_units(src.get(pos..end).unwrap_or_default()) { - return Err(self.syntax("invalid XML name")); - } - Ok(end) + scan_name(src, pos).ok_or_else(|| self.syntax("invalid XML name")) } fn check_units(&mut self, src: &[u16], from: usize, to: usize) -> JsResult<()> { @@ -2605,7 +2642,18 @@ impl Interp { let Value::String(s) = out.finish(self) else { return Err(Abrupt::Internal("not a string".into())) }; s } - _ => self.to_str(&v)?, + _ => { + let text = self.to_str(&v)?; + let units = flat_charged(self, &text)?; + self.xml_take_fuel(units.len() / UNITS_PER_FUEL + 1)?; + if kind == 2 && scan_name(&units, 0) != Some(units.len()) { + return Err(self.syntax("an embedded expression used as an XML name does not give a name")); + } + if kind != 2 && !is_attribute_run(&units) { + return Err(self.syntax("an embedded expression used as XML attributes does not give only attributes")); + } + text + } }; self.replace(1, Value::String(result)) } diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 86b813d25..58c984ad4 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -120,6 +120,60 @@ fn embedded_expressions_are_escaped_by_position() { truth("({1 < 2}) == 'true'"); } +#[test] +fn a_computed_tag_name_cannot_add_markup() { + for src in [ + r#"(<{'a x="1"'}/>)"#, + "(<{'a>)", + "(t)", + "(<{''}/>)", + "(<{'1a'}/>)", + "()", + r#"()"#, + ] { + assert_eq!(thrown_name(src), "SyntaxError", "{src}"); + } +} + +#[test] +fn a_computed_name_may_be_any_xml_name() { + assert_eq!(string("(<{'a.b-c'}/>).toXMLString()"), ""); + assert_eq!(string("(<{'tag'}>t).toXMLString()"), "t"); + assert_eq!(string("var n = 'n:b'; (<{n}/>).toXMLString()"), "\n \n"); +} + +#[test] +fn a_computed_attribute_name_matches_the_standard_example() { + let vars = "var tagname = 'name', attributename = 'id', attributevalue = 5, content = 'Fred';"; + let literal = "<{tagname} {attributename}={attributevalue}>{content}"; + assert_eq!(string(&format!("{vars} ({literal}).toXMLString()")), "Fred"); + assert_eq!(string(r#"var a = 'id'; ().toXMLString()"#), ""); +} + +#[test] +fn a_computed_attribute_run_cannot_add_markup() { + for src in [ + r#"()"#, + r#"(<>)"#, + "()", + "()", + r#"()"#, + r#"()"#, + ] { + assert_eq!(thrown_name(src), "SyntaxError", "{src}"); + } +} + +#[test] +fn a_computed_attribute_run_may_hold_any_attributes() { + truth( + r#"var a = " a=\"1\"\n b = '>' "; var x = ; x.@a == '1' && x.@b == '>' && x.attributes().length() === 2 && x.children().length() === 0"#, + ); + assert_eq!(string("().toXMLString()"), ""); + assert_eq!(string("().toXMLString()"), ""); +} + // ---- selectors and updates ---- #[test] @@ -953,9 +1007,29 @@ fn tree_markup(shape: &[u8]) -> String { out } +const TAG_PIECES: [&str; 11] = ["a", "b", " ", "=", "\"", "'", "<", "/", "<", "a=\"1\"", ">; v.children().length() === 0 && v.attributes().length() === 0"), + format!("var v = ; v.children().length() === 0 && v.name().localName === 'x'"), + format!("(<>).length() === 1"), + format!("var v = ; v.children().length() === 1"), + ] { + let mut r = limited(Limits { fuel: 200_000, heap_bytes: 4 << 20, frames: 200, reentry: 8 }); + match r.run(&src) { + Ok(v) => prop_assert_eq!(v, JsValue::Bool(true), "{}", src), + Err(e) => prop_assert!(e.kind != RunErrorKind::Internal, "{src:?}: {e:?}"), + } + } + } + #[test] fn source_mixes_parse_or_give_a_located_error(parts in proptest::collection::vec(0..PIECES.len(), 0..40)) { let src: String = parts.iter().map(|&i| PIECES[i]).collect(); From 33f04522f8742d068e310e6c02eef9f8bfe598e0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 07:58:49 -0800 Subject: [PATCH 6/8] JavaScript: an attribute placed among XML children goes in as text ECMA-357 sends a single attribute given to [[Insert]] through [[Replace]], which makes a text node. A list holding an attribute went into the children as the attribute itself, changed its parent and stayed on its old owner. Each attribute now goes in as a new text node with its value, and it stays on its owner. A list assigned such a value holds the new text nodes, and an attribute does not count against the depth limit. --- crates/js/src/xml.rs | 79 ++++++++++++++++++++++++++------------ crates/js/src/xml_tests.rs | 44 +++++++++++++++++++++ 2 files changed, 98 insertions(+), 25 deletions(-) diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 5a02ccdc1..c84bd6a6d 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -907,7 +907,9 @@ impl Interp { Content::List(l) => { for at in 0..self.list_len(*l)? { let Some(item) = self.list_at(*l, at)? else { break }; - self.check_attach_node(x, item)?; + if self.xml_kind(item)? != XmlKind::Attribute { + self.check_attach_node(x, item)?; + } } Ok(()) } @@ -940,9 +942,6 @@ impl Interp { /// position in `x` to use after the removal. The caller has taken [`Self::place_fuel`]. fn detach(&mut self, x: ObjId, v: ObjId, at: usize) -> JsResult { let Some(parent) = self.xml_node(v)?.parent else { return Ok(at) }; - if self.xml_kind(v)? == XmlKind::Attribute { - return Ok(at); - } let Some(q) = self.xml_node(parent)?.children.iter().position(|&c| c == v) else { return Ok(at) }; self.remove_child(parent, q)?; Ok(if parent == x && q < at { at - 1 } else { at }) @@ -952,10 +951,7 @@ impl Interp { /// the scan and removal that take it out. fn place_fuel(&mut self, len: usize, v: ObjId) -> JsResult<()> { self.xml_text_fuel(len + 1)?; - let node = self.xml_node(v)?; - if node.kind != XmlKind::Attribute - && let Some(parent) = node.parent - { + if let Some(parent) = self.xml_node(v)?.parent { let old = self.children_len(parent)?; self.xml_text_fuel(old)?; self.xml_text_fuel(old)?; @@ -987,15 +983,30 @@ impl Interp { self.place_child(x, at, v) } - /// Takes the fuel and heap to put every item of `l` under `x` and returns the items. The tree - /// does not change here, so a limit leaves it as it was. + /// The node to place as a child for `item`: `item` itself, or a new text node holding the + /// value when it is an attribute. `roots` are held across any collection. + fn child_for_item(&mut self, item: ObjId, roots: &[Value]) -> JsResult { + if self.xml_kind(item)? != XmlKind::Attribute { + return Ok(item); + } + let value = self.xml_node(item)?.value.clone(); + let t = self.rooted(roots, |i| i.new_text_node(value, None))?; + self.xml_keep(t); + Ok(t) + } + + /// Takes the fuel and heap to put every item of `l` under `x` and returns the nodes to place, + /// from [`Self::child_for_item`]. The tree does not change here, so a limit leaves it as it + /// was. fn reserve_list_insert(&mut self, x: ObjId, l: ObjId) -> JsResult> { - let items = self.xml_list(l)?.items.clone(); + let mut items = self.xml_list(l)?.items.clone(); let bytes = XML_REF_BYTES.saturating_mul(items.len()); - self.rooted(&[Value::Object(x), Value::Object(l)], |i| i.xml_charge(bytes))?; + let roots = [Value::Object(x), Value::Object(l)]; + self.rooted(&roots, |i| i.xml_charge(bytes))?; let len = self.children_len(x)?; - for (j, &item) in items.iter().enumerate() { - self.place_fuel(len + j, item)?; + for (j, item) in items.iter_mut().enumerate() { + *item = self.child_for_item(*item, &roots)?; + self.place_fuel(len + j, *item)?; } Ok(items) } @@ -1009,6 +1020,17 @@ impl Interp { Ok(()) } + /// Puts a new text node holding `s` at position `at` of `x`. + fn insert_text(&mut self, x: ObjId, at: usize, s: &Str) -> JsResult<()> { + let t = self.new_text_node(s.clone(), Some(x))?; + self.xml_keep(t); + self.xml_charge(XML_REF_BYTES)?; + let node = self.xml_node_mut(x)?; + let at = at.min(node.children.len()); + node.children.insert(at, t); + Ok(()) + } + /// [[Insert]] (ECMA-357 section 9.1.1.11). pub(crate) fn xml_insert(&mut self, x: ObjId, at: usize, c: &Content) -> JsResult<()> { if self.xml_kind(x)? != XmlKind::Element { @@ -1016,13 +1038,10 @@ impl Interp { } self.check_attach(x, c)?; match c { - Content::Text(s) => { - let t = self.new_text_node(s.clone(), Some(x))?; - self.xml_keep(t); - self.xml_charge(XML_REF_BYTES)?; - let node = self.xml_node_mut(x)?; - let at = at.min(node.children.len()); - node.children.insert(at, t); + Content::Text(s) => self.insert_text(x, at, s)?, + Content::Node(v) if self.xml_kind(*v)? == XmlKind::Attribute => { + let value = self.xml_node(*v)?.value.clone(); + self.insert_text(x, at, &value)?; } Content::Node(v) => { self.insert_node(x, at, *v)?; @@ -1473,11 +1492,21 @@ impl Interp { Content::List(l) => { let c = self.list_shallow_copy(l)?; self.xml_keep(c); - let items = self.xml_list(c)?.items.clone(); + let place = match parent { + Some(parent) => self.index_in_parent(parent, item)?.map(|q| (parent, q)), + None => None, + }; + let mut items = self.xml_list(c)?.items.clone(); + if place.is_some() { + let roots = [Value::Object(x), Value::Object(c)]; + for item in &mut items { + self.use_fuel()?; + *item = self.child_for_item(*item, &roots)?; + } + self.xml_list_mut(c)?.items.clone_from(&items); + } self.xml_charge(XML_REF_BYTES.saturating_mul(items.len()))?; - if let Some(parent) = parent - && let Some(q) = self.index_in_parent(parent, item)? - { + if let Some((parent, q)) = place { self.xml_replace(parent, q, &Content::List(c))?; } self.xml_list_mut(x)?.items.splice(i..=i, items); diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 58c984ad4..40d66fd64 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -911,6 +911,50 @@ fn matches_are_replaced_in_document_order() { truth("var x = ; x.b = 'v'; x.b == 'v' && x.*.length() === 2 && x.b[0].parent() === x"); } +#[test] +fn an_attribute_put_among_children_goes_in_as_text_and_stays_on_its_owner() { + let setup = "var y = ; var x = ;"; + let check = "x.b.length() === 0 && x.children().length() === 1 && x.children()[0].nodeKind() === 'text' && x.toString() === 'v' \ + && y.@p.length() === 1 && y.@p.parent() === y && y.@p[0].parent() === y"; + for edit in ["x.replace(0, y.@p)", "x.replace(0, y.@p[0])", "x.b = y.@p", "x.setChildren(y.@p)", "x.*[0] = y.@p"] { + truth(&format!("{setup} {edit}; {check}")); + } + let owner = "y.@p.length() === 1 && y.@p.parent() === y && y.@p[0].parent() === y"; + for (edit, k) in [ + ("x.prependChild(y.@p)", "0"), + ("x.prependChild(y.@p[0])", "0"), + ("x.appendChild(y.@p)", "1"), + ("x.appendChild(y.@p[0])", "1"), + ("x.insertChildAfter(x.b[0], y.@p)", "1"), + ("x.insertChildBefore(x.b[0], y.@p[0])", "0"), + ] { + truth(&format!( + "{setup} {edit}; x.children().length() === 2 && x.children()[{k}].nodeKind() === 'text' && x.children()[{k}] == 'v' && {owner}" + )); + } +} + +#[test] +fn a_list_assigned_attributes_holds_the_text_nodes_in_the_tree() { + truth( + "var y = ; var x = ; var l = x.*; l[0] = y.@*; \ + l.length() === 2 && l[0].nodeKind() === 'text' && l[0].parent() === x && l[1].nodeKind() === 'text' && l[1].parent() === x \ + && x.toString() === 'vw' && y.@*.length() === 2", + ); +} + +#[test] +fn an_attribute_list_goes_in_as_text_at_the_depth_limit() { + let open = "".repeat(7); + let close = "".repeat(7); + let src = format!( + "var x = new XML('{open}{close}'); var c = x; while (c.a.length()) c = c.a[0]; \ + var y = ; c.prependChild(y.@p); c.toString() === 'v'" + ); + let mut realm = Realm::with_options(Options { max_depth: 8, ..e4x() }, Limits::default()); + assert_eq!(realm.run(&src), Ok(JsValue::Bool(true))); +} + #[test] fn a_limit_during_normalize_does_not_repeat_text() { let setup = "var x = ; x.appendChild('p'); x.appendChild('q'); x.appendChild('r');"; From bc6c20d0c9cb6d1cf127ff9dc77d9e4875a419c2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 08:00:42 -0800 Subject: [PATCH 7/8] JavaScript: a limit during an XML path assignment or list copy keeps the tree whole - A list copy holds each new copy as a root while the list is charged for it, so a collection at that point no longer frees the copy. Before, `x.setChildren( + )` near the heap limit could leave one child twice or end with an internal error. - An assignment to a path such as `x.a.b = v` or `x.a.b[0] = v` removes the elements it added for the path when a later step stops at a limit or throws. - Comparing XML values counts the attribute tables and the work stack against the heap limit. --- crates/js/src/xml.rs | 153 ++++++++++++++++++++++++++++--------- crates/js/src/xml_tests.rs | 56 ++++++++++++++ 2 files changed, 175 insertions(+), 34 deletions(-) diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index c84bd6a6d..9dc6d71f8 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -26,6 +26,11 @@ fn parser_units(s: &str) -> Vec { } pub(crate) const STAR: &[u16] = &[0x2A]; +/// The estimated bytes, for one attribute, of the copies of two attribute lists and the count of +/// names and values that comparing two XML values builds. +const ATTR_COUNT_BYTES: usize = 96; +/// The estimated bytes of one frame of the work stack that comparing two XML values uses. +const EQUALS_FRAME_BYTES: usize = 24; const XML_URI: &str = "http://www.w3.org/XML/1998/namespace"; const XMLNS_URI: &str = "http://www.w3.org/2000/xmlns/"; pub(crate) const RESERVED_NS_BINDING: &str = "a namespace declaration uses a reserved prefix or URI"; @@ -460,8 +465,10 @@ impl Interp { Ok(self.heap.alloc_object(Object::new(Class::XmlList(Box::new(data)), Some(proto)))) } + /// Adds `item` to the end of `list`. Both are held as roots across any collection the charge + /// starts. pub(crate) fn list_push(&mut self, list: ObjId, item: ObjId) -> JsResult<()> { - self.xml_charge(XML_REF_BYTES)?; + self.rooted(&[Value::Object(list), Value::Object(item)], |i| i.xml_charge(XML_REF_BYTES))?; self.xml_list_mut(list)?.items.push(item); Ok(()) } @@ -1297,8 +1304,34 @@ impl Interp { if n.attribute { self.put_attribute(x, n, &c) } else { self.put_element(x, n, &c) } } - /// [[ResolveValue]] (ECMA-357 section 9.2.1.10), walking the targets with a loop. - fn resolve_value(&mut self, x: ObjId) -> JsResult> { + /// [[ResolveValue]] (ECMA-357 section 9.2.1.10), walking the targets with a loop. Also returns + /// the outermost element it added, if any. An error takes the added elements back out. + fn resolve_value(&mut self, x: ObjId) -> JsResult)>> { + let mut created = None; + let r = self.resolve_value_adding(x, &mut created); + match (r, created) { + (Ok(Some(base)), created) => Ok(Some((base, created))), + (Ok(None), _) => Ok(None), + (Err(e), Some(c)) => { + self.undo_created(c)?; + Err(e) + } + (Err(e), None) => Err(e), + } + } + + /// Takes `created`, an element a path assignment added, back out of its parent. It takes no + /// fuel or heap. + fn undo_created(&mut self, created: ObjId) -> JsResult<()> { + if let Some(parent) = self.xml_node(created)?.parent { + self.xml_node_mut(parent)?.children.retain(|&c| c != created); + self.xml_node_mut(created)?.parent = None; + } + Ok(()) + } + + /// The steps of [`Self::resolve_value`], setting `created` to the first element they add. + fn resolve_value_adding(&mut self, x: ObjId, created: &mut Option) -> JsResult> { let mut chain = Vec::new(); let mut cur = x; let base = loop { @@ -1328,7 +1361,22 @@ impl Interp { { return Ok(None); } + let owner = match self.classify(&Value::Object(base)) { + Xv::Node(b) => Some(b), + Xv::List(b) => self.list_at(b, 0)?, + Xv::Other => None, + }; + let before = match owner { + Some(o) => self.children_len(o)?, + None => 0, + }; self.put_name(base, &n, &Value::str(""))?; + if created.is_none() + && let Some(o) = owner + && self.children_len(o)? > before + { + *created = self.xml_node(o)?.children.last().copied(); + } target = self.xml_select(base, &n)?; self.xml_keep(target); } @@ -1342,14 +1390,15 @@ impl Interp { Xv::Node(x) => self.node_put(x, n, v), Xv::List(l) => { if self.list_len(l)? == 0 { - let Some(r) = self.resolve_value(l)? else { return Ok(()) }; - let single = match self.classify(&Value::Object(r)) { - Xv::List(rl) if self.list_len(rl)? == 1 => self.list_at(rl, 0)?, - Xv::Node(rn) => Some(rn), - _ => None, - }; - let Some(single) = single else { return Ok(()) }; - self.list_push(l, single)?; + let Some((r, created)) = self.resolve_value(l)? else { return Ok(()) }; + let done = self.put_resolved(l, r, n, v); + if done.is_err() + && let Some(c) = created + { + self.undo_created(c)?; + self.xml_list_mut(l)?.items.clear(); + } + return done; } if self.list_len(l)? == 1 && let Some(item) = self.list_at(l, 0)? @@ -1362,17 +1411,41 @@ impl Interp { } } + /// Assigns `v` to the name `n` of `r`, the value [`Self::resolve_value`] gave for the empty + /// list `l`, when `r` is one node, and makes that node the item of `l`. + fn put_resolved(&mut self, l: ObjId, r: ObjId, n: &NameRef, v: &Value) -> JsResult<()> { + let single = match self.classify(&Value::Object(r)) { + Xv::List(rl) if self.list_len(rl)? == 1 => self.list_at(rl, 0)?, + Xv::Node(rn) => Some(rn), + _ => None, + }; + let Some(single) = single else { return Ok(()) }; + self.list_push(l, single)?; + self.node_put(single, n, v) + } + fn list_put_index(&mut self, x: ObjId, index: usize, v: &Value) -> JsResult<()> { let data = self.xml_list(x)?; let target = data.target; let target_name = data.target_name.clone(); - let r = match target { + let (r, created) = match target { Some(t) => match self.resolve_value(t)? { - Some(r) => Some(r), + Some((r, created)) => (Some(r), created), None => return Ok(()), }, - None => None, + None => (None, None), }; + let put = self.list_put_resolved(x, index, v, r, target_name.as_ref()); + if put.is_err() + && let Some(c) = created + { + self.undo_created(c)?; + } + put + } + + /// The steps of [`Self::list_put_index`] after the target `r` is resolved. + fn list_put_resolved(&mut self, x: ObjId, index: usize, v: &Value, r: Option, target_name: Option<&(XmlName, bool)>) -> JsResult<()> { if index >= self.list_len(x)? { let r = match r { Some(r) => match self.classify(&Value::Object(r)) { @@ -1391,7 +1464,7 @@ impl Interp { { return Ok(()); } - let (kind, name) = match &target_name { + let (kind, name) = match target_name { Some((name, true)) => { if let Some(r) = r { let n = NameRef { uri: name.uri.clone(), local: name.local.clone(), attribute: true }; @@ -1672,8 +1745,12 @@ impl Interp { if x.attrs.is_empty() { return Ok(true); } + let count = x.attrs.len(); + self.xml_take_fuel(count)?; + let mut held = HeldBytes::new(self); + held.add(self, ATTR_COUNT_BYTES.saturating_mul(count))?; + let (x, y) = (self.xml_node(a)?, self.xml_node(b)?); let (xa, ya) = (x.attrs.clone(), y.attrs.clone()); - self.xml_take_fuel(xa.len())?; let mut counts: HashMap<(Option, Key, Key), usize> = HashMap::new(); let attr_key = |i: &Self, id: ObjId| -> JsResult<(Option, Key, Key)> { let n = i.xml_node(id)?; @@ -1692,27 +1769,35 @@ impl Interp { Ok(true) } - /// [[Equals]] of two XML values (ECMA-357 section 9.1.1.9). + /// [[Equals]] of two XML values (ECMA-357 section 9.1.1.9). The bytes it holds count against + /// the heap limit, and `a` and `b` are held as roots. pub(crate) fn node_equals(&mut self, a: ObjId, b: ObjId) -> JsResult { - if !self.same_shallow(a, b)? { - return Ok(false); - } - let mut stack = vec![(a, b, 0usize)]; - while let Some(&(x, y, next)) = stack.last() { - self.use_fuel()?; - let (Some(cx), Some(cy)) = (self.child_at(x, next)?, self.child_at(y, next)?) else { - stack.pop(); - continue; - }; - if let Some(frame) = stack.last_mut() { - frame.2 += 1; - } - if !self.same_shallow(cx, cy)? { + self.rooted(&[Value::Object(a), Value::Object(b)], |i| { + if !i.same_shallow(a, b)? { return Ok(false); } - stack.push((cx, cy, 0)); - } - Ok(true) + let mut held = HeldBytes::new(i); + held.add(i, EQUALS_FRAME_BYTES)?; + let mut stack = vec![(a, b, 0usize)]; + while let Some(&(x, y, next)) = stack.last() { + i.use_fuel()?; + let (Some(cx), Some(cy)) = (i.child_at(x, next)?, i.child_at(y, next)?) else { + stack.pop(); + continue; + }; + if let Some(frame) = stack.last_mut() { + frame.2 += 1; + } + if !i.same_shallow(cx, cy)? { + return Ok(false); + } + if stack.len() == stack.capacity() { + held.add(i, EQUALS_FRAME_BYTES.saturating_mul(stack.len()))?; + } + stack.push((cx, cy, 0)); + } + Ok(true) + }) } fn list_equals(&mut self, l: ObjId, v: &Value) -> JsResult { diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 40d66fd64..3d801e050 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -962,6 +962,62 @@ fn a_limit_during_normalize_does_not_repeat_text() { assert!(fuel_stops(setup, "x.normalize()", check) > 0); } +/// Runs `action` as [`heap_stops`] does, and asserts that every run completes or stops at the heap +/// limit and that `check` is true after it. Returns how many runs stopped. +fn heap_stops_only(setup: &str, action: &str, check: &str) -> usize { + let mut r = realm(); + let interp = r.interp(); + let global = interp.global; + assert!(crate::builtins::method(interp, global, "squeeze", squeeze, 1).is_ok()); + let mut stops = 0; + for spare in 0..100_000i64 { + r.set_limits(Limits::default()); + r.run(setup).unwrap(); + let action = action.replace("SPARE", &format!("squeeze({})", spare - 512)); + let got = r.run(&action); + r.set_limits(Limits::default()); + assert_eq!(r.run(check), Ok(JsValue::Bool(true)), "{action}: {got:?}"); + match got { + Ok(_) => return stops, + Err(e) if e.kind == RunErrorKind::Limit(LimitKind::Heap) => stops += 1, + Err(e) => panic!("{action}: {e:?}"), + } + } + panic!("{action} never completed"); +} + +#[test] +fn collections_while_a_list_is_copied_keep_every_copy() { + let setup = "var x = ; var v = + ; var w = undefined;"; + let set = tree_is("x", "", ""); + for action in ["x.setChildren((SPARE, v))", "x.* = (SPARE, v)"] { + assert!(heap_stops_only(setup, action, &set) > 0, "{action}"); + } + let copied = "w === undefined || (w.length() === 2 && w[0].name() == 'c' && w[1].name() == 'd' && w[0] !== v[0])"; + assert!(heap_stops_only(setup, "w = (SPARE, v).copy()", copied) > 0); +} + +#[test] +fn a_limit_during_path_assignment_leaves_no_new_element() { + let setup = "var x = ; var v = ;"; + for (target, value, after) in [ + ("x.a.b.c", "v", ""), + ("x.a.b", "'text'", "text"), + ("x.a.@p", "'text'", ""), + ("x.a.b[0]", "'text'", "text"), + ] { + let check = tree_is("x", "", after); + assert!(fuel_stops(setup, &format!("{target} = {value}"), &check) > 0, "{target}"); + assert!(heap_stops_only(setup, &format!("{target} = (SPARE, {value})"), &check) > 0, "{target}"); + } +} + +#[test] +fn comparing_xml_values_is_charged_to_the_heap() { + let setup = "var x = t; var y = x.copy();"; + assert!(heap_stops_only(setup, "(SPARE, x == y)", "x == y && y.@q == '2'") > 0); +} + #[test] fn an_interrupt_stops_serialization() { let mut r = realm(); From de95640d93017d924e94cbe6d5841b276ae94d4a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 08:09:22 -0800 Subject: [PATCH 8/8] JavaScript: XML namespace declarations and QName strings count against the heap limit addNamespace and setNamespace now add a declaration's bytes to the heap total when the element gains a new prefix, and QName.prototype.toString adds the bytes of the string it returns. Before, both only checked for room, so a loop of distinct prefixes could grow an element past the heap limit until a collection recounted it. --- crates/js/src/xml_builtins.rs | 10 ++++++++-- crates/js/src/xml_tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/js/src/xml_builtins.rs b/crates/js/src/xml_builtins.rs index 1662dc5db..35c578830 100644 --- a/crates/js/src/xml_builtins.rs +++ b/crates/js/src/xml_builtins.rs @@ -299,7 +299,9 @@ fn qname_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> Js _ => {} } units.extend_from_slice(&name.local); - interp.reserve_heap(string_bytes(units.len()))?; + let bytes = string_bytes(units.len()); + interp.reserve_heap(bytes)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); Ok(Value::String(Str::from_units(units))) } @@ -816,7 +818,11 @@ fn add_in_scope(interp: &mut Interp, x: ObjId, ns: XmlNs) -> JsResult<()> { if prefix.is_empty() && node.name.as_ref().and_then(|n| n.uri.as_ref()).is_some_and(|u| u.is_empty()) { return Ok(()); } - interp.reserve_heap(crate::heap::XML_NS_BYTES)?; + if !node.ns.iter().any(|m| m.prefix.as_ref() == Some(&prefix)) { + let bytes = crate::heap::XML_NS_BYTES; + interp.reserve_heap(bytes)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + } let attrs = interp.xml_node(x)?.attrs.clone(); let node = interp.xml_node_mut(x)?; node.ns.retain(|m| m.prefix.as_ref() != Some(&prefix)); diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 3d801e050..051c676a9 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -1018,6 +1018,32 @@ fn comparing_xml_values_is_charged_to_the_heap() { assert!(heap_stops_only(setup, "(SPARE, x == y)", "x == y && y.@q == '2'") > 0); } +/// Runs `setup` with no limit, then `action` in the same realm, and returns how `action` ended. +fn after_setup(setup: &str, action: &str) -> Result { + let mut r = realm(); + let interp = r.interp(); + let global = interp.global; + assert!(crate::builtins::method(interp, global, "squeeze", squeeze, 1).is_ok()); + r.run(setup).unwrap(); + r.run(action).map_err(|e| e.kind) +} + +#[test] +fn namespace_declarations_and_qname_strings_are_charged_to_the_heap() { + let heap = Err(RunErrorKind::Limit(LimitKind::Heap)); + let distinct = "var x = ; var a = []; for (var i = 0; i < 400; i++) a.push(new Namespace('p' + i, 'u' + i));"; + for method in ["addNamespace", "setNamespace"] { + let action = format!("squeeze(4096); for (var i = 0; i < 400; i++) x.{method}(a[i]); 1"); + assert_eq!(after_setup(distinct, &action), heap, "{method}"); + } + let same = "var x = ; var n = new Namespace('p', 'u');"; + let action = "squeeze(4096); for (var i = 0; i < 400; i++) x.addNamespace(n); 1"; + assert_eq!(after_setup(same, action), Ok(JsValue::Number(1.0))); + let long = "var s = 'x'; for (var i = 0; i < 12; i++) s += s; var q = new QName('u', s);"; + let action = "squeeze(12000); var t1 = q.toString(); var t2 = q.toString(); 1"; + assert_eq!(after_setup(long, action), heap); +} + #[test] fn an_interrupt_stops_serialization() { let mut r = realm();