Merge pull request #6772 from dotCooCoo/fix/e4x-xml-declaration-check

Scripts: E4X checks the XML declaration and takes fuel for every run the parser scans
This commit is contained in:
BFlat
2026-11-12 12:40:21 +07:00
committed by GitHub
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
@@ -767,7 +767,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.