Merge #465 (673af7ec, with #462 at 712bb766 and main at 530a8cc8) into #2151

# Conflicts:
#	docs/macros.md
This commit is contained in:
prxnvt
2026-10-23 22:08:05 -04:00
190 changed files with 3159 additions and 478 deletions
+1
View File
@@ -273,6 +273,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("automate.newScript", "Automate", "Scripting Tools", "New Script"),
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"),
("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"),
// Table Design
("table.rename", "Table Design", "Properties", "Table Name"),
("table.resize", "Table Design", "Properties", "Resize Table"),
+73 -3
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Values",
[],
None,
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas)}",
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}",
has_doc,
range_set_values
),
@@ -299,6 +299,15 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let start = target_range(s, p)?.start;
let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) };
// A string is run through typed input by default, which reads it with en-US rules: a caller
// sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence
// (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas
// are unaffected either way.
let parse = match p.get("parse") {
None | Some(Json::Null) => true,
Some(Json::Bool(b)) => *b,
Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))),
};
if rows.len() > MAX_ROWS as usize {
return Err(bad("range.setValues", "too many rows"));
}
@@ -321,6 +330,17 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
Json::Bool(b) => {
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
}
// Text as given: no date or number reading, and a leading `'` is part of the
// text rather than the marker that makes it text. A formula is still a
// formula, which is what the ticket asks for and what `=` means in a cell.
Json::String(text) if !parse && !text.starts_with('=') => {
let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
// Line breaks still turn on Wrap Text, as typed input does.
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
}
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
}
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
};
let sh = cx.sheet_mut(sheet)?;
@@ -1242,9 +1262,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
})?;
// Formulas and validation from an untrusted file leave the destination untrusted; plain values,
// formats (cell styles and merged areas, no conditional formats), column widths and notes
// carry nothing active (a linked paste of values still writes formulas).
// carry nothing active. A linked paste writes a formula into every cell whatever `what` says,
// except for column widths and notes, which write no cells at all.
let passive =
matches!(what, What::Formats | What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Values | What::ValuesAndNumberFormats));
matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats));
if !same_doc && !passive {
let d = s.doc_mut()?;
d.trust = d.trust.receive(clip.trust);
@@ -1778,6 +1799,55 @@ mod tests {
assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err());
}
/// Every string in `values` went through typed input, read with en-US rules, in silence: a
/// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and
/// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is.
#[test]
fn set_values_can_store_strings_as_the_text_they_are() {
let mut s = Session::new();
s.new_workbook();
let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"];
let row: Vec<Json> = strings.iter().map(|v| json!(v)).collect();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
// The default is unchanged: the dates swap, the thousands separator is a decimal point.
s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap();
assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy");
assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal");
assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage");
// With `parse: false` each string is the text it was given, and a formula is still a
// formula.
s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap();
for (i, want) in strings.iter().enumerate() {
let cell = format!("{}3", (b'A' + i as u8) as char);
let got = get(&mut s, &cell);
if *want == "=1+1" {
assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}");
assert_eq!(got["formula"], "=1+1", "{got}");
} else {
assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}");
assert!(got["formula"].is_null(), "{cell} is not a formula: {got}");
}
}
// Numbers and booleans are untouched either way.
s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap();
assert_eq!(get(&mut s, "A5")["value"], json!(12.5));
assert_eq!(get(&mut s, "B5")["value"], json!(true));
assert_eq!(get(&mut s, "C5")["text"], "");
// `parse` is a flag, and anything else is a usage error rather than a quiet default.
s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap();
assert_eq!(get(&mut s, "A7")["value"], json!(2.5));
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err());
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err());
// A line break still turns on Wrap Text.
s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap();
assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo"));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap);
}
#[test]
fn legacy_array_entry() {
let mut s = Session::new();
+90 -7
View File
@@ -193,7 +193,24 @@ pub fn specs() -> Vec<CommandSpec> {
new_script
),
cmd!(query "automate.allScripts", "All Scripts", ["Automate", "Scripting Tools"], None, "{} → saved scripts", has_doc, all_scripts),
cmd!("automate.runScript", "Run Script", [], None, "{name}", has_doc, run_script),
cmd!(
"automate.runScript",
"Run Script",
[],
None,
"{name, trusted?: bool (runs a script from an untrusted workbook this once)} — refuses scripts in a workbook opened from a file until it is trusted, and steps that reach outside the workbook (file.*, link.*, any `path` param)",
has_doc,
run_script
),
cmd!(
noundo "automate.trustScripts",
"Trust Scripts",
["Automate", "Scripting Tools"],
None,
"{trusted?: bool (default true)} → {trusted}: lets this workbook's scripts run for the rest of the session",
has_doc,
trust_scripts
),
cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script),
// Cell controls, themes, borders, styles
cmd!(
@@ -785,6 +802,7 @@ fn new_window(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?.clone();
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()));
copy.selection = d.selection.clone();
copy.scripts_trusted = d.scripts_trusted;
copy.trust = d.trust;
let i = s.add_document(copy);
Ok(json!({"index": i}))
@@ -977,10 +995,13 @@ fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
}
fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
let wb = &s.doc()?.wb;
let d = s.doc()?;
let (wb, trusted) = (&d.wb, d.scripts_trusted);
let all = stash_get(wb, SCRIPTS_NAME);
let mut list: Vec<Json> =
all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default();
let mut list: Vec<Json> = all
.as_object()
.map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect())
.unwrap_or_default();
// A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on
// save but never executed here, so it has no step count.
if let Some(bytes) = &wb.vba_project {
@@ -989,17 +1010,79 @@ fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
Ok(Json::Array(list))
}
/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted).
pub(crate) fn has_scripts(wb: &Workbook) -> bool {
stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty())
}
/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the
/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data,
/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run
/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by
/// command id and parameter name, so it is only the up-front refusal: the steps also run inside
/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and
/// [`run_script`] drops any `OpenUrl` request a step raises.
fn denied_script_step(id: &str, params: &Json) -> Option<String> {
if id.starts_with("file.") || id.starts_with("link.") {
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
}
if params.get("path").is_some() {
return Some(format!("scripts can't pass a file `path` to `{id}`"));
}
None
}
fn trust_scripts(s: &mut Session, p: &Json) -> Result<Json> {
// Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent.
let trusted = match p.get("trusted") {
None => true,
Some(Json::Bool(b)) => *b,
Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")),
};
s.doc_mut()?.scripts_trusted = trusted;
Ok(json!({"trusted": trusted}))
}
/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is
/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that
/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs.
fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?;
let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME);
let d = s.doc()?;
let all = stash_get(&d.wb, SCRIPTS_NAME);
let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?;
if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) {
return Err(bad(
"automate.runScript",
"scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)",
));
}
let steps: Vec<Json> = steps.into_iter().take(100_000).collect();
for st in &steps {
let id = st["command"].as_str().unwrap_or("");
if let Some(why) = denied_script_step(id, &st["params"]) {
return Err(bad("automate.runScript", why));
}
}
let _sandbox = crate::io::ScriptSandbox::enter();
let mut n = 0;
for st in steps.iter().take(100_000) {
for st in &steps {
let id = st["command"].as_str().unwrap_or("");
if id.starts_with("automate.") {
continue;
}
s.execute(id, st["params"].clone())?;
let before = s.ui_requests.len();
let ran = s.execute(id, st["params"].clone());
// A step that asks the host to open a URL leaves the app: drop the request and stop.
if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) {
let mut i = 0;
s.ui_requests.retain(|r| {
i += 1;
i <= before || !matches!(r, crate::UiRequest::OpenUrl(_))
});
return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)")));
}
ran?;
n += 1;
}
Ok(json!({"ran": n}))
+17 -2
View File
@@ -26,9 +26,9 @@ pub fn specs() -> Vec<CommandSpec> {
"Format as Table",
["Home", "Styles"],
None,
"{range?, style: \"TableStyleMedium2\", header?}",
"{range?, style: \"TableStyleMedium2\", header?} (inside a table: restyles it)",
has_doc,
insert_table
format_as_table
),
cmd!("table.totalRow", "Total Row", ["Table", "Table Style Options"], None, "{table?, on?: bool}", has_doc, |s, p| table_flag(
s, p, "totals"
@@ -351,6 +351,21 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Format as Table inside a table restyles that table, as in Excel; elsewhere it makes a new one.
fn format_as_table(s: &mut Session, p: &Json) -> Result<Json> {
// Without a range, the active area decides (as in insert_table), not the first one.
let r = if p.get("range").is_some() { target_range(s, p)? } else { s.doc()?.selection.current() };
let sheet = target_sheet(s, p)?;
let inside = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range.contains_range(&r))).map(|t| (t.name.clone(), t.range));
match inside {
Some((table, range)) => {
table_style(s, &with(p, "table", json!(table)))?;
Ok(json!({"table": table, "range": range.a1()}))
}
None => insert_table(s, p),
}
}
fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
if let Some(n) = str_param(p, "table") {
+56 -19
View File
@@ -8,7 +8,7 @@
//! one name each, `_sc_<key>`; reading accepts that form as well, so those items can move to this
//! format later, and a write of the same key replaces it.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashSet};
use gridcraft_model::{DefinedName, Workbook};
use serde_json::Value as Json;
@@ -59,25 +59,40 @@ pub fn get(wb: &Workbook, key: &str) -> Option<String> {
/// Stores `text` under `key`, replacing what was there (in either format). Empty text removes it.
pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> {
check_key(key)?;
if text.chars().count() > MAX_ITEM_CHARS {
return Err(EngineError::Other(format!("The data is too large to keep in the workbook ({} MB at most).", MAX_ITEM_CHARS / (1024 * 1024))));
}
let formulas = chunk_formulas(text);
// A visible name of the item's shape is someone's own (see `item_of`); a hidden name beside
// it with the same name would be the one an xlsx file drops. Refuse before changing anything.
for i in 0..formulas.len() {
let name = format!("{PREFIX}{key}_{i}");
if wb.names.iter().any(|n| n.scope.is_none() && !n.hidden && n.name.eq_ignore_ascii_case(&name)) {
set_many(wb, &[(key.to_string(), text.to_string())])
}
/// Stores every `(key, text)` of `items` as [`set`] would, with the checks done before anything
/// changes and one pass over the names for all of them, so writing a thousand items into a
/// workbook with many thousands of names stays near-linear.
pub fn set_many(wb: &mut Workbook, items: &[(String, String)]) -> Result<()> {
let mut planned = Vec::new();
let mut keys: HashSet<String> = HashSet::new();
for (key, text) in items {
check_key(key)?;
if text.chars().count() > MAX_ITEM_CHARS {
return Err(EngineError::Other(format!(
"The workbook has a defined name '{name}', which GridCraft needs for its own data. Rename it first."
"The data is too large to keep in the workbook ({} MB at most).",
MAX_ITEM_CHARS / (1024 * 1024)
)));
}
keys.insert(key.to_ascii_lowercase());
for (i, formula) in chunk_formulas(text).into_iter().enumerate() {
planned.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true });
}
}
remove(wb, key);
for (i, formula) in formulas.into_iter().enumerate() {
wb.names.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true });
// A visible name of an item's shape is someone's own (see `item_of`); a hidden name beside
// it with the same name would be the one an xlsx file drops. Refuse before changing anything.
let names: HashSet<String> = planned.iter().map(|n| n.name.to_ascii_lowercase()).collect();
if let Some(n) = wb.names.iter().find(|n| n.scope.is_none() && !n.hidden && names.contains(&n.name.to_ascii_lowercase())) {
return Err(EngineError::Other(format!(
"The workbook has a defined name '{}', which GridCraft needs for its own data. Rename it first.",
n.name
)));
}
// What was there under these keys goes, in either format, then the new chunks come.
wb.names.retain(|n| !item_of(n).is_some_and(|(k, _)| keys.contains(&k.to_ascii_lowercase())));
wb.names.extend(planned);
Ok(())
}
@@ -100,7 +115,8 @@ pub fn keys(wb: &Workbook, prefix: &str) -> Vec<String> {
let mut keys: Vec<String> = wb
.names
.iter()
.filter_map(|n| item_of(n).map(|(k, _)| k.to_ascii_lowercase()))
// A later chunk on its own is no item, as `get` reads it.
.filter_map(|n| item_of(n).filter(|(_, chunk)| chunk.is_none_or(|i| i == 0)).map(|(k, _)| k.to_ascii_lowercase()))
.filter(|k| k.starts_with(&prefix.to_ascii_lowercase()))
.collect();
keys.sort();
@@ -127,8 +143,10 @@ fn item_of(n: &DefinedName) -> Option<(&str, Option<u64>)> {
fn parse_name(name: &str) -> Option<(&str, Option<u64>)> {
if let Some(rest) = strip_prefix_ci(name, PREFIX) {
let (key, k) = rest.rsplit_once('_')?;
let all_digits = !k.is_empty() && k.len() <= 9 && k.bytes().all(|b| b.is_ascii_digit());
if key.is_empty() || !all_digits {
// Only the chunk number as `set` writes it: `_gc_watch_00` is someone else's name, not
// chunk 0 under another spelling, so it is neither read nor in the way of `_gc_watch_0`.
let canonical = !k.is_empty() && k.len() <= 9 && k.bytes().all(|b| b.is_ascii_digit()) && (k == "0" || !k.starts_with('0'));
if key.is_empty() || !canonical {
return None;
}
return k.parse().ok().map(|i| (key, Some(i)));
@@ -289,11 +307,13 @@ mod tests {
wb.names.push(name("_gc_gap_0", "\"a\""));
wb.names.push(name("_gc_gap_2", "\"c\""));
assert_eq!(get(&wb, "gap").as_deref(), Some("a"));
// Only a later chunk: no item, and the legacy name is still read.
// Only a later chunk: no item, not listed, and the legacy name is still read.
wb.names.push(name("_gc_orphan_2", "\"c\""));
assert_eq!(get(&wb, "orphan"), None);
assert_eq!(keys(&wb, "orphan"), Vec::<String>::new());
wb.names.push(name("_sc_orphan", "\"old\""));
assert_eq!(get(&wb, "orphan").as_deref(), Some("old"));
assert_eq!(keys(&wb, "orphan"), ["orphan"]);
// Chunks out of order, a duplicate, junk around the literals, an unterminated literal.
wb.names.push(name("_gc_mix_1", "\"b\"&A1&"));
wb.names.push(name("_gc_mix_0", "SUM(\"a\")"));
@@ -355,6 +375,23 @@ mod tests {
assert_eq!(wb.names, vec![own]);
}
#[test]
fn a_chunk_number_with_leading_zeros_is_another_name() {
// `_gc_watch_00` is not chunk 0 under another spelling: a hidden one is not read, and a
// visible one is not in the way of writing `_gc_watch_0`.
let mut wb = Workbook::new();
wb.names.push(name("_gc_watch_00", "\"theirs\""));
assert_eq!(get(&wb, "watch"), None);
assert!(keys(&wb, "").is_empty());
let own = DefinedName { hidden: false, ..name("_gc_watch_00", "\"mine\"") };
wb.names.push(own.clone());
set(&mut wb, "watch", "ours").unwrap();
assert_eq!(get(&wb, "watch").as_deref(), Some("ours"));
assert_eq!(wb.names.len(), 3, "both other names stay");
remove(&mut wb, "watch");
assert_eq!(wb.names, vec![name("_gc_watch_00", "\"theirs\""), own]);
}
#[test]
fn keys_list_items_by_prefix() {
let mut wb = Workbook::new();
+2
View File
@@ -35,6 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("view.gridlines", "Gridlines", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "gridlines")),
cmd!("view.headings", "Headings", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "headings")),
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")),
cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects),
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
@@ -166,6 +167,7 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
"gridlines" => &mut sh.show_gridlines,
"headings" => &mut sh.show_headings,
"zeros" => &mut sh.show_zeros,
"outlineSymbols" => &mut sh.show_outline_symbols,
"rtl" => &mut sh.right_to_left,
"printGridlines" => &mut sh.print.gridlines,
_ => &mut sh.print.headings,
+62
View File
@@ -565,8 +565,41 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
Ok(out)
}
thread_local! {
/// How many [`ScriptSandbox`]es are alive on this thread.
static SCRIPT_SANDBOX: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
/// While one is alive, [`read_file`] and [`write_file`] on this thread refuse every path: a
/// stored script's steps run inside one (#2178), so no command reaches the file system from a
/// script whatever its parameters are called. Dropping it (also on unwind) lifts the refusal.
pub(crate) struct ScriptSandbox(());
impl ScriptSandbox {
pub(crate) fn enter() -> Self {
let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_add(1)));
ScriptSandbox(())
}
}
impl Drop for ScriptSandbox {
fn drop(&mut self) {
let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_sub(1)));
}
}
/// Refuses file access while a [`ScriptSandbox`] is alive (and, failing closed, while the
/// thread is being torn down).
fn script_sandbox_check(path: &str) -> Result<()> {
if SCRIPT_SANDBOX.try_with(|c| c.get() > 0).unwrap_or(true) {
return Err(EngineError::Other(format!("{path}: scripts can't read or write files")));
}
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file(path: &str) -> Result<Vec<u8>> {
script_sandbox_check(path)?;
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() > 2 * 1024 * 1024 * 1024 {
return Err(EngineError::Other("files larger than 2 GB aren't supported".into()));
@@ -576,11 +609,13 @@ pub fn read_file(path: &str) -> Result<Vec<u8>> {
#[cfg(target_arch = "wasm32")]
pub fn read_file(path: &str) -> Result<Vec<u8>> {
script_sandbox_check(path)?;
Err(EngineError::Other(format!("{path}: no file system in the browser")))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
script_sandbox_check(path)?;
// Write to a temporary file, then rename: a crash never leaves a half-written workbook.
let tmp = format!("{path}.sctmp");
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
@@ -593,6 +628,7 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
#[cfg(target_arch = "wasm32")]
pub fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
script_sandbox_check(path)?;
Err(EngineError::Other(format!("{path}: no file system in the browser")))
}
@@ -911,6 +947,32 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// While a script runs (#2178), file reads and writes are refused whatever command or
/// parameter asked for them; nesting and dropping the sandbox restore access.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn script_sandbox_refuses_file_access_until_dropped() {
let dir = std::env::temp_dir().join(format!("gridcraft-sandbox-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let existing = dir.join("in.csv");
std::fs::write(&existing, b"a,b\n").unwrap();
let (existing, out) = (existing.to_string_lossy().to_string(), dir.join("out.xlsx").to_string_lossy().to_string());
{
let _outer = ScriptSandbox::enter();
{
let _inner = ScriptSandbox::enter();
assert!(read_file(&existing).is_err());
}
assert!(read_file(&existing).is_err(), "the outer sandbox still holds");
assert!(write_file(&out, b"leak").is_err());
assert!(!std::path::Path::new(&out).exists());
}
assert_eq!(read_file(&existing).unwrap(), b"a,b\n");
write_file(&out, b"ok").unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
/// root entry and the given stream names.
fn cfb(streams: &[&str]) -> Vec<u8> {
+161 -27
View File
@@ -98,6 +98,21 @@ struct OpenGroup {
redo: Vec<HistoryEntry>,
}
/// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the
/// workbook and selection it ended at, the undo length then, and whether it made a step. A
/// command that ended the group tells its own later changes from the group's step by it, and
/// keeps the group's name only when the group's step is exactly its own (the entry holds the
/// workbook the step started from). Dropped when the outermost command finishes, so it keeps no
/// workbook alive between commands.
#[derive(Debug)]
struct GroupStep {
doc: u64,
after: Arc<Workbook>,
selection: Selection,
len: usize,
stepped: bool,
}
/// The clipboard: a block of cells (with formulas and styles) or marching-ants reference.
#[derive(Clone, Debug)]
pub struct Clipboard {
@@ -174,7 +189,15 @@ pub struct DocState {
/// The format `path`'s content was recognised as when its extension doesn't name one (an
/// XLSX called `budget.dat`): Save writes it back in that format.
pub content_kind: Option<io::FileKind>,
/// Whether the workbook's active content may run without asking.
/// Whether the scripts stored in this workbook may run (`automate.runScript`). A workbook
/// that arrives already carrying scripts (opened from a file) starts untrusted until the
/// user trusts it in this session (`automate.trustScripts`); one with no scripts starts
/// trusted, since any script it gains later was written here.
pub scripts_trusted: bool,
/// Where the workbook's content came from: whether its active content may run without
/// asking. Unlike `scripts_trusted`, this is provenance, not consent: it is set when the
/// workbook is opened from a file, carried by content pasted from such a workbook, and never
/// changed by a trust decision.
pub trust: Trust,
}
@@ -201,6 +224,10 @@ fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize {
let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells);
bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next));
}
// Snapshots share the macro list until a change to it; a snapshot with its own list keeps it.
if !older.macros.shares(&newer.macros) {
bytes = bytes.saturating_add(older.macros.estimated_bytes());
}
bytes
}
@@ -240,6 +267,7 @@ impl DocState {
pub fn new(mut wb: Workbook, path: Option<String>, title: String) -> DocState {
let mut calc = Calc::new();
calc.recalc_all(&mut wb);
let scripts_trusted = !cmd::extra::has_scripts(&wb);
let wb = Arc::new(wb);
let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default());
DocState {
@@ -256,6 +284,7 @@ impl DocState {
sheet_selections: vec![],
unlocked_ranges: vec![],
content_kind: None,
scripts_trusted,
trust: Trust::Local,
}
}
@@ -396,8 +425,12 @@ pub struct Session {
next_group: u64,
/// Counts group begins and ends, so `execute` can tell that a command opened or closed one.
group_epoch: u64,
/// The step the last group made: document, its undo length after the step, and the epoch.
group_step: Option<(u64, usize, u64)>,
/// Where the last group ended, so `execute` can tell a command's own changes from its group's.
group_step: Option<GroupStep>,
/// For each running command, innermost last: its document and that document's undo length
/// when it began. Steps before that length are not touched while the command runs (see
/// [`Session::bound_tail`]).
marks: Vec<(u64, usize)>,
/// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses
/// [`cmd::format::approx_text_width`].
pub text_width: Option<TextWidth>,
@@ -504,30 +537,64 @@ impl Session {
let (first_group, group_epoch) = (self.next_group, self.group_epoch);
// Whether a group was open on the document before the command, and how long its history was.
let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len())));
// A command that panics leaves the history as it found it: the steps commands nested in it
// made or undid, and the redo history they cleared, come back. A length is not enough,
// because a nested `edit.undo` removes entries.
let history_before = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone())));
// The groups open on the document before the command (usually none), for the same reason.
let groups_before: Vec<(u64, String, Arc<Workbook>, Selection)> = match &before {
Some((uid, ..)) => {
self.groups.iter().filter(|g| g.doc == *uid).map(|g| (g.id, g.label.clone(), g.wb.clone(), g.selection.clone())).collect()
}
None => Vec::new(),
};
// The recorder keeps top-level calls with the place they ran in.
let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() {
Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()),
None => (0, 0, String::new(), Selection::default()),
});
let doc_uid = before.as_ref().map(|(uid, ..)| *uid);
let marked = doc_uid.zip(start).map(|(uid, (_, len))| (uid, len));
self.marks.extend(marked);
self.depth = self.depth.saturating_add(1);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, &params)));
self.depth = self.depth.saturating_sub(1);
if marked.is_some() {
self.marks.pop();
}
let result = match result {
Ok(r) => r,
Err(p) => {
let msg =
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
// Restore the workbook as it was, and drop the undo groups the command opened.
// Restore the workbook and its history as they were, and drop the undo groups the
// command opened.
self.groups.retain(|g| g.id < first_group);
let group_open = before.as_ref().is_some_and(|(uid, ..)| self.groups.iter().any(|g| g.doc == *uid));
if let Some((uid, wb, sel)) = &before
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid)
{
d.wb = wb.clone();
d.selection = sel.clone();
if let Some((undo, redo)) = history_before {
d.undo = undo;
d.redo = redo;
}
// A group open before the command and ended or rolled back inside it stays
// ended (its token went with the command), and keeps a step from its start to
// the restored workbook, so Undo still returns to the state before the group.
if !group_open
&& let Some((_, label, wb, selection)) = groups_before.into_iter().min_by_key(|g| g.0)
&& !Arc::ptr_eq(&wb, &d.wb)
{
push_entry(&mut d.undo, label, wb, &d.wb, selection);
d.redo.clear();
}
d.calc.rebuild(&d.wb);
}
if self.depth == 0 {
self.trim_history();
self.group_step = None;
}
return Err(EngineError::Internal(id.to_string(), msg));
}
@@ -542,28 +609,53 @@ impl Session {
}
self.push_journal(id, cmd::journal_params(&params));
}
// Inside an undo group, or when the command ended one that was open before it, the group
// owns the step. A command that opened and closed its own groups makes one step of its
// own, which replaces the steps those groups made.
// Inside an undo group the group owns the step. A command that ended a group open before
// it leaves the group its step and makes one of its own for what it changed after the end.
// A command that opened and closed its own groups makes one step of its own, which
// replaces the steps those groups made.
let epoch_changed = self.group_epoch != group_epoch;
let in_group_now = before.as_ref().is_some_and(|(uid, ..)| self.in_group(*uid));
let own_groups = epoch_changed && !in_group_now && start.is_some_and(|(was_open, _)| !was_open);
let grouped = !own_groups && (epoch_changed || in_group_now);
let was_open = start.is_some_and(|(was_open, _)| was_open);
if spec.undoable
&& let Some((uid, wb, sel)) = before
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid)
&& !Arc::ptr_eq(&d.wb, &wb)
{
if !grouped {
let mut label = spec.label.trim_end_matches('…').to_string();
if own_groups && let Some((_, len)) = start {
let label = spec.label.trim_end_matches('…').to_string();
if in_group_now {
// A group this command opened and left open (a debugger's start) takes the
// command's start as its own, so what the command changed before opening it is
// part of the group's step and not a step of its own.
if !was_open
&& let Some((_, len)) = start
&& let Some(g) = self.groups.iter_mut().filter(|g| g.doc == uid && g.id >= first_group).min_by_key(|g| g.id)
{
g.start = len;
g.wb = wb;
g.selection = sel;
}
} else if epoch_changed && was_open {
// The group it ended made its step; changes after the end, whether direct or by
// nested commands, are the command's own step from where the group ended.
if let Some(step) = self.group_step.as_ref().filter(|g| g.doc == uid)
&& (d.undo.len() > step.len || !Arc::ptr_eq(&step.after, &d.wb))
{
d.undo.truncate(step.len);
push_entry(&mut d.undo, label, step.after.clone(), &d.wb, step.selection.clone());
d.redo.clear();
}
} else {
let mut label = label;
if epoch_changed && let Some((_, len)) = start {
// A command whose only step is its group's keeps that name ("Run Macro: Totals").
// The step must have been made by a group this command ended (its epoch is
// past the one the command started with): an older record at the same length
// belongs to an earlier command, undone since.
// That is so only when the one entry the command added is a group's step from
// the command's own start to its own result: an entry a nested command made,
// a group step of an earlier command (undone since), or a group's step that
// the command edited past all name the step after the command instead.
if d.undo.len() == len + 1
&& self.group_step.is_some_and(|(doc, n, epoch)| doc == uid && n == len + 1 && epoch > group_epoch)
&& let Some(last) = d.undo.last()
&& Arc::ptr_eq(&last.wb, &wb)
&& self.group_step.as_ref().is_some_and(|g| g.doc == uid && g.stepped && Arc::ptr_eq(&g.after, &d.wb))
{
label = last.label.clone();
}
@@ -577,9 +669,12 @@ impl Session {
d.revision += 1;
}
// The history limit applies once the outermost command has collapsed its steps, so the
// lengths it compared stay true.
// lengths it compared stay true. Meanwhile the steps made inside it are bounded on their own.
if self.depth == 0 {
self.trim_history();
self.group_step = None;
} else if let Some(uid) = doc_uid {
self.bound_tail(uid);
}
result
}
@@ -589,17 +684,27 @@ impl Session {
/// is rolled back and its error is returned. Groups nest; the outermost one makes the step.
pub fn grouped<R>(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result<R>) -> Result<R> {
let token = self.group_begin(label)?;
match f(self) {
Ok(r) => {
self.group_end(token)?;
Ok(r)
}
Err(e) => {
let doc = token.doc();
// A panic in `f` rolls the group back before it goes on, so a caller that catches it
// (outside `execute`, which drops a command's groups itself) finds no group left open.
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) {
Ok(Ok(r)) => match self.group_end(token) {
Ok(_) => Ok(r),
// `f` closed the group's document (`file.close`, or `file.open` taking the place of
// an untouched new workbook), and the group went with it: nothing is left to undo.
Err(_) if !self.docs.iter().any(|d| d.uid == doc) => Ok(r),
Err(e) => Err(e),
},
Ok(Err(e)) => {
// The error from `f` is the one to report. A rollback fails only when the group
// has already ended, and then there is nothing left to undo.
let _ = self.group_rollback(token);
Err(e)
}
Err(panic) => {
let _ = self.group_rollback(token);
std::panic::resume_unwind(panic)
}
}
}
@@ -637,10 +742,13 @@ impl Session {
let changed = !Arc::ptr_eq(&d.wb, &g.wb);
if changed && !outer {
push_entry(&mut d.undo, g.label, g.wb, &d.wb, g.selection);
self.group_step = Some((g.doc, d.undo.len(), self.group_epoch));
d.redo.clear();
d.revision += 1;
}
if !outer {
self.group_step =
Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: d.undo.len(), stepped: changed });
}
// Inside a command, the outermost command applies the history limit when it finishes.
if self.depth == 0 {
self.trim_history();
@@ -648,6 +756,23 @@ impl Session {
Ok(changed)
}
/// Bounds the undo steps pushed while a command runs (a script of many steps, a subtotal per
/// group): at most [`UNDO_LIMIT`] of them within [`UNDO_BYTES_LIMIT`], dropping the oldest.
/// Only steps after the latest mark go: a running command's start, an open group's start and
/// the end of the last group are positions the command or group collapses its steps by when
/// it finishes, so what is before them stays as it is until [`Session::trim_history`].
fn bound_tail(&mut self, uid: u64) {
let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
let marks = self.marks.iter().filter(|(doc, _)| *doc == uid).map(|(_, len)| *len);
let groups = self.groups.iter().filter(|g| g.doc == uid).map(|g| g.start);
let step = self.group_step.as_ref().filter(|g| g.doc == uid).map(|g| g.len);
let mark = marks.chain(groups).chain(step).max().unwrap_or(0).min(d.undo.len());
let mut bytes = d.undo.iter().skip(mark).fold(0usize, |sum, e| sum.saturating_add(e.retained_bytes));
while d.undo.len() > mark + 1 && (d.undo.len() - mark > UNDO_LIMIT || bytes > UNDO_BYTES_LIMIT) {
bytes = bytes.saturating_sub(d.undo.remove(mark).retained_bytes);
}
}
/// Drops the oldest undo steps past [`UNDO_LIMIT`] or [`UNDO_BYTES_LIMIT`]. A document with
/// an open group waits for the group to end, because the group remembers where its steps start.
fn trim_history(&mut self) {
@@ -670,9 +795,11 @@ impl Session {
d.undo.truncate(g.start);
d.redo = g.redo;
d.revision += 1;
// A step the rollback dropped is no longer anyone's to name.
if self.group_step.is_some_and(|(doc, n, _)| doc == g.doc && n > g.start) {
self.group_step = None;
// The record of the last group step stays when the document is back exactly where that
// step left it (this group began there); otherwise the rollback is where the group ended.
let kept = self.group_step.as_ref().is_some_and(|st| st.doc == g.doc && st.len == g.start && Arc::ptr_eq(&st.after, &d.wb));
if !kept && !self.groups.iter().any(|o| o.doc == g.doc) {
self.group_step = Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: g.start, stepped: false });
}
Ok(())
}
@@ -727,10 +854,17 @@ impl Session {
/// queued a file dialog because it got no file (`file.open`, `file.saveAs`, `file.save` on a workbook with no
/// file yet, `data.getData`) is an error instead of a silent success.
pub fn execute_headless(&mut self, id: &str, params: Json) -> Result<Json> {
let recorded = self.recorder.as_ref().map(|r| (r.steps.len(), r.dropped));
let r = self.execute(id, params);
let requests = self.take_ui_requests();
let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs"));
if r.is_ok() && asked_for_file {
// The caller gets an error, so the recording keeps no step for the command.
if let Some((steps, dropped)) = recorded
&& let Some(rec) = self.recorder.as_mut()
{
rec.truncate(steps, dropped);
}
let what = match id {
"file.open" => "`path` or `base64`",
"data.getData" | "data.fromTextCsv" => "`path` or `text`",
+30 -18
View File
@@ -37,11 +37,18 @@ pub fn parse_macro(v: &Json) -> Result<Macro, String> {
}
let mut m: Macro = serde_json::from_value(v).map_err(|e| format!("This isn't a valid macro: {e}"))?;
m.name = m.name.trim().to_string();
m.check_limits()?;
check_size(&m)?;
check(&m)?;
Ok(m)
}
/// Every check a macro must pass before it is kept in a workbook: the model's limits
/// ([`Macro::check_limits`]) and the size an xlsx file can hold. Every path that adds a macro,
/// from JSON, from a file or built in code, goes through this one check.
pub fn check(m: &Macro) -> Result<(), String> {
m.check_limits()?;
check_size(m)
}
/// Refuses a macro too large to keep in an xlsx file ([`stash::MAX_ITEM_CHARS`] characters of
/// JSON), so saving a workbook never fails because of one of its macros.
fn check_size(m: &Macro) -> Result<(), String> {
@@ -55,9 +62,6 @@ fn check_size(m: &Macro) -> Result<(), String> {
/// before the steps are deserialized.
fn normalize_steps(steps: &mut Json, level: usize) -> Result<(), String> {
let Some(list) = steps.as_array_mut() else { return Err("`steps` must be a list.".into()) };
if level > gridcraft_model::MAX_MACRO_DEPTH {
return Err(format!("Repeat and If steps can nest at most {} deep.", gridcraft_model::MAX_MACRO_DEPTH));
}
for st in list {
let Some(o) = st.as_object_mut() else { return Err("Every step must be an object.".into()) };
if !o.contains_key("type")
@@ -66,6 +70,11 @@ fn normalize_steps(steps: &mut Json, level: usize) -> Result<(), String> {
o.insert("type".into(), json!("command"));
o.insert("id".into(), id);
}
// A step with a body is a Repeat or an If: one at the top is nested 1 deep.
let nested = ["body", "then", "else"].iter().any(|key| o.contains_key(*key));
if nested && level > gridcraft_model::MAX_MACRO_DEPTH {
return Err(format!("Repeat and If steps can nest at most {} deep.", gridcraft_model::MAX_MACRO_DEPTH));
}
for key in ["body", "then", "else"] {
if let Some(body) = o.get_mut(key) {
normalize_steps(body, level + 1)?;
@@ -89,17 +98,18 @@ pub fn find(wb: &Workbook, name: &str) -> Option<usize> {
/// A copy of `wb` for xlsx with each macro in hidden names (`_gc_macro_<n>_<k>`) instead of the
/// field, or `None` when there is nothing to change.
pub fn with_names(wb: &Workbook) -> crate::Result<Option<Workbook>> {
let stale = !stash::keys(wb, MACRO_KEY).is_empty();
if wb.macros.is_empty() && !stale {
// Only the numbered items are this build's: a hidden `_gc_macro_<x>_<k>` name with some
// other key stays, as `load` leaves it.
let stored = |n: &DefinedName| macro_item(n).is_some_and(|(_, i)| i.is_some());
if wb.macros.is_empty() && !wb.names.iter().any(stored) {
return Ok(None);
}
let mut out = wb.clone();
for key in stash::keys(&out, MACRO_KEY) {
stash::remove(&mut out, &key);
}
for (i, m) in wb.macros.iter().enumerate() {
stash::set(&mut out, &format!("{MACRO_KEY}{i}"), &macro_json(m).to_string())?;
}
// One pass drops the items of the last save, and one writes the macros (a file can hold a
// thousand macros and many thousands of names).
out.names.retain(|n| !stored(n));
let items: Vec<(String, String)> = wb.macros.iter().enumerate().map(|(i, m)| (format!("{MACRO_KEY}{i}"), macro_json(m).to_string())).collect();
stash::set_many(&mut out, &items)?;
out.macros.clear();
Ok(Some(out))
}
@@ -125,8 +135,9 @@ pub fn load(wb: &mut Workbook) -> Vec<String> {
match macro_item(&n) {
None => kept.push(n),
Some((key, Some(i))) => stored.entry((i, key)).or_default().push(n),
// A `macro_` item that isn't numbered isn't one this build wrote; it goes unread.
Some((_, None)) => {}
// A `macro_` item that isn't numbered isn't one this build wrote: it stays as it is,
// unread, and a save keeps it.
Some((_, None)) => kept.push(n),
}
}
wb.names = kept;
@@ -154,7 +165,7 @@ pub fn load(wb: &mut Workbook) -> Vec<String> {
let checked = m.check_limits().and_then(|()| {
m.name = taken.unique(m.name.trim());
// The macro as it is kept, renamed, must still pass, or the next save would lose it.
m.check_limits().and_then(|()| check_size(&m))
check(&m)
});
match checked {
Ok(()) => {
@@ -186,8 +197,9 @@ fn macro_item(n: &DefinedName) -> Option<(String, Option<u64>)> {
Some((key, i))
}
/// `name`, or `name (2)`, `name (3)`… if a macro already has it. A long name is shortened to make
/// room for the suffix, so the result stays within [`gridcraft_model::MAX_MACRO_NAME`] characters.
/// `name`, or `name (2)`, `name (3)`… if a macro already has it. A free name comes back as it is;
/// a renamed one is shortened to make room for the suffix, so it stays within
/// [`gridcraft_model::MAX_MACRO_NAME`] characters. The result still goes through [`check`].
pub fn unique_name(wb: &Workbook, name: &str) -> String {
let mut taken = Taken::default();
for m in &wb.macros {
+98 -5
View File
@@ -55,16 +55,25 @@ fn macros_over_the_limits_are_refused() {
let flat = |n: usize| json!({"name": "Big", "steps": vec![json!({"command": "cell.set"}); n]});
assert!(parse_macro(&flat(MAX_MACRO_STEPS)).is_ok());
assert!(parse_macro(&flat(MAX_MACRO_STEPS + 1)).unwrap_err().contains("10000 steps"));
// Nested Repeat steps: 4 levels are fine, 5 are not.
let nest = |levels: usize| {
// Nested Repeat steps: 4 Repeats one inside the other are fine (the innermost holds a
// command, or nothing), a 5th is not.
let nest = |repeats: usize| {
let mut step = json!({"command": "cell.set"});
for _ in 1..levels {
for _ in 0..repeats {
step = json!({"type": "repeat", "over": "Input", "body": [step]});
}
json!({"name": "Deep", "steps": [step]})
};
assert!(parse_macro(&nest(4)).is_ok());
assert!(parse_macro(&nest(5)).unwrap_err().contains("4 deep"));
assert!(parse_macro(&nest(MAX_MACRO_DEPTH)).is_ok());
assert_eq!(parse_macro(&nest(MAX_MACRO_DEPTH)).unwrap().step_count(), MAX_MACRO_DEPTH + 1);
assert!(parse_macro(&nest(MAX_MACRO_DEPTH + 1)).unwrap_err().contains("4 deep"));
let mut empty = json!({"type": "repeat", "over": "Input", "body": []});
for _ in 1..MAX_MACRO_DEPTH {
empty = json!({"type": "if", "cell": "A1", "op": "eq", "value": 1, "then": [empty], "else": []});
}
let deep = parse_macro(&json!({"name": "Deep", "steps": [empty]})).unwrap();
assert_eq!(deep.step_count(), MAX_MACRO_DEPTH);
assert!(deep.check_limits().is_ok(), "the model's check agrees with the parser");
// Far deeper than serde would allow is refused before it is read.
assert!(parse_macro(&nest(200)).is_err());
for bad in [
@@ -140,6 +149,90 @@ fn a_visible_name_that_looks_like_macro_storage_is_left_alone() {
assert_eq!(wb.names, vec![name]);
}
#[test]
fn input_names_that_read_as_cell_references_are_refused() {
// A recorded step holds plain A1 text; an input called `Q1` would take its place.
let with_input = |name: &str| json!({"name": "M", "inputs": [{"name": name}]});
for name in ["Q1", "AB12", "Sum1", "a1"] {
let err = parse_macro(&with_input(name)).unwrap_err();
assert!(err.contains("reads as a cell reference"), "{name}: {err}");
}
for name in ["Amount", "Q", "Sales_Q1", "Total1a"] {
assert!(parse_macro(&with_input(name)).is_ok(), "{name}");
}
assert!(parse_macro(&with_input("Row")).unwrap_err().contains("token of its own"));
}
#[test]
fn a_hidden_macro_name_this_build_did_not_write_is_kept() {
// `_gc_macro_index_0` is not a numbered item: it is neither read nor dropped on open, and a
// save with macros keeps it beside the items written.
let mut wb = Workbook::new();
let theirs = hidden("_gc_macro_index_0", "{}");
wb.names.push(theirs.clone());
wb.macros.push(sample_macro("Totals"));
let (raw, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap();
let names = names_of(&raw);
assert!(names.contains(&"_gc_macro_index_0".to_string()) && names.contains(&"_gc_macro_0_0".to_string()), "{names:?}");
let (back, warnings) = crate::io::open_bytes("book.xlsx", &crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(back.macros, wb.macros);
assert_eq!(names_of(&back), ["_gc_macro_index_0"]);
}
#[test]
fn workbook_snapshots_share_the_macro_list_until_it_changes() {
// An undo snapshot or a copy made for a cell edit holds the same list, not a copy of every
// step; the undo budget charges a list only once it is a snapshot's own.
let mut wb = Workbook::new();
wb.macros.push(sample_macro("Totals"));
let snapshot = wb.clone();
assert!(wb.macros.shares(&snapshot.macros));
assert_eq!(crate::history_retained_bytes(&snapshot, &wb), std::mem::size_of::<Workbook>());
wb.macros.push(sample_macro("More"));
assert!(!wb.macros.shares(&snapshot.macros), "a write copies the list first");
assert_eq!(snapshot.macros.len(), 1);
assert!(crate::history_retained_bytes(&snapshot, &wb) > std::mem::size_of::<Workbook>());
// The copy is per list, not per workbook copy: cloning again shares the new list.
let again = wb.clone();
assert!(wb.macros.shares(&again.macros));
let rebuilt: Macros = wb.macros.iter().cloned().collect();
assert_eq!(rebuilt, wb.macros);
assert_eq!(rebuilt.into_iter().count(), 2);
}
#[test]
fn a_macro_built_in_code_goes_through_the_size_check_too() {
// `check` is the one check for every path, so a macro that passes it never makes a save fail.
let big = Macro {
name: "Big".into(),
steps: vec![Step::command("cell.set", json!({"input": "x".repeat(crate::cmd::stash::MAX_ITEM_CHARS)}))],
..Macro::default()
};
assert!(big.check_limits().is_ok(), "the model's limits say nothing about the size");
assert!(check(&big).unwrap_err().contains("too large"));
let mut wb = Workbook::new();
wb.macros.push(big);
assert!(crate::io::save_bytes(&wb, "book.xlsx").is_err(), "a macro that fails `check` fails the save");
}
#[test]
fn a_save_refuses_a_clashing_visible_name_before_changing_anything() {
let mut wb = Workbook::new();
wb.macros.push(sample_macro("A"));
wb.macros.push(sample_macro("B"));
let own = DefinedName { hidden: false, ..hidden("_GC_MACRO_1_0", "mine") };
wb.names.push(own.clone());
wb.names.push(hidden("_gc_macro_7_0", "stale"));
let err = crate::io::save_bytes(&wb, "book.xlsx").unwrap_err().to_string();
assert!(err.contains("_GC_MACRO_1_0"), "{err}");
assert_eq!(wb.names.len(), 2, "nothing changed");
wb.names.remove(0);
let (raw, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap();
let names = names_of(&raw);
assert_eq!(names, ["_gc_macro_0_0", "_gc_macro_1_0"], "the stale item went in the same pass");
}
#[test]
fn scripts_and_vba_stay_as_they_are() {
// Automate scripts keep their own storage, and a VBA project is carried through unchanged.
+22 -2
View File
@@ -13,6 +13,10 @@ use crate::Selection;
/// The most steps one recording keeps; later ones are counted in [`Recorder::dropped`].
pub const RECORD_LIMIT: usize = 100_000;
/// The most bytes (steps serialized as JSON) one recording keeps. A step keeps its full
/// parameters so it can be played back, and a `file.open` holds the whole file as base64, so
/// without a size cap a recording of a few opens of a large workbook keeps tens of MB alive.
pub const RECORD_MAX_BYTES: usize = 64 * 1024 * 1024;
/// One recorded command and where it ran.
#[derive(Clone, Debug, PartialEq, Serialize)]
@@ -34,17 +38,33 @@ pub struct RecordedStep {
#[derive(Clone, Debug, Default)]
pub struct Recorder {
pub steps: Vec<RecordedStep>,
/// Steps not kept because the recording reached [`RECORD_LIMIT`].
/// Steps not kept because the recording reached [`RECORD_LIMIT`] or [`RECORD_MAX_BYTES`].
/// Once a step is dropped, every later one is too, so `steps` is always the beginning of
/// what ran, never a sequence with gaps.
pub dropped: usize,
/// The size of `steps` serialized as JSON.
bytes: usize,
}
impl Recorder {
/// Adds a step, or counts it as dropped once the buffer is full.
pub fn push(&mut self, step: RecordedStep) {
if self.steps.len() < RECORD_LIMIT {
let size = serde_json::to_vec(&step).map_or(0, |v| v.len());
let fits = self.steps.len() < RECORD_LIMIT && self.bytes.saturating_add(size) <= RECORD_MAX_BYTES;
if fits && self.dropped == 0 {
self.bytes = self.bytes.saturating_add(size);
self.steps.push(step);
} else {
self.dropped = self.dropped.saturating_add(1);
}
}
/// Forgets the steps after the first `steps` and puts the dropped count back to `dropped`,
/// for a command the caller received as failed after it was recorded.
pub fn truncate(&mut self, steps: usize, dropped: usize) {
for step in self.steps.drain(steps.min(self.steps.len())..) {
self.bytes = self.bytes.saturating_sub(serde_json::to_vec(&step).map_or(0, |v| v.len()));
}
self.dropped = dropped;
}
}
+140
View File
@@ -1093,6 +1093,131 @@ fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() {
s.execute("automate.runScript", json!({"name": "Lock"})).unwrap();
}
/// A session holding a workbook opened from file bytes whose `Fill` script sets B1 (#2178).
fn opened_with_script() -> Session {
let mut author = s();
author
.execute("automate.newScript", json!({"name": "Fill", "commands": [{"command": "cell.set", "params": {"cell": "B1", "input": "ran"}}]}))
.unwrap();
let bytes = crate::io::save_bytes(&author.doc().unwrap().wb, "book.xlsx").unwrap();
let mut s = Session::new();
s.execute("file.open", json!({"name": "downloaded.xlsx", "base64": crate::io::base64_encode(&bytes)})).unwrap();
s
}
#[test]
fn scripts_written_in_this_session_run_without_a_trust_step() {
let mut s = s();
assert!(s.doc().unwrap().scripts_trusted);
s.execute("automate.newScript", json!({"name": "Fill", "commands": [{"command": "cell.set", "params": {"cell": "B1", "input": "ran"}}]}))
.unwrap();
assert_eq!(s.execute("automate.runScript", json!({"name": "Fill"})).unwrap()["ran"], 1);
assert_eq!(v(&s, "B1"), Value::Text("ran".into()));
}
#[test]
fn untrusted_workbook_scripts_are_refused_until_trusted() {
let mut s = opened_with_script();
s.ui_requests.clear();
assert!(!s.doc().unwrap().scripts_trusted, "a workbook opened with scripts starts untrusted");
let listed = s.execute("automate.allScripts", json!({})).unwrap();
assert_eq!(listed[0]["trusted"], false, "{listed}");
let err = s.execute("automate.runScript", json!({"name": "Fill"})).unwrap_err().to_string();
assert!(err.contains("trust"), "{err}");
assert_eq!(v(&s, "B1"), Value::Empty, "no step of an untrusted script runs");
// Trust can't be smuggled in as a string, and nothing opens a dialog.
assert!(s.execute("automate.runScript", json!({"name": "Fill", "trusted": "yes"})).is_err());
assert!(s.ui_requests.is_empty(), "{:?}", s.ui_requests);
// An explicit `trusted: true` runs it this once without trusting the workbook.
s.execute("automate.runScript", json!({"name": "Fill", "trusted": true})).unwrap();
assert_eq!(v(&s, "B1"), Value::Text("ran".into()));
assert!(!s.doc().unwrap().scripts_trusted);
s.execute("cell.set", json!({"cell": "B1", "input": ""})).unwrap();
assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err());
// Trusting the workbook (the UI's Trust Scripts action) lets its scripts run this session.
assert_eq!(s.execute("automate.trustScripts", json!({})).unwrap()["trusted"], true);
s.execute("automate.runScript", json!({"name": "Fill"})).unwrap();
assert_eq!(v(&s, "B1"), Value::Text("ran".into()));
// A second window on the same workbook keeps the decision; withdrawing it disables them again.
s.execute("view.newWindow", json!({})).unwrap();
assert!(s.doc().unwrap().scripts_trusted);
s.execute("automate.trustScripts", json!({"trusted": false})).unwrap();
assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err());
}
#[test]
fn trust_scripts_only_defaults_to_trusted_when_the_flag_is_missing() {
let mut s = opened_with_script();
// A `trusted` that isn't a boolean is an error and leaves the workbook untrusted.
for wrong in [json!("false"), json!("true"), json!(0), json!(1), json!(null), json!([true])] {
let err = s.execute("automate.trustScripts", json!({"trusted": wrong})).unwrap_err().to_string();
assert!(err.contains("trusted"), "{wrong}: {err}");
assert!(!s.doc().unwrap().scripts_trusted, "{wrong} must not trust the workbook");
assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err());
}
// The refusal names where the Trust Scripts button actually is.
let err = s.execute("automate.runScript", json!({"name": "Fill"})).unwrap_err().to_string();
assert!(err.contains("View › Macros") && err.contains("automate.trustScripts"), "{err}");
assert_eq!(s.execute("automate.trustScripts", json!({"trusted": false})).unwrap()["trusted"], false);
assert!(!s.doc().unwrap().scripts_trusted);
assert_eq!(s.execute("automate.trustScripts", json!({})).unwrap()["trusted"], true);
assert!(s.doc().unwrap().scripts_trusted);
}
#[test]
fn scripts_leave_file_access_working_after_they_run() {
// The sandbox a script runs in is lifted afterwards, also when a step fails.
let dir = std::env::temp_dir().join(format!("gridcraft-script-sandbox-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let csv = dir.join("in.csv");
std::fs::write(&csv, b"a,b\n1,2\n").unwrap();
let csv = csv.to_string_lossy().to_string();
let mut s = s();
let bad_step = json!({"command": "cell.set", "params": {"cell": "not a cell", "input": "x"}});
s.execute("automate.newScript", json!({"name": "Bad", "commands": [bad_step]})).unwrap();
assert!(s.execute("automate.runScript", json!({"name": "Bad"})).is_err());
s.execute("automate.newScript", json!({"name": "Ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "1"}}]})).unwrap();
s.execute("automate.runScript", json!({"name": "Ok"})).unwrap();
s.execute("data.fromTextCsv", json!({"path": csv})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn scripts_cannot_reach_outside_the_workbook_even_when_trusted() {
let dir = std::env::temp_dir().join(format!("gridcraft-script-deny-{}", std::process::id()));
let leak = dir.join("leak.xlsx");
let leak = leak.to_string_lossy().to_string();
let mut s = opened_with_script();
s.ui_requests.clear();
s.execute("automate.trustScripts", json!({})).unwrap();
let denied = [
json!({"command": "link.open", "params": {"url": "https://example.com/?d=secret"}}),
json!({"command": "file.saveAs", "params": {"path": leak}}),
json!({"command": "file.save", "params": {}}),
json!({"command": "file.exportPdf", "params": {"path": leak}}),
json!({"command": "file.open", "params": {"path": leak}}),
json!({"command": "data.getData", "params": {"path": leak}}),
json!({"command": "data.fromTextCsv", "params": {"path": leak}}),
json!({"command": "insert.picture", "params": {"path": leak}}),
];
for step in denied {
// The harmless first step proves the whole script is refused before anything runs.
let first = json!({"command": "cell.set", "params": {"cell": "C1", "input": "partial"}});
s.execute("automate.newScript", json!({"name": "Leak", "commands": [first, step]})).unwrap();
let r = s.execute("automate.runScript", json!({"name": "Leak", "trusted": true}));
assert!(r.is_err(), "{step} must be refused");
assert_eq!(v(&s, "C1"), Value::Empty, "{step}: no step runs");
}
assert!(!s.ui_requests.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_))), "{:?}", s.ui_requests);
assert!(!std::path::Path::new(&leak).exists());
// Data a script supplies itself is still fine.
s.execute("automate.newScript", json!({"name": "Csv", "commands": [{"command": "data.fromTextCsv", "params": {"text": "a,b\n1,2"}}]})).unwrap();
s.execute("automate.runScript", json!({"name": "Csv"})).unwrap();
}
/// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected.
fn protected(extra: serde_json::Value) -> Session {
let mut s = s();
@@ -2595,6 +2720,21 @@ fn file_options_reports_settings_and_asks_for_the_options_page() {
assert_eq!(s.active().unwrap().wb, before, "Options never changes the workbook");
}
/// Ctrl+8 hides and shows the outline symbols; it's undoable and a sheet setting.
#[test]
fn show_outline_symbols_toggles() {
let mut s = s();
let on = |s: &Session| s.active().unwrap().wb.active().unwrap().show_outline_symbols;
assert!(on(&s));
assert_eq!(s.execute("view.showOutlineSymbols", json!({})).unwrap(), json!({"on": false}));
assert!(!on(&s));
s.execute("edit.undo", json!({})).unwrap();
assert!(on(&s));
s.execute("view.showOutlineSymbols", json!({"on": false})).unwrap();
reopen(&mut s);
assert!(!on(&s));
}
fn reopen(s: &mut Session) {
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
let b64 = r["base64"].as_str().unwrap().to_string();
+331 -3
View File
@@ -170,6 +170,27 @@ fn a_long_group_keeps_the_history_before_it() {
assert_eq!(d.undo.iter().filter(|e| e.label == "Enter Cell").count(), 99);
}
#[test]
fn a_grouped_action_that_closes_its_document_succeeds() {
// The group goes with the document; the action did what it was asked.
let mut s = s();
let uid = s.doc().unwrap().uid;
s.grouped("Close", |s| s.execute("file.close", json!({}))).unwrap();
assert!(s.docs.iter().all(|d| d.uid != uid) && !s.in_group(uid));
// Opening a file into an untouched new workbook takes its place the same way.
s.new_workbook();
let uid = s.doc().unwrap().uid;
let bytes = serde_json::to_vec(&gridcraft_model::Workbook::new()).unwrap();
s.grouped("Open", |s| s.execute("file.open", json!({"name": "theirs.json", "base64": crate::io::base64_encode(&bytes)}))).unwrap();
assert!(s.docs.iter().all(|d| d.uid != uid) && !s.in_group(uid) && s.groups.is_empty());
// A group ended by someone else while its document is still open is still an error.
let err = s.grouped("Ended", |s| {
let token = s.groups.last().map(|g| crate::GroupToken { id: g.id, doc: g.doc });
s.group_end(token.ok_or_else(|| EngineError::Other("no group".into()))?).map(|_| ())
});
assert!(err.unwrap_err().to_string().contains("already ended"));
}
#[test]
fn closing_a_document_drops_its_groups() {
let mut s = s();
@@ -226,6 +247,27 @@ fn register_group_commands() {
});
// A command that is one group keeps the group's name.
register("test.onlyGroup", |s, _| s.grouped("Named Step", |s| put(s, "D1", "x")).map(|_| json!({})));
// A group, then an edit the command makes itself (no nested command, so no undo entry).
register("test.groupThenEdit", |s, _| {
s.grouped("Named Step", |s| put(s, "D1", "x"))?;
crate::cmd::edit(s, |cx| {
let at = gridcraft_core::CellRef::parse("D2").ok_or_else(|| EngineError::Other("D2".into()))?;
cx.sheet_mut(0)?.set_value(at, gridcraft_core::Value::Text("y".into()));
cx.touch(0, at);
Ok(())
})?;
Ok(json!({}))
});
// A group with a change, then one that is rolled back: the step is still the first group's.
register("test.groupThenRolledBack", |s, _| {
s.grouped("Named Step", |s| put(s, "D1", "x"))?;
let failed: crate::Result<()> = s.grouped("Doomed", |s| {
put(s, "D3", "gone")?;
Err(EngineError::Other("no".into()))
});
assert!(failed.is_err());
Ok(json!({}))
});
// An edit, then the rollback of the innermost group that was open before the command.
register("test.rollbackOpenGroup", |s, _| {
put(s, "E1", "gone")?;
@@ -240,6 +282,33 @@ fn register_group_commands() {
s.group_end(token.ok_or_else(|| EngineError::Other("no group".into()))?)?;
Ok(json!({}))
});
// The end of the innermost group that was open before the command, then an edit.
register("test.endOpenGroupThenEdit", |s, _| {
let token = s.groups.last().map(|g| crate::GroupToken { id: g.id, doc: g.doc });
s.group_end(token.ok_or_else(|| EngineError::Other("no group".into()))?)?;
put(s, "E1", "after")?;
Ok(json!({}))
});
// Many nested edits: the steps they make are bounded while the command runs. (Not undoable
// itself: like a script, its nested steps are the undo steps.)
crate::cmd::register_test_command("test.manyNestedEdits", |s, _| {
let start = s.doc()?.undo.len();
for i in 0..crate::UNDO_LIMIT + 50 {
put(s, "A1", &i.to_string())?;
}
let len = s.doc()?.undo.len();
if len > start + crate::UNDO_LIMIT {
return Err(EngineError::Other(format!("{} steps kept inside the command", len - start)));
}
Ok(json!({}))
});
// An edit, then a group the command leaves open (a debugger's start).
register("test.editThenOpenGroup", |s, _| {
put(s, "A1", "first")?;
let _open = s.group_begin("Debug")?;
put(s, "A2", "second")?;
Ok(json!({}))
});
}
#[test]
@@ -266,9 +335,9 @@ fn a_command_that_runs_its_own_group_makes_one_step() {
#[test]
fn a_stale_group_step_does_not_name_a_later_command() {
// The record of the last group step (document, length, epoch) survives the Undo of that step.
// A later command that ends a group of its own at the same history length, after rolling
// another back, must not take the name of the nested edit it made.
// The record of the last group step survives the Undo of that step. A later command that
// ends a group of its own at the same history length, after rolling another back, must not
// take the name of the nested edit it made.
register_group_commands();
let mut s = s();
s.execute("test.onlyGroup", json!({})).unwrap();
@@ -285,6 +354,95 @@ fn a_stale_group_step_does_not_name_a_later_command() {
assert!(v(&s, "B2").is_empty() && v(&s, "D1").is_empty());
}
#[test]
fn a_command_keeps_its_groups_name_only_for_exactly_that_step() {
// The name comes from the group only when the group's step is the command's whole step: an
// edit after the group (no nested command, so the history length matches) makes it the
// command's own, while a later group that rolled back changes nothing.
register_group_commands();
let mut s = s();
s.execute("test.groupThenEdit", json!({})).unwrap();
assert_eq!(undo_labels(&s), ["test.groupThenEdit"]);
assert_eq!((v(&s, "D1").display(), v(&s, "D2").display()), ("x".into(), "y".into()));
s.execute("edit.undo", json!({})).unwrap();
assert!(v(&s, "D1").is_empty() && v(&s, "D2").is_empty());
assert_eq!(undo_labels(&s), Vec::<String>::new());
s.execute("test.groupThenRolledBack", json!({})).unwrap();
assert_eq!(undo_labels(&s), ["Named Step"]);
assert_eq!(v(&s, "D1").display(), "x");
assert!(v(&s, "D3").is_empty());
s.execute("edit.undo", json!({})).unwrap();
assert!(v(&s, "D1").is_empty());
}
#[test]
fn what_a_command_changes_after_ending_a_group_is_its_own_step() {
// Ending a group that was open before the command leaves the group its step (if it made one)
// and makes a step of the command's own for what it changed after the end.
register_group_commands();
let mut s = s();
// The group made no step: the command's edit must still be undoable, under its own name.
let _unchanged = s.group_begin("Debug").unwrap();
s.execute("test.endOpenGroupThenEdit", json!({})).unwrap();
assert_eq!(undo_labels(&s), ["test.endOpenGroupThenEdit"]);
assert_eq!(v(&s, "E1").display(), "after");
s.execute("edit.undo", json!({})).unwrap();
assert!(v(&s, "E1").is_empty());
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(v(&s, "E1").display(), "after");
// The group made a step: two steps, the group's and the command's.
s.execute("edit.undo", json!({})).unwrap();
let _changed = s.group_begin("Debug").unwrap();
set(&mut s, "B1", "inside");
s.execute("test.endOpenGroupThenEdit", json!({})).unwrap();
assert_eq!(undo_labels(&s), ["Debug", "test.endOpenGroupThenEdit"]);
s.execute("edit.undo", json!({})).unwrap();
assert!(v(&s, "E1").is_empty());
assert_eq!(v(&s, "B1").display(), "inside");
s.execute("edit.undo", json!({})).unwrap();
assert!(v(&s, "B1").is_empty());
}
#[test]
fn a_group_a_command_leaves_open_starts_where_the_command_did() {
// A command that edits and then opens a group it leaves open has no undo step of its own:
// the group's step, made when it ends, covers the command's edit too, so no state from
// inside the command can come back.
register_group_commands();
let mut s = s();
set(&mut s, "C1", "base");
s.execute("test.editThenOpenGroup", json!({})).unwrap();
let d = s.doc().unwrap();
assert!(s.in_group(d.uid));
let token = s.groups.last().map(|g| crate::GroupToken { id: g.id, doc: g.doc }).unwrap();
assert!(s.group_end(token).unwrap());
assert_eq!(undo_labels(&s), ["Enter Cell", "Debug"]);
s.execute("edit.undo", json!({})).unwrap();
assert!(v(&s, "A1").is_empty() && v(&s, "A2").is_empty());
assert_eq!(v(&s, "C1").display(), "base");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!((v(&s, "A1").display(), v(&s, "A2").display()), ("first".into(), "second".into()));
}
#[test]
fn steps_made_inside_a_command_are_bounded_while_it_runs() {
// The history limit applies when the outermost command finishes, so a command's bookkeeping
// holds; but a command that runs thousands of nested commands (a script, a subtotal) must not
// keep thousands of steps alive until then. Each nested step bounds the steps made since the
// command began, and leaves the history from before it alone.
register_group_commands();
let mut s = s();
let before = fill_history(&mut s);
s.execute("test.manyNestedEdits", json!({})).unwrap();
let d = s.doc().unwrap();
assert_eq!(d.undo.len(), crate::UNDO_LIMIT);
assert!(d.undo.iter().all(|e| e.label == "Enter Cell"));
assert!(!d.undo.iter().any(|e| before.iter().any(|w| std::sync::Arc::ptr_eq(w, &e.wb))), "the older history gave way to the newest steps");
assert_eq!(v(&s, "A1").display(), "149");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v(&s, "A1").display(), "148");
}
#[test]
fn a_group_rolled_back_inside_a_command_keeps_the_redo_history() {
// The debugger's Stop with Undo Changes rolls its group back inside a command. The rollback
@@ -385,12 +543,125 @@ fn a_group_ended_at_the_history_limit_replaces_only_the_oldest_step() {
assert_undo_redo(&mut s, &[("F1", "one"), ("F2", "two")]);
}
#[test]
fn a_command_that_panics_leaves_the_history_as_it_found_it() {
// A command that edits through a nested command and then panics: the nested step is gone
// with the workbook it changed, and the redo history the nested step cleared comes back.
crate::cmd::register_undoable_test_command("test.editThenPanic", |s, _| {
put(s, "A1", "half done")?;
panic!("after an edit");
});
let mut s = s();
set(&mut s, "A1", "1");
set(&mut s, "A1", "2");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!((undo_labels(&s).len(), s.doc().unwrap().redo.len()), (1, 1));
let err = s.execute("test.editThenPanic", json!({})).unwrap_err().to_string();
assert!(err.contains("after an edit"), "{err}");
assert_eq!(v(&s, "A1").display(), "1", "the workbook is restored");
assert_eq!(undo_labels(&s), ["Enter Cell"], "no step for the nested edit");
assert_eq!(s.doc().unwrap().redo.len(), 1, "the redo history is back");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(v(&s, "A1").display(), "2");
// A command that undoes through a nested command and then panics: the entry Undo removed
// comes back, and the redo entry it made goes.
crate::cmd::register_undoable_test_command("test.undoThenPanic", |s, _| {
s.execute("edit.undo", json!({}))?;
panic!("after an undo");
});
assert_eq!((undo_labels(&s).len(), s.doc().unwrap().redo.len()), (2, 0));
assert!(s.execute("test.undoThenPanic", json!({})).is_err());
assert_eq!(v(&s, "A1").display(), "2", "the workbook is restored");
assert_eq!(undo_labels(&s), ["Enter Cell", "Enter Cell"], "the undone step is back");
assert_eq!(s.doc().unwrap().redo.len(), 0);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v(&s, "A1").display(), "1");
}
#[test]
fn a_grouped_action_that_panics_is_rolled_back_before_the_panic_goes_on() {
// Outside a command, a caller that catches the panic finds the group rolled back and no
// group left open, so Undo is enabled and the next grouped action does not nest.
let mut s = s();
set(&mut s, "A1", "before");
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
s.grouped::<()>("Doomed", |s| {
set(s, "A1", "half done");
panic!("inside the group");
})
}));
assert!(caught.is_err(), "the panic goes on to the caller");
let uid = s.doc().unwrap().uid;
assert!(!s.in_group(uid));
assert_eq!(v(&s, "A1").display(), "before");
assert_eq!(undo_labels(&s), ["Enter Cell"]);
s.grouped("Next", |s| {
set(s, "A1", "after");
Ok(())
})
.unwrap();
assert_eq!(undo_labels(&s), ["Enter Cell", "Next"]);
}
#[test]
fn a_group_ended_by_a_command_that_then_panics_keeps_its_step() {
// The command takes a token the test left for it, ends or rolls back that group, and panics.
// The group's token went with the command, so the group stays ended; its step runs from the
// group's start to the restored workbook, so Undo still returns to the state before the group.
static TOKEN: std::sync::Mutex<Option<crate::GroupToken>> = std::sync::Mutex::new(None);
crate::cmd::register_undoable_test_command("test.endGroupThenPanic", |s, _| {
let token = TOKEN.lock().map_err(|_| EngineError::Other("lock".into()))?.take();
if let Some(token) = token {
s.group_end(token)?;
}
panic!("after ending a group");
});
crate::cmd::register_undoable_test_command("test.rollBackGroupThenPanic", |s, _| {
let token = TOKEN.lock().map_err(|_| EngineError::Other("lock".into()))?.take();
if let Some(token) = token {
s.group_rollback(token)?;
}
panic!("after rolling back a group");
});
for cmd in ["test.endGroupThenPanic", "test.rollBackGroupThenPanic"] {
let mut s = s();
set(&mut s, "A1", "before");
let token = s.group_begin("Outer").unwrap();
set(&mut s, "A1", "inside");
set(&mut s, "A2", "inside too");
*TOKEN.lock().unwrap() = Some(token);
assert!(s.execute(cmd, json!({})).is_err());
let uid = s.doc().unwrap().uid;
assert!(!s.in_group(uid), "{cmd}: the group stays ended");
assert_eq!(undo_labels(&s), ["Enter Cell", "Outer"], "{cmd}");
assert_eq!(v(&s, "A1").display(), "inside", "{cmd}: the workbook is as the command found it");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!((v(&s, "A1").display(), v(&s, "A2").display()), ("before".into(), String::new()), "{cmd}");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(v(&s, "A2").display(), "inside too", "{cmd}");
}
}
// ---------------------------------------------------------------- recording buffer
fn recorded_ids(s: &Session) -> Vec<String> {
s.recorder.as_ref().unwrap().steps.iter().map(|st| st.id.clone()).collect()
}
#[test]
fn a_headless_call_that_fails_for_want_of_a_file_is_not_recorded() {
// `file.open` with no file queues a dialog and succeeds; a headless caller gets an error for
// it instead, so the recording keeps no step for it either.
let mut s = s();
s.start_recording();
set(&mut s, "A1", "1");
assert!(s.execute_headless("file.open", json!({})).is_err());
assert_eq!(recorded_ids(&s), ["cell.set"]);
let rec = s.stop_recording().unwrap();
assert_eq!(rec.dropped, 0);
}
#[test]
fn the_recorder_keeps_top_level_calls_with_where_they_ran() {
let mut s = s();
@@ -452,6 +723,31 @@ fn the_recorder_has_a_cap() {
assert_eq!(rec.dropped, 3);
}
#[test]
fn the_recorder_has_a_byte_cap_and_keeps_a_prefix() {
// A `file.open` step holds the whole file as base64: the buffer is bounded in bytes too, and
// once a step is dropped every later one is, so what is kept is always how the recording began.
let mut rec = crate::record::Recorder::default();
let step = |n: usize| crate::record::RecordedStep {
doc: 1,
sheet: 0,
sheet_name: "Sheet1".into(),
selection: crate::Selection::default(),
id: "file.open".into(),
params: json!({"base64": "A".repeat(n)}),
};
let big = crate::record::RECORD_MAX_BYTES / 2;
rec.push(step(big));
assert_eq!((rec.steps.len(), rec.dropped), (1, 0));
rec.push(step(big));
assert_eq!((rec.steps.len(), rec.dropped), (1, 1), "the second does not fit beside the first");
rec.push(step(1));
assert_eq!((rec.steps.len(), rec.dropped), (1, 2), "a small step after a dropped one is dropped too");
let mut rec = crate::record::Recorder::default();
rec.push(step(crate::record::RECORD_MAX_BYTES + 1));
assert_eq!((rec.steps.len(), rec.dropped), (0, 1), "a step larger than the whole budget is not kept");
}
#[test]
fn passwords_stay_out_of_the_recording_buffer() {
const PW: &str = "synthetic-pw-5521";
@@ -548,3 +844,35 @@ fn pasting_from_a_file_carries_its_trust() {
assert_eq!(Trust::FromFile.receive(Trust::Local), Trust::FromFile);
assert_eq!(Trust::Local.receive(Trust::Local), Trust::Local);
}
#[test]
fn a_linked_paste_carries_the_files_trust_whatever_it_pastes() {
// Paste Link writes a formula into every cell whatever `what` says, so a linked paste of
// formats from a file carries the file's trust like one of formulas. Column widths and notes
// write no cells, linked or not.
let mut s = s();
open_json(&mut s, &gridcraft_model::Workbook::new());
set(&mut s, "A1", "=1+1");
s.execute("review.newComment", json!({"cell": "A1", "text": "theirs"})).unwrap();
let wide = gridcraft_model::LineInfo { size: Some(123.0), ..Default::default() };
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().cols.insert(0, wide);
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
let b1 = gridcraft_core::CellRef::parse("B1").unwrap();
let sheet = |s: &Session| s.doc().unwrap().wb.sheet(0).unwrap().clone();
for what in ["columnWidths", "comments"] {
s.new_workbook();
s.execute("edit.pasteSpecial", json!({"what": what, "link": true, "at": "B1"})).unwrap();
assert_eq!(trust(&s), Trust::Local, "{what}");
let sh = sheet(&s);
assert!(sh.cell(b1).is_none(), "a linked paste of {what} writes no cell");
if what == "comments" {
assert_eq!(sh.comments.get(&b1).map(|c| c.text.as_str()), Some("theirs"));
} else {
assert_eq!(sh.cols.get(&1).and_then(|c| c.size), Some(123.0));
}
}
s.new_workbook();
s.execute("edit.pasteSpecial", json!({"what": "formats", "link": true, "at": "B1"})).unwrap();
assert!(sheet(&s).cell(b1).is_some_and(|c| c.formula.is_some()), "a linked paste of formats writes a formula");
assert_eq!(trust(&s), Trust::FromFile);
}