Engine: document trust for workbooks opened from files

Active content such as macros must not run only because a workbook was
opened. DocState gets a trust state: Local for workbooks made in the
session (new workbooks and samples) and FromFile for anything opened
with file.open. Active content in a FromFile workbook asks before it
runs. A second window on a workbook shares its trust.

The clipboard carries the trust of its source document. Pasting
formulas, formats (conditional formats hold formulas) or validation
from a FromFile workbook into another workbook makes that workbook
FromFile too; pasting values, column widths or notes carries nothing
active and changes nothing. Sheet moves and copies stay inside one
workbook, so its trust is unchanged. document.inspect reports the trust.
This commit is contained in:
prxnvt
2026-10-21 14:05:48 -04:00
parent d4f1e61c99
commit b7bce3bc09
6 changed files with 108 additions and 3 deletions
+7 -1
View File
@@ -676,7 +676,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
// 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<Json> {
*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;
}
+1
View File
@@ -782,6 +782,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.trust = d.trust;
let i = s.add_document(copy);
Ok(json!({"index": i}))
}
+2
View File
@@ -80,6 +80,8 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
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();
+2 -1
View File
@@ -7,7 +7,7 @@ use super::*;
pub fn specs() -> Vec<CommandSpec> {
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<Json> {
"title": d.display_title(),
"path": d.path,
"dirty": d.is_dirty(),
"trust": d.trust,
"activeSheet": wb.active_sheet,
"sheets": sheets,
"selection": d.selection.a1(),
+30
View File
@@ -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<RangeRef>,
/// 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<io::FileKind>,
/// 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 {
+66 -1
View File
@@ -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<String> {
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);
}