From e2eb364f836f85d672fb89172483a6a4b97c08f7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:35:25 -0800 Subject: [PATCH 01/24] Scripts: operators, JSON.stringify and join count a flattened string against the heap limit Flattening a concatenated string copies its units into a new buffer. The string built-ins reserve that copy with flat_charged, but several other paths flattened without it, so a script could build copies the heap limit never saw: - the relational operators (`<`, `>`, `<=`, `>=`) on two strings, `==` between a string and a number, unary `+` and every other ToNumber of a string, and reading a character by index (`s[0]`) - a property name made from an object whose toString returns a long string, and a long string used as a property name, which now reserves its copy before it is made instead of after - direct `eval` of a concatenated source - `JSON.stringify` of a string value and its `space` argument - `Array.prototype.join`'s separator and each string element it writes Property names and eval source reserve the copy's heap bytes without taking fuel. --- crates/js/src/builtins/array.rs | 6 +++-- crates/js/src/builtins/json.rs | 8 +++++-- crates/js/src/interp.rs | 40 +++++++++++++++++++++------------ crates/js/src/text_tests.rs | 17 ++++++++++++++ 4 files changed, 53 insertions(+), 18 deletions(-) diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs index 2f81bf7666..263656437c 100644 --- a/crates/js/src/builtins/array.rs +++ b/crates/js/src/builtins/array.rs @@ -13,6 +13,7 @@ use super::{ }; use crate::heap::{Class, NativeKind, ObjId}; use crate::interp::{Abrupt, Interp, JsResult, strict_eq}; +use crate::string_builtins::flat_charged; use crate::value::{Str, Value, to_uint32}; pub(super) fn install(interp: &mut Interp) -> JsResult<()> { @@ -278,7 +279,7 @@ fn join_with(interp: &mut Interp, o: ObjId, separator: &Value, item: fn(&mut Int if len == 0 { return Ok(Value::str("")); } - let flat = sep.flat(); + let flat = flat_charged(interp, &sep)?; let sep = &flat[..]; // A result that cannot fit is refused before any element is read. let least = (sep.len() as u64).saturating_mul(u64::from(len - 1)); @@ -301,7 +302,8 @@ fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn( let Some(s) = item(interp, v)? else { continue }; out.push_repeated(interp, sep, k.saturating_sub(seps))?; seps = k; - out.push_units(interp, &s.flat())?; + let units = flat_charged(interp, &s)?; + out.push_units(interp, &units)?; } out.push_repeated(interp, sep, u64::from(len - 1).saturating_sub(seps))?; Ok(out.finish(interp)) diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index 4375303fe0..991495ba9d 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -13,6 +13,7 @@ use std::collections::HashSet; use super::{Builder, ELEMENT_BYTES, arg, data, key_of, length_of, method, new_array, new_string, to_integer}; use crate::heap::{Class, Key, OBJECT_BYTES, OPEN, ObjId, Object, Prop, key, string_bytes}; use crate::interp::{Desc, ErrorType, Hint, Interp, JsResult}; +use crate::string_builtins::flat_charged; use crate::value::{Str, Value, number_to_str}; pub(super) fn install(interp: &mut Interp) -> JsResult<()> { @@ -473,7 +474,7 @@ fn settings(interp: &mut Interp, replacer: &Value, space: &Value) -> JsResult s.gap = t.flat().iter().take(10).copied().collect(), + Value::String(t) => s.gap = flat_charged(interp, &t)?.iter().take(10).copied().collect(), _ => {} } Ok(s) @@ -632,7 +633,10 @@ fn write_primitive(interp: &mut Interp, out: &mut Builder, v: &Value) -> JsResul Value::Bool(b) => out.push_str(interp, if *b { "true" } else { "false" }), Value::Number(n) if n.is_finite() => out.push_units(interp, &number_to_str(*n).flat()), Value::Number(_) => out.push_str(interp, "null"), - Value::String(s) => write_quoted(interp, out, &s.flat()), + Value::String(s) => { + let units = flat_charged(interp, s)?; + write_quoted(interp, out, &units) + } Value::Undefined | Value::Object(_) => Ok(()), } } diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index afb6cde635..afca1a1024 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -26,6 +26,7 @@ use crate::heap::{ ArgMap, Binding, CONFIGURABLE, Class, ENUMERABLE, ENV_BYTES, Env, EnvId, ForIn, Func, HIDDEN, Heap, Key, NativeKind, OBJECT_BYTES, OPEN, ObjId, Object, PROP_BYTES, Prop, PropMap, ROPE_BYTES, Roots, WRITABLE, code_bytes, key, string_bytes, }; +use crate::string_builtins::flat_charged; use crate::value::{Str, Value, array_index, index_key, number_to_str, string_to_number, to_int32, to_uint32}; use crate::{LimitKind, Limits, Options}; @@ -1290,7 +1291,7 @@ impl Interp { if let Some(i) = array_index(k) && (i as usize) < s.len() { - let unit = s.flat().get(i as usize).copied().unwrap_or(0); + let unit = flat_charged(self, s)?.get(i as usize).copied().unwrap_or(0); return Ok(Value::String(Str::from_units(vec![unit]))); } } @@ -2397,7 +2398,7 @@ impl Interp { }; let strict = self.strict(); self.check_source_units(s.len()).map_err(|e| self.compile_error_to_abrupt(e))?; - let units = s.flat(); + let units = self.flat_reserved(s)?; let code = self.compile_source(&units, strict, CodeKind::Eval).map_err(|e| self.compile_error_to_abrupt(e))?; self.charge_code(&code, declaration_bytes(&code).saturating_add(ENV_BYTES))?; let frame = self.frame()?; @@ -2562,7 +2563,7 @@ impl Interp { Value::Null => 0.0, Value::Bool(b) => f64::from(u8::from(*b)), Value::Number(n) => *n, - Value::String(s) => string_to_number(&s.flat()), + Value::String(s) => string_to_number(&flat_charged(self, s)?), Value::Object(_) => { let p = self.to_primitive(v, Hint::Number)?; return self.to_number(&p); @@ -2596,19 +2597,27 @@ impl Interp { #[allow(clippy::wrong_self_convention)] pub(crate) fn to_key(&mut self, v: &Value) -> JsResult { match v { - Value::String(s) => { - let rope = s.is_rope(); - let flat = s.flat(); - if rope { - self.heap.bytes += string_bytes(flat.len()); - } - Ok(flat) - } + Value::String(s) => self.flat_reserved(s), Value::Number(n) if *n >= 0.0 && *n < 4_294_967_295.0 && n.fract() == 0.0 => Ok(index_key(*n as u32)), - _ => Ok(self.to_str(v)?.flat()), + _ => { + let s = self.to_str(v)?; + self.flat_reserved(&s) + } } } + /// The code units of `s`. Flattening a rope makes a copy of its units, so for a rope that + /// copy's bytes are reserved and added to the heap first, without taking fuel. Everything + /// the caller still needs must be reachable from the roots. + fn flat_reserved(&mut self, s: &Str) -> JsResult { + if s.is_rope() { + let bytes = string_bytes(s.len()); + self.reserve_heap(bytes)?; + self.heap.bytes = self.heap.bytes.saturating_add(bytes); + } + Ok(s.flat()) + } + /// ToObject (section 9.9). #[allow(clippy::wrong_self_convention)] pub(crate) fn to_object(&mut self, v: &Value) -> JsResult { @@ -2731,7 +2740,8 @@ impl Interp { (self.to_primitive(x, Hint::Number)?, py) }; if let (Value::String(a), Value::String(b)) = (&px, &py) { - return Ok(Some(*a.flat() < *b.flat())); + let (a, b) = (flat_charged(self, a)?, flat_charged(self, b)?); + return Ok(Some(*a < *b)); } let nx = self.to_number(&px)?; let ny = self.to_number(&py)?; @@ -2756,7 +2766,9 @@ impl Interp { | (Value::String(_), Value::String(_)) | (Value::Bool(_), Value::Bool(_)) | (Value::Object(_), Value::Object(_)) => return Ok(strict_eq(&x, &y)), - (Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => return Ok(*n == string_to_number(&s.flat())), + (Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => { + return Ok(*n == string_to_number(&flat_charged(self, s)?)); + } (Value::Bool(v), _) => x = Value::Number(f64::from(u8::from(*v))), (_, Value::Bool(v)) => y = Value::Number(f64::from(u8::from(*v))), (Value::Number(_) | Value::String(_), Value::Object(_)) => y = self.to_primitive(&y, Hint::Default)?, diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index c873bdaf59..3134e318df 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -309,6 +309,23 @@ fn flattening_a_rope_counts_against_the_heap() { } } +#[test] +fn operators_and_builtins_count_a_flattened_rope_against_the_heap() { + // Three ropes of about 524,288 units, each 1 MiB once flat, with a 3 MiB heap. + let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z';"; + let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() }; + let not_stopped: Vec<_> = + ["X < 'a'", "X == 1", "+X", "X[0]", "({})[{ toString: function () { return X; } }]", "JSON.stringify(0, null, X)", "[1].join(X)"] + .into_iter() + .filter(|read| { + let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); + let result = Realm::with_options(Options::default(), limits.clone()).run(&src); + !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) + }) + .collect(); + assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); +} + #[test] fn compiling_a_pattern_counts_against_the_heap() { let src = "var s = '[\\\\s\\\\S]a*'; for (var i = 0; i < 14; i++) s = s + s; new RegExp(s).source.length"; From cd262fa6e805f5eab43ec8a955524c6a99ce7611 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:42:34 -0800 Subject: [PATCH 02/24] MCP: save, export and package replies leave out paths outside a confined client's read roots A client confined with --automation-read-root could learn paths it was not given and may not read from the replies of several commands. Each one now names such a path only when no read roots are in force or the path is inside them: - document.save and file.save, file.saveAs, file.saveCopy and file.saveAsTemplate (app.save and engine.execute) without a path: the document's own file, the files of separate artboards and linked files are left out or named by file name, and the folder a save suggests is left out. - document.exportForScreens with the folder of an exportPreset: the ZIP path is left out and files are relative to that folder. - document.exportSettings, document.exportPresets.list and document.exportPresets.export: a kept folder outside the read roots is left out of the reply and of the presets file the export writes. - file.package: warnings, missingLinks and the report name a file outside the read roots by its file name. A link's stored path, read from the document's folder when it is relative, decides, also when its file was found under another path. - document.info: an image linked to a file outside the read roots has link null, and its Linked Images row names the file alone. docs/mcp.md and the params text describe the rule. --- crates/engine/src/cmd/docinfo.rs | 21 +++- crates/engine/src/cmd/fileio.rs | 10 +- crates/engine/src/cmd/fileio/access.rs | 23 +++++ crates/engine/src/cmd/fileio/commands.rs | 8 +- crates/engine/src/cmd/fileio/save.rs | 40 +++++++- crates/engine/src/cmd/fileio/screens.rs | 49 +++++++++- crates/engine/src/cmd/fileio/tests.rs | 13 +++ crates/engine/src/cmd/fileio/tests_screens.rs | 77 +++++++++++++++ crates/engine/src/cmd/package.rs | 39 +++++--- crates/engine/src/tests/package.rs | 97 +++++++++++++++++++ crates/engine/src/tests/save.rs | 44 +++++++++ crates/ui-egui/src/menus/run.rs | 18 +++- crates/ui-egui/src/tests/automation.rs | 21 ++++ docs/mcp.md | 11 +++ docs/target-app-parity.md | 1 + 15 files changed, 435 insertions(+), 37 deletions(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 7499e14943..ff1fbe3591 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -1,7 +1,7 @@ //! Document Info panel data, its categories and the text report (Document Info › Save…, File → //! Package), and Object → Make Pixel Perfect. -use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use serde_json::{Value, json}; use vectorcraft_color::Paint; @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", has_doc, info ), @@ -358,6 +358,16 @@ fn info(s: &mut Session, p: &Value) -> Result { let mut fonts = BTreeSet::new(); let mut images: BTreeMap = BTreeMap::new(); let mut image_rows: Vec = vec![]; + // Whether the request being served may read each linked file (a relative path read from the + // document's folder), checked once per file. + let mut readable: HashMap = HashMap::new(); + let link_doc = super::links::LinkBase::of(st).doc; + let mut may_read = |stored: &str| { + *readable.entry(stored.to_string()).or_insert_with(|| { + let resolved = super::fileio::document_path(stored, link_doc).ok(); + crate::file_access::check_read(resolved.as_deref().unwrap_or(stored)).is_ok() + }) + }; let mut patterns = BTreeSet::new(); let mut symbols = BTreeSet::new(); let mut spot = BTreeSet::new(); @@ -411,12 +421,15 @@ fn info(s: &mut Session, p: &Value) -> Result { Some("textObjects") } NodeKind::Image(im) => { + // A linked file outside a confined request's read roots: `link` is null, and the row + // names the file alone. + let link = im.link.as_ref().map(|l| (l, may_read(&l.path))); images.insert( im.key.clone(), - json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link.as_ref().map(|l| &l.path) }), + json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": link.and_then(|(l, ok)| ok.then_some(&l.path)) }), ); let name = im.link.as_ref().map_or_else(|| n.display_name(), |l| l.name().to_string()); - image_rows.push((name, im.width, im.height, im.link.as_ref().map(|l| l.path.clone()))); + image_rows.push((name, im.width, im.height, link.map(|(l, ok)| if ok { l.path.clone() } else { l.name().to_string() }))); Some("images") } NodeKind::SymbolInstance { symbol, .. } => { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index eaa417f4ef..7eac419da9 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -90,9 +90,9 @@ pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option, - remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, - templates_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, leave_out_unreadable_save_paths, + reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, + template_filters, templates_dialog_folder, templates_folder, }; pub(crate) use screens::{ PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS, @@ -124,7 +124,9 @@ pub(crate) use access::list_dir; pub use access::write_file_with; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::{Placed, copy_new, file_metadata}; -pub(crate) use access::{absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, write_file, write_new_file}; +pub(crate) use access::{ + absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, shown_link, shown_path, write_file, write_new_file, +}; #[cfg(all(test, windows))] pub(crate) use access::{document_path_on, document_path_with}; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 4dce2023bb..7b20326106 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -42,6 +42,29 @@ pub(crate) fn touched(f: impl FnOnce() -> T) -> (T, Vec) { (out, seen) } +/// May a reply to the request being served name `path`, a file or folder the client did not give? +/// Always without roots in force; with them, only inside the read roots ([`crate::file_access::check_read`]). +pub(crate) fn shown(path: &str) -> bool { + crate::file_access::check_read(path).is_ok() +} + +/// `path` as a reply to the request being served names it: whole when [`shown`], else its file name, +/// after the last `/` or `\` whichever platform wrote it (empty for a path that ends in no name, such +/// as `..` or a folder ending in a separator). +pub(crate) fn shown_path(path: &str) -> std::borrow::Cow<'_, str> { + shown_link(path, None) +} + +/// A link's `stored` path as a reply to the request being served names it: whole when the file it +/// names (`resolved`, else `stored` itself) is [`shown`], else its file name as [`shown_path`] gives it. +pub(crate) fn shown_link<'a>(stored: &'a str, resolved: Option<&str>) -> std::borrow::Cow<'a, str> { + if shown(resolved.unwrap_or(stored)) { + return std::borrow::Cow::Borrowed(stored); + } + let name = stored.rsplit(vectorcraft_doc::links::path_separators(stored)).next().unwrap_or_default(); + std::borrow::Cow::Borrowed(if matches!(name, "." | "..") { "" } else { name }) +} + /// `path` checked for writing against the automation roots in force. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn may_write(path: &str) -> Result<()> { diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 13df0d5eb1..83b6359786 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,