Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
54c0864
Engine: macro.run and macro.check: input checks, footprint dry run, p…
prxnvt Oct 12, 2026
23c0107
Engine: macro policy checks a step's parameters after tokens resolve;…
prxnvt Oct 21, 2026
d305566
Docs: running and checking macros
prxnvt Oct 21, 2026
4c6962a
Engine: a partial macro footprint no longer clears later writes; the …
prxnvt Oct 21, 2026
bf456c9
Engine: a macro's footprint dry runs keep to the run's timeLimit
prxnvt Oct 21, 2026
b51d588
Engine: macros can't open, hide or show windows
prxnvt Oct 21, 2026
06bbcb5
Engine: macro approvals are keyed on the macro's canonical JSON, not …
prxnvt Oct 21, 2026
f1a46a2
Docs: the macro policy summary lists the file commands that do run
prxnvt Oct 21, 2026
c0079aa
Docs: a failed macro run rolls back workbook changes; a file it alrea…
prxnvt Oct 21, 2026
38ad2db
Merge main into #2144
prxnvt Oct 22, 2026
0df540f
Merge #2151 (44a2f4e2, with #465 at 673af7ec, #462 at 712bb766 and ma…
prxnvt Oct 24, 2026
74ff8ac
Engine: macro runs refuse a shift that moves an input, stay clean whe…
prxnvt Oct 24, 2026
a626705
Engine: a dry run puts the journal back even after the journal trimme…
prxnvt Oct 24, 2026
879b96f
Docs: the footprint dry run is skipped when the conflict rule is over…
prxnvt Oct 24, 2026
2215934
Engine: a shift is refused when the footprint cannot follow every write
prxnvt Oct 24, 2026
9331b05
Merge #2151 (9fdf03cf, with #465 at 3f8e6c28 and main at 6ead1fb5, wh…
prxnvt Oct 25, 2026
8ea4e27
Merge #2151 (63a29800, with main at 17b9f6ff) into #2144
prxnvt Oct 25, 2026
5860857
Engine: a macro run checks each step for writes over data; the check …
prxnvt Oct 25, 2026
ad79b07
Engine: a macro's inputs, output and Repeat ranges follow their cells…
prxnvt Oct 25, 2026
65cbcdd
Engine: a macro run names the files it kept, refuses parameters it wo…
prxnvt Oct 25, 2026
2cf5b80
Docs: the macro page's conflict rules wrap at the page's width
prxnvt Oct 25, 2026
198fe48
Engine: the run's check of each step finds the inputs on their sheet …
prxnvt Oct 25, 2026
90a46da
Engine: macro.run's description lists only what it returns here
prxnvt Oct 25, 2026
fe14c0d
Merge #2151 (0aae1ffe) into #2144
prxnvt Oct 25, 2026
1a00ce1
Merge #2151 (0a929eaf) into #2144
prxnvt Oct 25, 2026
b9aa4a6
Merge #2151 (0533480c) into #2144
prxnvt Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/engine/src/cmd/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col }
};
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e);
cx.edits.push((src_sheet_name.clone(), e));
cx.structural = true;
}
let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1);
Expand Down
2 changes: 1 addition & 1 deletion crates/engine/src/cmd/insert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
shift_cell_features(sh, &e);
let name = sh.name.clone();
rewrite(&mut cx.wb, &name, &e);
rewrite(cx, &name, &e);
let sh = cx.sheet_mut(sheet)?;
for (i, c) in (r.start.col..=r.end.col).enumerate() {
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
Expand Down
27 changes: 27 additions & 0 deletions crates/engine/src/cmd/macros.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//! Macros: typed command lists that run as one undo step. See [`crate::macros`] for the model,
//! tokens, runner and policy. Automate scripts (`automate.*`) are a separate, older feature.

use serde_json::Value as Json;

use super::*;

pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"macro.run",
"Run Macro",
[],
None,
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight (a shift stops the run as fail does), overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?}",
has_doc,
run
)]
}

fn name_param<'a>(cmd: &str, p: &'a Json) -> Result<&'a str> {
str_param(p, "name").filter(|n| !n.trim().is_empty()).ok_or_else(|| bad(cmd, "missing `name`"))
}

