diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 77fbb1d7a..1148a6b6e 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -459,8 +459,11 @@ pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000; pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet."; /// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or -/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or -/// deleting a sheet isn't an edit of its cells. +/// formula), or that unlocked it, whichever command made it. Sheets are matched by name, so +/// moving, renaming or deleting a sheet isn't an edit of its cells. A sort or duplicate removal +/// that moves an unlocked cell with equal content onto a locked one changes no content but would +/// leave the cell editable, so the effective lock (row and column formats included) is compared +/// as well. fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> { for sh in &old.sheets { if !sh.is_protected() { @@ -471,7 +474,8 @@ fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String) continue; } for c in sh.cells.diff(&after.cells) { - if !same_content(sh.cell(c), after.cell(c)) + let unlocked_now = !new.styles.get(after.style_id(c)).protection.locked; + if (unlocked_now || !same_content(sh.cell(c), after.cell(c))) && old.styles.get(sh.style_id(c)).protection.locked && let Some(m) = locked_cell_error(sh, c, unlocked) { diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 69e04dae6..95a0c93f5 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -398,10 +398,10 @@ fn move_columns(s: &mut Session, p: &Json) -> Result { { return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into())); } - // The checks must authorize the sheet this command modifies (`target_sheet`), not whatever - // sheet is active: both carry the command's own `sheet` parameter on. - check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": p.get("sheet")}), Axis::Cols, before, count, true)?; - check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": p.get("sheet")}), Axis::Cols, from, count, false)?; + // The checks must authorize the sheet this command modifies, not whatever sheet is active: + // both get the index `target_sheet` resolved (from `sheet`, or a `Sheet!` range or cell). + check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": sheet}), Axis::Cols, before, count, true)?; + check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": sheet}), Axis::Cols, from, count, false)?; let moved_from = if before <= from { from + count } else { from }; if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 94a80f824..5f92718ba 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5297,6 +5297,42 @@ fn move_columns_checks_the_protection_of_the_named_sheet() { assert_eq!(v(&s, "A1"), Value::text("A")); } +#[test] +fn move_columns_checks_the_protection_of_a_sheet_named_by_its_range() { + // A `Sheet!` range or cell picks the sheet the move modifies; the protection checks must + // authorize that sheet too, not the active one. + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "P"})).unwrap(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + for key in ["range", "cell"] { + let err = s.execute("sheet.moveColumns", json!({key: "P!A1", "cols": "B:C", "before": 4})).expect_err("moving columns on a protected sheet"); + assert!(err.to_string().contains("protected"), "{err}"); + } + let d = s.doc().unwrap(); + let sh = &d.wb.sheets[d.wb.sheet_index("P").unwrap()]; + let pv: Vec = (0..4).map(|c| sh.value(CellRef::new(0, c))).collect(); + assert_eq!(pv, vec![Value::number(1.0), Value::number(2.0), Value::number(3.0), Value::number(4.0)]); +} + +#[test] +fn sorting_cannot_unlock_a_locked_cell_by_moving_equal_content_onto_it() { + // An unlocked 2 sorted onto a locked 2 leaves the content as it was, but would make the cell + // editable; the sort is refused instead. + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[2], [2], [1]]})).unwrap(); + s.execute("home.lockCell", json!({"range": "A1", "on": false})).unwrap(); + s.execute("home.lockCell", json!({"range": "A3", "on": false})).unwrap(); + s.execute("review.protectSheet", json!({"sort": true})).unwrap(); + let before = s.doc().unwrap().wb.clone(); + let err = + s.execute("data.sort", json!({"range": "A1:A3", "header": false, "keys": [{"column": "A", "order": "asc"}]})).expect_err("sort unlocking A2"); + assert!(err.to_string().contains("protected sheet"), "{err}"); + assert_eq!(*s.doc().unwrap().wb, *before); + assert!(s.execute("cell.set", json!({"cell": "A2", "input": "9"})).is_err(), "A2 stays locked"); +} + #[test] fn a_script_cannot_switch_windows() { // Stored scripts are scoped to their workbook: a step that activates another window would