Scripts: E4X checks the XML declaration and takes fuel for every run the parser scans

An XML declaration at the start of XML or XMLList text was skipped up to
the next "?>" without being read. It is now checked against XML 1.0's
grammar (https://www.w3.org/TR/xml/#NT-XMLDecl): a version of "1." and
digits, then an optional encoding name and an optional standalone "yes"
or "no", in that order, with optional white space before "?>". Any other
declaration gives "SyntaxError: invalid XML declaration", and a character
XML does not allow gives "invalid character in XML text". White space
before the declaration is still accepted.

The parser now takes fuel for the length of the declaration and the
white space before it, element and attribute names, white space in open
and closing tags, and the text it searched when an attribute value, CDATA
section, comment or processing instruction is not closed, or when "]]>"
or "<" stops a text run or attribute value. A long run in any of these
used the same fuel as a short one.
This commit is contained in:
dotCooCoo
2026-11-11 19:35:39 -08:00
parent 6b8be09699
commit 781357921e
3 changed files with 180 additions and 22 deletions
+87 -21
View File
@@ -175,6 +175,52 @@ fn is_attribute_run(units: &[u16]) -> bool {
true
}
/// Reads one pseudo-attribute of an XML declaration at `at`: white space, `name`, `=` with
/// optional white space around it, and a quoted value. Returns the offset after the closing
/// quote and the value.
fn decl_attribute<'a>(body: &'a [u16], at: usize, name: &str) -> Option<(usize, &'a [u16])> {
let start = skip_xml_space(body, at);
let name = parser_units(name);
if start == at || !body.get(start..)?.starts_with(&name) {
return None;
}
let eq = skip_xml_space(body, start + name.len());
if body.get(eq) != Some(&EQ) {
return None;
}
let open = skip_xml_space(body, eq + 1);
let quote = body.get(open).copied().filter(|&q| q == DQUOTE || q == SQUOTE)?;
let len = body.get(open + 1..)?.iter().position(|&c| c == quote)?;
Some((open + len + 2, body.get(open + 1..open + 1 + len)?))
}
/// Whether `body`, the text between `<?xml` and `?>`, is an XML declaration's version, optional
/// encoding, optional standalone flag and optional trailing white space (XML 1.0 productions 23
/// to 26, 32, 80 and 81).
fn is_xml_decl_body(body: &[u16]) -> bool {
let is_digit = |c: &u16| (u16::from(b'0')..=u16::from(b'9')).contains(c);
let is_letter = |c: &u16| (u16::from(b'A')..=u16::from(b'Z')).contains(c) || (u16::from(b'a')..=u16::from(b'z')).contains(c);
let Some((mut at, version)) = decl_attribute(body, 0, "version") else { return false };
let digits = version.strip_prefix(parser_units("1.").as_slice()).unwrap_or_default();
if digits.is_empty() || !digits.iter().all(is_digit) {
return false;
}
if let Some((next, encoding)) = decl_attribute(body, at, "encoding") {
let Some((first, rest)) = encoding.split_first() else { return false };
if !is_letter(first) || !rest.iter().all(|c| is_letter(c) || is_digit(c) || matches!(*c, 0x2D | 0x2E | 0x5F)) {
return false;
}
at = next;
}
if let Some((next, standalone)) = decl_attribute(body, at, "standalone") {
if standalone != parser_units("yes").as_slice() && standalone != parser_units("no").as_slice() {
return false;
}
at = next;
}
skip_xml_space(body, at) == body.len()
}
/// 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 };
@@ -2305,8 +2351,30 @@ impl Interp {
src.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| from + i)
}
/// Takes fuel for `units` code units that the parser scanned, on top of the fuel of the step
/// that scanned them, so a short run costs nothing extra.
fn xml_scan_fuel(&mut self, units: usize) -> JsResult<()> {
self.xml_take_fuel(units / UNITS_PER_FUEL)
}
/// The offset after the white space at `src[pos]`, taking fuel for its length.
fn skip_space(&mut self, src: &[u16], pos: usize) -> JsResult<usize> {
let end = skip_xml_space(src, pos);
self.xml_scan_fuel(end - pos)?;
Ok(end)
}
/// The offset after the XML name at `src[pos]`, taking fuel for the units it scanned.
fn read_name(&mut self, src: &[u16], pos: usize) -> JsResult<usize> {
scan_name(src, pos).ok_or_else(|| self.syntax("invalid XML name"))
if let Some(end) = scan_name(src, pos) {
self.xml_scan_fuel(end - pos)?;
return Ok(end);
}
if src.get(pos).copied().is_some_and(is_name_start) {
let scanned = src.get(pos..).unwrap_or_default().iter().skip(1).take_while(|&&c| is_name_part(c)).count();
self.xml_scan_fuel(scanned + 1)?;
}
Err(self.syntax("invalid XML name"))
}
fn check_units(&mut self, src: &[u16], from: usize, to: usize) -> JsResult<()> {
@@ -2390,9 +2458,7 @@ impl Interp {
let mut raw_attrs: Vec<(usize, usize, Vec<u16>)> = Vec::new();
let closes = loop {
let before = at;
while src.get(at).copied().is_some_and(is_xml_space) {
at += 1;
}
at = self.skip_space(src, at)?;
match src.get(at).copied() {
None => return Err(self.syntax("unterminated XML tag")),
Some(SLASH) if src.get(at + 1) == Some(&GT) => {
@@ -2407,26 +2473,22 @@ impl Interp {
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;
}
let p = self.skip_space(src, end)?;
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 p = self.skip_space(src, 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 {
self.xml_text_fuel(src.len().saturating_sub(value_start))?;
return Err(self.syntax("unterminated attribute value"));
};
if src.get(value_start..value_end).is_some_and(|v| v.contains(&LT)) {
self.xml_text_fuel(value_end - value_start)?;
return Err(self.syntax("'<' cannot appear in an attribute value"));
}
let mut value = Vec::new();
@@ -2528,10 +2590,7 @@ impl Interp {
fn parse_close_tag(&mut self, src: &[u16], pos: usize, open: &mut Vec<ObjId>, names: &mut Vec<(usize, usize)>) -> JsResult<usize> {
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;
}
let at = self.skip_space(src, name_end)?;
if src.get(at) != Some(&GT) {
return Err(self.syntax("expected '>' at the end of a closing tag"));
}
@@ -2552,14 +2611,17 @@ impl Interp {
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;
}
let offset_start = skip_xml_space(src, 0);
if src.get(offset_start..).is_some_and(|r| r.starts_with(&parser_units("<?xml")) && r.get(5).copied().is_some_and(is_xml_space)) {
let Some(end) = Self::find_from(src, offset_start, &parser_units("?>")) else {
self.xml_text_fuel(src.len())?;
return Err(self.syntax("unterminated XML declaration"));
};
self.xml_text_fuel(end + 2)?;
self.check_units(src, offset_start, end)?;
if !is_xml_decl_body(src.get(offset_start + 5..end).unwrap_or_default()) {
return Err(self.syntax("invalid XML declaration"));
}
src = src.get(end + 2..).unwrap_or_default();
}
let mut pos = 0;
@@ -2573,6 +2635,7 @@ impl Interp {
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)) {
self.xml_text_fuel(end - pos)?;
return Err(self.syntax("']]>' cannot appear in XML text"));
}
self.decode_into(src, pos, end, &mut text, false)?;
@@ -2582,6 +2645,7 @@ impl Interp {
let rest = src.get(pos..).unwrap_or_default();
if rest.starts_with(&parser_units("<![CDATA[")) {
let Some(end) = Self::find_from(src, pos + 9, &parser_units("]]>")) else {
self.xml_text_fuel(src.len() - pos)?;
return Err(self.syntax("unterminated CDATA section"));
};
self.xml_text_fuel(end - pos)?;
@@ -2593,12 +2657,13 @@ impl Interp {
self.flush_text(&mut text, settings.ignore_whitespace, &open, &mut roots)?;
if rest.starts_with(&parser_units("<!--")) {
let Some(end) = Self::find_from(src, pos + 4, &parser_units("--")) else {
self.xml_text_fuel(src.len() - pos)?;
return Err(self.syntax("unterminated XML comment"));
};
self.xml_text_fuel(end - pos)?;
if src.get(end + 2) != Some(&GT) {
return Err(self.syntax("'--' cannot appear in an XML comment"));
}
self.xml_text_fuel(end - pos)?;
self.check_units(src, pos + 4, end)?;
if !settings.ignore_comments {
let value = Str::from_units(src.get(pos + 4..end).unwrap_or_default().to_vec());
@@ -2623,6 +2688,7 @@ impl Interp {
return Err(self.syntax("'xml' cannot name a processing instruction"));
}
let Some(end) = Self::find_from(src, name_end, &parser_units("?>")) else {
self.xml_text_fuel(src.len() - pos)?;
return Err(self.syntax("unterminated processing instruction"));
};
self.xml_text_fuel(end - pos)?;
+92
View File
@@ -561,6 +561,58 @@ fn bad_xml_text_is_a_catchable_syntax_error() {
assert_eq!(thrown_name("new XML('<a b=\"1\" b=\"2\"/>')"), "TypeError");
}
#[test]
fn an_xml_declaration_is_checked_against_its_grammar() {
for s in [
"<?xml version=\"1.0\"?><a/>",
"<?xml version='1.1'?><a/>",
"<?xml version = \"1.0\" encoding=\"UTF-8\"?><a/>",
"<?xml version=\"1.0\" encoding='ISO-8859-1' standalone=\"yes\" ?><a/>",
"<?xml\tversion=\"1.10\"\nstandalone='no'?>\n<a/>",
"<?xml version=\"1.0\" encoding=\"x-Mac_1.2\"?><a/>",
" <?xml version=\"1.0\"?><a/>",
] {
truth(&format!("new XML({s:?}).localName() === 'a'"));
truth(&format!("var l = new XMLList({s:?}); l.length() === 1 && l[0].localName() === 'a'"));
}
for s in [
"<?xml ?><a/>",
"<?xml encoding=\"UTF-8\"?><a/>",
"<?xml standalone=\"yes\"?><a/>",
"<?xml version=\"2.0\"?><a/>",
"<?xml version=\"1.\"?><a/>",
"<?xml version=\"1.x\"?><a/>",
"<?xml version=\" 1.0\"?><a/>",
"<?xml version=\"1.0'?><a/>",
"<?xml version=1.0?><a/>",
"<?xml version\"1.0\"?><a/>",
"<?xml Version=\"1.0\"?><a/>",
"<?xml version=\"1.0\"encoding=\"UTF-8\"?><a/>",
"<?xml version=\"1.0\" encoding=\"8bit\"?><a/>",
"<?xml version=\"1.0\" encoding=\"UTF 8\"?><a/>",
"<?xml version=\"1.0\" encoding=\"\"?><a/>",
"<?xml version=\"1.0\" standalone=\"maybe\"?><a/>",
"<?xml version=\"1.0\" standalone=\"YES\"?><a/>",
"<?xml version=\"1.0\" standalone=\"yes\" encoding=\"UTF-8\"?><a/>",
"<?xml version=\"1.0\" version=\"1.0\"?><a/>",
"<?xml version=\"1.0\" encoding=\"a\" encoding=\"b\"?><a/>",
"<?xml version=\"1.0\" other=\"x\"?><a/>",
"<?xml version=\"1.0\" ? ><a/>",
] {
assert_eq!(thrown_name(&format!("new XML({s:?})")), "SyntaxError", "{s}");
assert_eq!(thrown_name(&format!("new XMLList({s:?})")), "SyntaxError", "{s}");
}
let message = |s: &str| string(&format!("try {{ new XML({s}); 'no error' }} catch (e) {{ e.message }}"));
assert_eq!(message("'<?xml version=\"1.0\" junk?><a/>'"), "invalid XML declaration");
assert_eq!(message("'<?xml version=\"1.0\"'"), "unterminated XML declaration");
assert_eq!(message("'<?xml version=\"1.0\"' + String.fromCharCode(1) + '?><a/>'"), "invalid character in XML text");
assert_eq!(message("'<?xml version=\"1.0\" encoding=\"' + String.fromCharCode(0xD800) + '\"?><a/>'"), "invalid character in XML text");
assert_eq!(message("'<a/><?xml version=\"1.0\"?>'"), "'xml' cannot name a processing instruction");
truth(
"XML.ignoreProcessingInstructions = false; var x = new XML('<a><?xml-stylesheet href=\"s\"?></a>'); x.children()[0].nodeKind() === 'processing-instruction'",
);
}
#[test]
fn unpaired_surrogates_are_rejected_in_xml_text() {
let lone = "String.fromCharCode(0xD800)";
@@ -750,6 +802,46 @@ fn namespace_lookups_take_fuel_for_each_ancestor_they_climb() {
}
}
#[test]
fn xml_text_takes_fuel_for_every_run_it_scans() {
// `pad` is white space and `name` is name characters, each one unit long in the short run and
// 16,384 units long in the long run.
let setup = |doublings: u32, text: &str| {
format!("var pad = ' ', name = 'n'; for (var i = 0; i < {doublings}; i++) {{ pad += pad; name += name; }} var s = {text};")
};
let action = "try { new XML(s) } catch (e) {}";
let mut flat = Vec::new();
for text in [
"'<?xml version=\"1.0\"' + pad + '?><a/>'",
"pad + '<?xml version=\"1.0\"?><a/>'",
"'<?xml version=\"1.0\"' + pad",
"'<?xml version=\"1.0\" junk' + pad + '?><a/>'",
"'<a' + name + '/>'",
"'<a' + name + String.fromCharCode(0xD800) + '/>'",
"'<a' + pad + '/>'",
"'<a' + pad + 'b=\"1\"/>'",
"'<a b' + pad + '=\"1\"/>'",
"'<a b=' + pad + '\"1\"/>'",
"'<a' + pad",
"'<a b=\"' + pad",
"'<a b=\"' + pad + '<\"/>'",
"'<a></a' + name + '>'",
"'<a></a' + pad + '>'",
"'<a>' + pad + ']]></a>'",
"'<a><![CDATA[' + pad",
"'<a><!--' + pad",
"'<a><!--' + pad + '-- x'",
"'<a><?p' + pad",
] {
let short = fuel_of(&setup(0, text), action);
let long = fuel_of(&setup(14, text), action);
if long < short + 900 {
flat.push(format!("{text}: {short} short, {long} long"));
}
}
assert!(flat.is_empty(), "{flat:#?}");
}
#[test]
fn limits_cannot_be_caught_by_the_script() {
assert_eq!(kind_of(TIGHT, "try { for (;;) (<a/>).toXMLString(); } catch (e) { 1 }"), RunErrorKind::Limit(LimitKind::Fuel));
+1 -1
View File
@@ -763,7 +763,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). `<!DOCTYPE>` 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`.
- `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). `<!DOCTYPE>` in an XML string gives a `SyntaxError`. An XML declaration at the start of an XML string, after optional white space, is checked against XML 1.0's grammar (https://www.w3.org/TR/xml/#NT-XMLDecl): a version of `1.` and digits, then an optional encoding name and an optional standalone `yes` or `no`, in that order; any other declaration, or one with a character XML does not allow, gives a `SyntaxError`. The parser takes fuel for the length of every name, white space run and declaration it scans, and for the text it searched when a quote, CDATA section, comment or processing instruction is not closed. 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.