fn run(s: &mut Session, p: &Json) -> Result<Json> {
let name = name_param("macro.run", p)?;
crate::macros::run::run(s, name, p)
}
20 changes: 18 additions & 2 deletions crates/engine/src/cmd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub mod format;
pub mod formulas;
pub mod insert;
pub mod inspect;
pub mod macros;
pub mod pivot;
pub mod print;
pub mod review;
Expand Down Expand Up @@ -143,6 +144,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(view::specs());
v.extend(inspect::specs());
v.extend(extra::specs());
v.extend(macros::specs());
v.extend(draw::specs());
v.extend(spelling::specs());
v.extend(solver::specs());
Expand Down Expand Up @@ -338,6 +340,9 @@ pub struct Ctx<'a> {
/// The command applied sheet protection itself (row/column inserts and deletes the
/// protection allows), so cells it shifts aren't refused as edits of locked cells.
pub protection_checked: bool,
/// The structural edits the command made, in order (the sheet's name, the edit), as
/// references were rewritten for them.
pub edits: Vec<(String, gridcraft_formula::adjust::Edit)>,
}

pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
Expand All @@ -355,15 +360,26 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
#[allow(clippy::disallowed_methods)]
let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| std::time::Instant::now()) };
let mut sel = d.selection.clone();
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false };
let mut ctx = Ctx {
wb: (*d.wb).clone(),
changed: Vec::new(),
structural: false,
sel: &mut sel,
fit_rows: Vec::new(),
protection_checked: false,
edits: Vec::new(),
};
let r = f(&mut ctx)?;
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx;
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx;
if !protection_checked {
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
}
if !structural {
check_arrays(&d.wb, &wb)?;
}
if let Some(log) = d.edits.as_mut() {
log.extend(edits);
}
if changed.len() <= 200_000 {
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
}
Expand Down
18 changes: 10 additions & 8 deletions crates/engine/src/cmd/sheet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,9 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
}

/// Rewrites every formula reference (and chart series) for a structural edit on `target`.
pub(super) fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) {
cx.edits.push((target.to_string(), e.clone()));
let wb = &mut cx.wb;
super::edit::rewrite_all_formulas(wb, target, e);
for si in 0..wb.sheets.len() {
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
Expand Down Expand Up @@ -307,7 +309,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
}
}
}
rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count });
rewrite(cx, &name, &Edit::Insert { axis, at, count });
cx.structural = true;
Ok(Json::Null)
})?;
Expand Down Expand Up @@ -372,7 +374,7 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
}
shift_sheet_features(sh, axis, at, count, false);
}
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
rewrite(cx, &name, &Edit::Delete { axis, at, count });
}
cx.structural = true;
Ok(Json::Null)
Expand Down Expand Up @@ -424,7 +426,7 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
sh.cells.shift_cols(before, count as i64);
shift_sheet_features(sh, Axis::Cols, before, count, true);
}
rewrite(&mut cx.wb, &name, &Edit::Insert { axis: Axis::Cols, at: before, count });
rewrite(cx, &name, &Edit::Insert { axis: Axis::Cols, at: before, count });

