mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 13:33:19 +00:00
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:
+87
-21
@@ -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(>) => {
|
||||
@@ -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(<)) {
|
||||
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(>) {
|
||||
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(>) {
|
||||
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)?;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user