From 22c5dfe0df2cea2e2fceb1189c75fcfa77f2b587 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:56:06 -0500 Subject: [PATCH] engine: protected sheets gate filter reapply, outline, line sizes and floating pictures; pivot destination resolved before the structure check; user-limited edit ranges stay locked (#3296) --- crates/engine/src/cmd.rs | 8 +++- crates/engine/src/cmd/data/filter.rs | 2 + crates/engine/src/cmd/data/grouping.rs | 17 ++++++++ crates/engine/src/cmd/format/sizes.rs | 11 +++++ crates/engine/src/cmd/insert.rs | 4 ++ crates/engine/src/cmd/pivot/create.rs | 7 +-- crates/engine/src/tests.rs | 59 ++++++++++++++++++++++++++ 7 files changed, 103 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 1148a6b6e..02f6dabca 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -503,10 +503,14 @@ pub(crate) fn refuse_unless_allowed(s: &Session, sheet: usize, what: &str, pick: pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option { let mut needs_password = None; for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) { - if r.password.is_none() || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) { + // A range limited to Windows users or groups (a security descriptor) can't be checked + // here, so without a password it stays locked rather than opening to every editor. + if (r.password.is_none() && r.security_descriptor.is_none()) || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) { return None; } - needs_password.get_or_insert(r.title.as_str()); + if r.password.is_some() { + needs_password.get_or_insert(r.title.as_str()); + } } Some(match needs_password { Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."), diff --git a/crates/engine/src/cmd/data/filter.rs b/crates/engine/src/cmd/data/filter.rs index 1b52e4537..437bd7887 100644 --- a/crates/engine/src/cmd/data/filter.rs +++ b/crates/engine/src/cmd/data/filter.rs @@ -270,6 +270,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result { let sheet = s.doc()?.wb.active_sheet(); + crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; if let Some(a) = sh.autofilter.as_mut() { @@ -283,6 +284,7 @@ pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result { pub(super) fn reapply(s: &mut Session, _: &Json) -> Result { let sheet = s.doc()?.wb.active_sheet(); + crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; edit(s, |cx| { apply_filter(&mut cx.wb, sheet); cx.structural = true; diff --git a/crates/engine/src/cmd/data/grouping.rs b/crates/engine/src/cmd/data/grouping.rs index d2e183ae1..03761b454 100644 --- a/crates/engine/src/cmd/data/grouping.rs +++ b/crates/engine/src/cmd/data/grouping.rs @@ -21,6 +21,15 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { } } +/// Outline commands change line visibility and grouping: a protected sheet must allow formatting that axis. +fn refuse_unless_outline_allowed(s: &Session, sheet: usize, rows: bool) -> Result<()> { + if rows { + crate::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows) + } else { + crate::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns) + } +} + pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result { let si = target_sheet(s, p)?; // The selection belongs to the active sheet, so another sheet needs its lines. @@ -28,6 +37,7 @@ pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result { return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet")); } let (rows, a, b) = outline_lines(s, p)?; + refuse_unless_outline_allowed(s, si, rows)?; edit(s, |cx| { let sh = cx.sheet_mut(si)?; let before = outline::summary_before(sh, rows); @@ -58,6 +68,7 @@ pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result { /// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed. pub(super) fn detail(s: &mut Session, p: &Json, hide: bool) -> Result { let (rows, a, b) = outline_lines(s, p)?; + refuse_unless_outline_allowed(s, s.doc()?.wb.active_sheet(), rows)?; let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8); edit(s, |cx| { let si = cx.sheet_index(); @@ -123,6 +134,12 @@ pub(super) fn show_levels(s: &mut Session, p: &Json) -> Result { msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(), }); } + let active = s.doc()?.wb.active_sheet(); + for (n, is_rows) in [(rows, true), (cols, false)] { + if n.is_some() { + refuse_unless_outline_allowed(s, active, is_rows)?; + } + } edit(s, |cx| { let si = cx.sheet_index(); let sh = cx.sheet_mut(si)?; diff --git a/crates/engine/src/cmd/format/sizes.rs b/crates/engine/src/cmd/format/sizes.rs index 3335d6100..c79327678 100644 --- a/crates/engine/src/cmd/format/sizes.rs +++ b/crates/engine/src/cmd/format/sizes.rs @@ -9,6 +9,17 @@ use super::{ /// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active /// sheet, so another sheet needs explicit lines. fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> { + let (sheet, lines) = resolve_lines(s, p, rows)?; + // Sizes and visibility are formatting of the line, which a protected sheet must allow. + if rows { + crate::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows)?; + } else { + crate::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns)?; + } + Ok((sheet, lines)) +} + +fn resolve_lines(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> { let key = if rows { "rows" } else { "cols" }; let mut sheet = target_sheet(s, p)?; if let Some(t) = str_param(p, key) { diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index b713061c2..56e066383 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1100,6 +1100,10 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result { .unwrap_or_else(|| if data.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png".into() } else { "image/jpeg".into() }); let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240)); let d = s.doc()?; + // Sheet protection locks drawing objects, as for a text box's text. + if d.wb.active().is_some_and(|sh| sh.is_protected()) { + return Err(EngineError::Other(PROTECTED.into())); + } let id = d.wb.next_object_id(); let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); let scale = (640.0 / w.max(1) as f32).min(1.0); diff --git a/crates/engine/src/cmd/pivot/create.rs b/crates/engine/src/cmd/pivot/create.rs index ce5f97257..0cec82b54 100644 --- a/crates/engine/src/cmd/pivot/create.rs +++ b/crates/engine/src/cmd/pivot/create.rs @@ -67,9 +67,6 @@ fn field_list(v: Option<&Json>) -> Vec { } pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result { - if s.doc()?.wb.protected_structure && matches!(str_param(p, "destination"), None | Some("new")) { - return Err(other("The workbook is protected and cannot be changed.")); - } let source = source_param(s, p)?; let active = s.doc()?.wb.active_sheet(); // Validate the source now (header names, size). @@ -98,6 +95,10 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result { let at = RangeRef::parse(body).map(|r| r.start).ok_or_else(|| bad("insert.pivotTable", format!("not a cell: `{dest}`")))?; Some((si, at)) }; + // Decided on the resolved destination: `New`, ` new ` and an empty one all make a sheet. + if target.is_none() && s.doc()?.wb.protected_structure { + return Err(other("The workbook is protected and cannot be changed.")); + } let replace = bool_param(p, "replace").unwrap_or(false); let rows = field_list(p.get("rows")); let columns = field_list(p.get("columns")); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dd7488f19..75e5eac96 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5590,3 +5590,62 @@ fn clearing_links_and_notes_keeps_allow_edit_ranges() { let sh = s.doc().unwrap().wb.active().unwrap().clone(); assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty()); } + +#[test] +fn protected_sheet_refuses_layout_commands_its_permissions_do_not_allow() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["h"], [1], [2]]})).unwrap(); + s.execute("data.filter", json!({"range": "A1:A3"})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + for (id, params) in [ + ("home.rowHeight", json!({"rows": "1:1", "height": 40})), + ("home.columnWidth", json!({"cols": "A:A", "width": 80})), + ("data.group", json!({"rows": "2:3"})), + ("data.showLevels", json!({"rows": 1})), + ("data.clearFilter", json!({})), + ("data.reapply", json!({})), + ("insert.picture", json!({"base64": "iVBORw0KGgo="})), + ] { + let err = s.execute(id, params).expect_err(id).to_string(); + assert!(err.contains("protected"), "{id}: {err}"); + } + // With the permissions ticked the same layout commands run. + s.execute("review.unprotectSheet", json!({})).unwrap(); + s.execute("review.protectSheet", json!({"formatRows": true, "formatColumns": true, "autofilter": true})).unwrap(); + for (id, params) in [ + ("home.rowHeight", json!({"rows": "1:1", "height": 40})), + ("home.columnWidth", json!({"cols": "A:A", "width": 80})), + ("data.reapply", json!({})), + ] { + s.execute(id, params).unwrap_or_else(|e| panic!("{id}: {e}")); + } +} + +#[test] +fn an_edit_range_limited_to_windows_users_stays_locked() { + use gridcraft_model::EditRange; + let mut s = s(); + let a1 = CellRef::parse("A1").unwrap(); + let range = |sd: Option<&str>| EditRange { + title: "T".into(), + ranges: vec![gridcraft_core::RangeRef::cell(a1)], + password: None, + security_descriptor: sd.map(str::to_string), + }; + let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + sh.edit_ranges.push(range(None)); + assert!(crate::cmd::locked_cell_error(sh, a1, &[]).is_none(), "an open range"); + sh.edit_ranges = vec![range(Some("D:(A;;GA;;;WD)"))]; + assert!(crate::cmd::locked_cell_error(sh, a1, &[]).is_some(), "a user-limited range"); +} + +#[test] +fn pivot_new_sheet_destination_respects_workbook_structure_protection() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["N", 1], ["S", 2]]})).unwrap(); + s.execute("review.protectWorkbook", json!({"on": true})).unwrap(); + for dest in ["new", "NEW", " new ", ""] { + assert!(s.execute("insert.pivotTable", json!({"source": "A1:B3", "destination": dest})).is_err(), "destination {dest:?}"); + } + assert!(s.execute("insert.pivotTable", json!({"source": "A1:B3", "location": "New"})).is_err()); +}