let mut touched = Vec::new();
{
Expand Down Expand Up @@ -462,14 +464,14 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
for cell in touched {
cx.touch(sheet, cell);
}
rewrite(&mut cx.wb, &name, &Edit::Move { from: source, to_row: 0, to_col: destination });
rewrite(cx, &name, &Edit::Move { from: source, to_row: 0, to_col: destination });

{
let sh = cx.sheet_mut(sheet)?;
sh.cells.shift_cols(delete_at + count, -(count as i64));
shift_sheet_features(sh, Axis::Cols, delete_at, count, false);
}
rewrite(&mut cx.wb, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count });
rewrite(cx, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count });
*cx.sel = crate::Selection {
active: CellRef::new(active_row, final_start),
anchor: CellRef::new(active_row, final_start),
Expand Down Expand Up @@ -514,7 +516,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
}
shift_cell_features(sh, &e);
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
rewrite(&mut cx.wb, &name, &e);
rewrite(cx, &name, &e);
cx.structural = true;
Ok(Json::Null)
})?;
Expand Down Expand Up @@ -549,7 +551,7 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
shift_cell_features(sh, &e);
}
// References into the deleted cells become #REF!; the rest move.
rewrite(&mut cx.wb, &name, &e);
rewrite(cx, &name, &e);
cx.structural = true;
Ok(Json::Null)
})?;
Expand Down
2 changes: 1 addition & 1 deletion crates/engine/src/io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,7 @@ impl Drop for ScriptSandbox {
/// 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")));
return Err(EngineError::Other(format!("{path}: scripts, and macro steps without the permission, can't read or write files")));
}
Ok(())
}
Expand Down
13 changes: 13 additions & 0 deletions crates/engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ pub enum EngineError {
NoDocument,
#[error("{0}")]
Other(String),
/// The command would replace something; run it again with `overwrite: true` to go ahead.
#[error("confirm: {0} (run again with `overwrite: true`)")]
Confirm(String),
/// Active content (a macro) asks to run; run it again with `approve: true` to allow it.
#[error("approve: {0} (run again with `approve: true`)")]
Approve(String),
#[error("internal error in `{0}` (the workbook was kept as it was): {1}")]
Internal(String, String),
}
Expand Down Expand Up @@ -203,6 +209,9 @@ pub struct DocState {
/// workbook is opened from a file, carried by content pasted from such a workbook, and never
/// changed by a trust decision.
pub trust: Trust,
/// While a macro step runs: the structural edits its command made (the sheet's name, the
/// edit), so the macro's inputs, output and Repeat ranges follow their cells as formulas do.
pub edits: Option<Vec<(String, gridcraft_formula::adjust::Edit)>>,
/// The references `edit.goTo` with `record: true` went to in this document, newest first,
/// without names, each area written with its sheet (`Sheet1!B14:C22,Sheet1!F19:G30`).
pub go_to_recent: Vec<String>,
Expand Down Expand Up @@ -296,6 +305,7 @@ impl DocState {
content_kind: None,
scripts_trusted,
trust,
edits: None,
go_to_recent: vec![],
}
}
Expand Down Expand Up @@ -445,6 +455,9 @@ pub struct Session {
pub recorder: Option<record::Recorder>,
/// How many `execute` calls are running (0 between top-level commands).
depth: u32,
/// Macros the person allowed to run in this session: (document uid, the macro's content as
/// canonical JSON, from [`macros::fingerprint`]).
pub macro_approvals: std::collections::HashSet<(u64, String)>,
/// Windows hidden with View › Hide.
pub hidden_windows: Vec<usize>,
/// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width.
Expand Down
9 changes: 9 additions & 0 deletions crates/engine/src/macros/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ use serde_json::{Value as Json, json};

use crate::cmd::stash;

pub mod policy;
pub mod resolve;
pub mod run;

/// The hidden-data key prefix of a stored macro (`macro_<n>`).
const MACRO_KEY: &str = "macro_";
Expand Down Expand Up @@ -96,6 +98,13 @@ pub fn find(wb: &Workbook, name: &str) -> Option<usize> {
wb.macros.iter().position(|m| m.name.eq_ignore_ascii_case(name))
}

/// A macro's content as canonical JSON text (object keys sorted): approval to run a macro holds
/// until the macro changes. It is the text itself rather than a hash, so no two different macros
/// share it.
pub fn fingerprint(m: &Macro) -> String {
macro_json(m).to_string()
}

/// 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. The macros keep their order; the index `n`
/// is this build's choice, so it steps around any a person's own visible name already uses.
Expand Down
141 changes: 141 additions & 0 deletions crates/engine/src/macros/policy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
//! What a macro step may run. Macros run commands, so this keeps a macro from saving, opening or
//! closing files, driving the app, running other macros, or reading and writing files without the
//! person knowing. It goes by command id and the `path` parameter; the runner also runs every
//! step except an approved file step inside the scripts' file sandbox, so a command that touches a
//! file under another parameter can't either.

use serde_json::Value as Json;

use crate::Trust;

/// Whether a command may run as a macro step.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Policy {
Allow,
/// Allowed once the person approves the macro; the text says why it asks ("came with a
/// workbook you opened").
Ask(String),
/// Reads or writes files: the macro must list the command in its permissions, and the person
/// approves the macro before it runs. The text says what it does ("reads a file").
NeedsPermission(String),
/// Never runs in a macro; the text says why.
Deny(String),
}

/// Prefixes of commands that never run in a macro, with the reason.
const DENIED_PREFIXES: &[(&str, &str)] = &[
("file.", "macros don't open, save, close or create workbooks"),
("app.", "macros don't change the app"),
("window.", "macros don't switch windows"),
("automate.", "macros don't record or run scripts or other macros"),
("macro.", "macros don't record or run other macros"),
];

