diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fe7f1fe8b..496ca811a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -676,7 +676,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and // scripts get it only when they ask for it. let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None }; - let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected }; + let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected, trust: d.trust }; s.clipboard = Some(clip); let mut result = json!({"range": range.a1(), "text": text}); if let Some(html) = html { @@ -1183,6 +1183,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; Ok(()) })?; + // Formulas, formats (conditional formats hold formulas) and validation from an untrusted file + // leave the destination untrusted; plain values, column widths and notes carry nothing active. + if !same_doc && !matches!(what, What::Values | What::ValuesAndNumberFormats | What::ColumnWidths | What::Comments) { + let d = s.doc_mut()?; + d.trust = d.trust.receive(clip.trust); + } if cut_move { s.clipboard = None; } diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 01e2ac6e6..6d6516f50 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -782,6 +782,7 @@ fn new_window(s: &mut Session, _: &Json) -> Result { 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.trust = d.trust; let i = s.add_document(copy); Ok(json!({"index": i})) } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 9ddc05611..b4dec84c1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -80,6 +80,8 @@ fn open(s: &mut Session, p: &Json) -> Result { if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx { doc.content_kind = Some(crate::io::FileKind::Xlsx); } + // Someone else may have made the file: its active content asks before it runs. + doc.trust = crate::Trust::FromFile; let i = s.add_document(doc); // The decrypted-workbook notice is for the info bar, not a message box. let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::io::DECRYPTED_NOTICE).collect(); diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 366d8e6e6..de65ac206 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -7,7 +7,7 @@ use super::*; pub fn specs() -> Vec { vec![ - cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, history", has_doc, inspect), + cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, trust (local|fromFile), history", has_doc, inspect), cmd!(query "sheet.read", "Read Range", [], None, "{range?: \"A1:D10\" (default: used range), sheet?, formulas?: bool, formatted?: bool} → rows of values", has_doc, read), cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link", has_doc, cell_get), cmd!(query "selection.stats", "Selection Statistics", [], None, "{} → count, sum, average, min, max", has_doc, sel_stats), @@ -63,6 +63,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result { "title": d.display_title(), "path": d.path, "dirty": d.is_dirty(), + "trust": d.trust, "activeSheet": wb.active_sheet, "sheets": sheets, "selection": d.selection.a1(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 03e710a45..b57c474a1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -112,6 +112,33 @@ pub struct Clipboard { /// A multi-area copy: the areas as selected (for the marquee). `wb` then holds them slid /// together into one block at `range`. Empty for a single-range copy. pub areas: Vec, + /// Trust of the source document: pasting content from a file someone else made carries it. + pub trust: Trust, +} + +/// Whether a document's active content (macros) may run without asking the person first. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Trust { + /// Made in this session: a new workbook or a sample. + #[default] + Local, + /// Opened from a file, or holding content pasted from one: active content asks before it + /// runs. + FromFile, +} + +impl Trust { + /// Whether active content may run without asking. + pub fn allows_code(self) -> bool { + self == Trust::Local + } + + /// The trust of a document after it receives content from a document with trust `source`: + /// content from an untrusted file leaves the receiver untrusted. + pub fn receive(self, source: Trust) -> Trust { + if source == Trust::FromFile { Trust::FromFile } else { self } + } } /// In-cell edit mode as shown in the status bar. @@ -143,6 +170,8 @@ 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, + /// Whether the workbook's active content may run without asking. + pub trust: Trust, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -168,6 +197,7 @@ impl DocState { sheet_selections: vec![], unlocked_ranges: vec![], content_kind: None, + trust: Trust::Local, } } pub fn is_dirty(&self) -> bool { diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 005d700a6..55c35d76c 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -4,7 +4,7 @@ use serde_json::json; use super::{s, v}; -use crate::{EngineError, Session}; +use crate::{EngineError, Session, Trust}; fn undo_labels(s: &Session) -> Vec { s.doc().unwrap().undo.iter().map(|e| e.label.clone()).collect() @@ -293,3 +293,68 @@ fn record_actions_is_unchanged_by_the_buffer() { assert_eq!(r["steps"], 1); assert!(s.recorder.is_none()); } + +// ---------------------------------------------------------------- trust + +fn open_json(s: &mut Session, wb: &gridcraft_model::Workbook) { + let bytes = serde_json::to_vec(wb).unwrap(); + s.execute("file.open", json!({"name": "theirs.json", "base64": crate::io::base64_encode(&bytes)})).unwrap(); +} + +fn trust(s: &Session) -> Trust { + s.doc().unwrap().trust +} + +#[test] +fn documents_know_where_they_came_from() { + let mut s = s(); + assert_eq!(trust(&s), Trust::Local); + assert!(Trust::Local.allows_code() && !Trust::FromFile.allows_code()); + s.execute("file.new", json!({"sample": "sales"})).unwrap(); + assert_eq!(trust(&s), Trust::Local); + assert_eq!(s.execute("document.inspect", json!({})).unwrap()["trust"], "local"); + open_json(&mut s, &gridcraft_model::Workbook::new()); + assert_eq!(trust(&s), Trust::FromFile); + assert_eq!(s.execute("document.inspect", json!({})).unwrap()["trust"], "fromFile"); + // A second window on the same workbook is the same workbook. + s.execute("view.newWindow", json!({})).unwrap(); + assert_eq!(trust(&s), Trust::FromFile); + // Moving and copying sheets inside it changes nothing. + s.execute("sheet.move", json!({"to": 0, "copy": true})).unwrap(); + assert_eq!(trust(&s), Trust::FromFile); +} + +#[test] +fn pasting_from_a_file_carries_its_trust() { + let mut s = s(); // document 0: Local + open_json(&mut s, &gridcraft_model::Workbook::new()); // document 1: FromFile + let theirs = s.active_index(); + set(&mut s, "A1", "=1+1"); + s.execute("edit.copy", json!({"range": "A1"})).unwrap(); + s.new_workbook(); + let mine = s.active_index(); + // Values carry nothing that runs. + s.execute("edit.pasteSpecial", json!({"what": "values", "at": "B1"})).unwrap(); + assert_eq!(trust(&s), Trust::Local); + s.execute("edit.paste", json!({"at": "C1"})).unwrap(); + assert_eq!(trust(&s), Trust::FromFile); + assert_eq!(v(&s, "C1").display(), "2"); + + // Pasting within a workbook changes nothing, and content from a local workbook leaves the + // destination as it was. + s.set_active(theirs); + s.execute("edit.paste", json!({"at": "A3"})).unwrap(); + assert_eq!(trust(&s), Trust::FromFile, "pasting within a workbook"); + s.new_workbook(); + set(&mut s, "A1", "=3"); + s.execute("edit.copy", json!({"range": "A1"})).unwrap(); + s.set_active(mine); + s.execute("edit.paste", json!({"at": "D1"})).unwrap(); + assert_eq!(trust(&s), Trust::FromFile, "still untrusted from the earlier paste"); + s.new_workbook(); + s.execute("edit.paste", json!({"at": "D1"})).unwrap(); + assert_eq!(trust(&s), Trust::Local); + assert_eq!(Trust::Local.receive(Trust::FromFile), Trust::FromFile); + assert_eq!(Trust::FromFile.receive(Trust::Local), Trust::FromFile); + assert_eq!(Trust::Local.receive(Trust::Local), Trust::Local); +}