/// Single commands that never run in a macro.
const DENIED: &[(&str, &str)] = &[
("edit.undo", "a macro is undone as one step after it runs"),
("edit.redo", "a macro is undone as one step after it runs"),
("view.newWindow", "macros don't open, hide or show windows"),
("view.hideWindow", "macros don't open, hide or show windows"),
("view.unhideWindow", "macros don't open, hide or show windows"),
];

/// Commands under a denied prefix that only change the workbook.
const ALLOWED: &[&str] = &["file.properties"];

/// Commands that read a file when given a `path`.
const READS_FILES: &[&str] = &["data.getData", "data.fromTextCsv", "insert.picture", "pageLayout.background"];

/// File commands that only produce bytes unless given a `path` (then they write a file).
const EXPORTS: &[&str] = &["file.exportPdf", "file.exportCsv", "file.exportHtml", "file.print", "file.printPreview", "file.saveBytes"];

/// Whether command `id` with `params` writes a file (an export or print given a `path`).
pub fn writes_file(id: &str, params: &Json) -> bool {
EXPORTS.contains(&id) && params.get("path").is_some_and(|p| !p.is_null())
}

/// The policy for running command `id` with `params` as a macro step in a document with `trust`.
///
/// Any `path` other than `null` counts as touching a file: a token such as `{"$ref": "Input"}`
/// becomes a path when the step runs, and exports write to `""` and `" "` as file names. The
/// runner checks the parameters both as written and as resolved.
pub fn macro_policy(id: &str, params: &Json, trust: Trust) -> Policy {
let has_path = params.get("path").is_some_and(|p| !p.is_null());
let base = if EXPORTS.contains(&id) {
if has_path { Policy::NeedsPermission("writes a file".into()) } else { Policy::Allow }
} else if ALLOWED.contains(&id) {
Policy::Allow
} else if let Some((_, why)) = DENIED.iter().find(|(d, _)| *d == id) {
Policy::Deny((*why).into())
} else if let Some((_, why)) = DENIED_PREFIXES.iter().find(|(p, _)| id.starts_with(p)) {
Policy::Deny((*why).into())
} else if READS_FILES.contains(&id) && has_path {
Policy::NeedsPermission("reads a file".into())
} else {
Policy::Allow
};
match base {
Policy::Allow if !trust.allows_code() => Policy::Ask("came with a workbook you opened".into()),
other => other,
}
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

#[test]
fn the_deny_and_permission_lists() {
let none = json!({});
let path = json!({"path": "/tmp/x"});
for id in [
"file.saveAs",
"file.save",
"file.open",
"file.close",
"file.new",
"app.commands",
"window.activate",
"automate.runScript",
"automate.recordActions",
"macro.run",
"macro.record",
"edit.undo",
"edit.redo",
"view.newWindow",
"view.hideWindow",
"view.unhideWindow",
] {
assert!(matches!(macro_policy(id, &none, Trust::Local), Policy::Deny(_)), "{id}");
assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::Deny(_)), "{id}");
}
for id in ["data.getData", "data.fromTextCsv", "insert.picture", "pageLayout.background", "file.exportPdf", "file.print", "file.exportCsv"] {
assert!(matches!(macro_policy(id, &path, Trust::Local), Policy::NeedsPermission(_)), "{id}");
assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::NeedsPermission(_)), "{id}");
}
// Without a path they touch no file.
for id in ["data.getData", "insert.picture", "file.exportPdf", "file.saveBytes", "file.printPreview"] {
assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}");
assert_eq!(macro_policy(id, &json!({"path": null}), Trust::Local), Policy::Allow, "{id}");
}
// A path that is blank or a token still touches a file once the step runs.
for path in [json!(""), json!(" "), json!({"$ref": "Input"}), json!("{{Input}}.csv"), json!(1)] {
for id in ["file.exportCsv", "data.fromTextCsv"] {
let p = json!({ "path": path });
assert!(matches!(macro_policy(id, &p, Trust::Local), Policy::NeedsPermission(_)), "{id} {p}");
}
}
for id in ["cell.set", "home.bold", "view.macros", "edit.copy", "file.properties"] {
assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}");
assert!(matches!(macro_policy(id, &none, Trust::FromFile), Policy::Ask(_)), "{id}");
}
}

#[test]
fn every_listed_command_exists() {
for id in ALLOWED.iter().chain(READS_FILES).chain(EXPORTS).chain(DENIED.iter().map(|(id, _)| id)).chain(&["data.solver"]) {
assert!(crate::find_command(id).is_some(), "{id}");
}
}
}
Loading
Loading