From bcd751a58808f3697116e04d6900f296ec6d9317 Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 13:00:21 +0100 Subject: [PATCH 01/13] Restrict Editing: a file command that changes the document is checked too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `always_allowed` waved through every `file.*` id, so on a protected document the file commands that do change it went unchecked: the privacy options and the properties wrote document settings, and the inspector's removals deleted content. The privacy options' own comment already said they were "refused on a protected document"; that wasn't true. The allow-list now names the file commands that don't change the open document, and the ones that do are checked like any other command. Reading the document stays allowed: the inspector without removals, the privacy settings, the info, and saving a version snapshot. Restoring a stored version replaces the whole document, and a snapshot's own protection is stale — restoring one lifted the protection and dropped its password. The live document's protection, password and formatting limit now survive a restore. Co-Authored-By: opencode --- ROADMAP.md | 2 + crates/engine/src/cmd/protect.rs | 74 ++++++++++++++++++++++++++++++-- crates/engine/src/cmd/tools.rs | 9 ++++ docs/file-format-parity.md | 3 +- docs/gaps.md | 1 + 5 files changed, 84 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5a9fbf09f..4d9971143 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,6 +141,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-20 | Restrict Editing holds for the file commands too: the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password instead of the snapshot's stale ones | | 2026-10-19 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. | | 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). | | 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). | @@ -506,6 +507,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Restrict Editing: a file command that changes the open document is checked like any other — the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version no longer lifts the protection or drops its password | | 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control | | 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised | | 2026-10-18 | trivial | Progress log: long navigation headings wrap within the pane instead of ending in an ellipsis (#3015) | diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index b77a6f9d6..57541444e 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -202,9 +202,23 @@ const CONTROL_INSERTS: [&str; 9] = [ "developer.repeatingSection", ]; -/// Commands always allowed in a protected document: protection itself, file commands, undo. -fn always_allowed(id: &str) -> bool { - id == "review.restrict" || id.starts_with("file.") || id == "edit.undo" || id == "edit.redo" +/// Commands always allowed in a protected document: protection itself, undo, and the file +/// commands that don't change the open document. A `file.*` command that does change it is +/// checked like any other, so the protection holds for it too: the privacy options and the +/// properties are document settings, and the inspector removes content (#1327 follow-up). +fn always_allowed(id: &str, params: &Value) -> bool { + if id == "review.restrict" || id == "edit.undo" || id == "edit.redo" { + return true; + } + if !id.starts_with("file.") { + return false; + } + match id { + "file.privacyOptions" | "file.properties" => false, + // The inspector reads the document; its removals change it. + "file.inspect" => params.get("remove").and_then(Value::as_array).is_none_or(|a| a.is_empty()), + _ => true, + } } /// What a protected document lets command `id` do before it runs: `Err` refuses it; `Ok(true)` @@ -212,7 +226,7 @@ fn always_allowed(id: &str) -> bool { /// [`outside_unchanged`]). pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result { let Some(mode) = s.doc.settings.protection.as_deref() else { return Ok(false) }; - if always_allowed(id) { + if always_allowed(id, params) { return Ok(false); } // Word blocks content-control property edits until Stop Protection: changing a control's @@ -687,6 +701,58 @@ mod tests { assert_eq!(s.doc, before); } + /// Restoring a stored version replaces the whole document, and a snapshot's own protection + /// is stale: the live document's protection, and its password, still hold after a restore + /// (#1327 follow-up). + #[test] + fn a_version_restore_keeps_the_protection() { + let mut s = Session::new(Document::from_text("Original text.")); + s.run("file.versions", &json!({"save": "before"})).unwrap(); + s.run("review.restrict", &json!({"mode": "readOnly", "password": "pw"})).unwrap(); + assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly")); + let hash = s.doc.settings.protect_hash.clone(); + assert!(!hash.is_empty(), "the password is set"); + + s.run("file.versions", &json!({"restore": 0})).unwrap(); + assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"), "the restore lifted the protection"); + assert_eq!(s.doc.settings.protect_hash, hash, "the restore dropped the password"); + // The document is still protected: an edit outside the exceptions is refused. + let before = s.doc.clone(); + assert!(s.run("text.insert", &json!({"text": "edited "})).is_err()); + assert_eq!(s.doc, before); + } + + /// A file command that changes the document is checked like any other: the privacy options + /// and the properties are document settings, and the inspector removes content (#1327 + /// follow-up). + #[test] + fn file_commands_that_change_the_document_are_protected() { + for (id, params) in [ + ("file.privacyOptions", json!({"removePersonalInfo": true})), + ("file.properties", json!({"title": "New title"})), + ("file.inspect", json!({"remove": ["comments"]})), + ] { + let mut s = Session::new(Document::from_text("Some text.")); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + let before = s.doc.clone(); + assert!(s.run(id, ¶ms).is_err(), "{id}: a protected document took the change"); + assert_eq!(s.doc, before, "{id}"); + } + } + + /// Reading the document stays allowed under protection: the inspector without removals, the + /// privacy settings, the info, and saving a version snapshot. + #[test] + fn file_commands_that_only_read_stay_allowed() { + let mut s = Session::new(Document::from_text("Some text.")); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + for (id, params) in + [("file.inspect", json!({})), ("file.privacy", json!({})), ("file.info", json!({})), ("file.versions", json!({"save": "snapshot"}))] + { + assert!(s.run(id, ¶ms).is_ok(), "{id}: a protected document refused a read"); + } + } + /// Read only, no exceptions: going to a header the document doesn't have doesn't create /// one, and loading or filtering recipients doesn't rewrite the merge fields' shown results /// (both used to change the document from a non-mutating command, #1327). diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index b02408dd2..c09a9c534 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -476,7 +476,16 @@ pub fn specs() -> Vec { if let Some(i) = p::u64(v, "restore") { let (_, _, d) = s.versions.get(i as usize).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?; s.checkpoint("Restore Version"); + // A snapshot's own protection is stale: it was taken at some earlier moment, and + // restoring it must not lift the protection the live document is under, nor + // its password (#1327 follow-up). + let protection = s.doc.settings.protection.clone(); + let protect_hash = s.doc.settings.protect_hash.clone(); + let protect_formatting = s.doc.settings.protect_formatting; s.doc = d; + s.doc.settings.protection = protection; + s.doc.settings.protect_hash = protect_hash; + s.doc.settings.protect_formatting = protect_formatting; s.touch(); s.clamp_selection(); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d68488edf..d5ad1cdd8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -83,7 +83,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. Textual `REF` fields with `\h` export internal PDF links without refreshing cached text; existing enclosing links take precedence. `wordcraft-cli convert --update-fields` explicitly runs field updates before saving; ordinary conversion preserves caches and locked fields stay unchanged. Instruction-only paragraphs carry hidden marks instead of blank lines; an empty result paragraph keeps its line. Imported TOCs preserve actual result boundaries, formatted entries and nested fields without relying on TOC styles. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465) | | Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. ODT/PDF export the result (moved-away text hidden like deletions); RTF writes a move as a plain tracked deletion and insertion | | Content controls (`w:sdt`) | ✅ | ✅ | Block, inline, row and cell level, nested; title, tag, id, lock, placeholder, temporary, rich/plain text, `w14:checkbox`, combo box, drop-down list, date, picture, building block gallery, `w15` repeating sections; other `w:sdtPr` children (data binding, appearance) kept verbatim (#381). A TOC's control is rebuilt by the TOC. Replace All respects content-edit and control-deletion locks and refuses a batch before making any edit | -| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges; forms mode (`w:edit="forms"`) lets edits inside fillable content controls (`w:lock` content-editable, innermost wins) run, everything else rolls back; row-wrapped controls and control deletion remain refused; `review.restrict` reports `formFields`; Find Next Region covers form fields (the pane button shows for forms documents); under active protection, `developer.properties` refuses `lockEdit`/`lockDelete` changes, as Word blocks property edits until Stop Protection; under forms protection it refuses every property, and inserting or removing content controls is refused too (Word greys out the Developer tab's control buttons and Properties and offers no Remove Content Control), while under the other modes the title, tag and placeholder stay editable | +| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges; forms mode (`w:edit="forms"`) lets edits inside fillable content controls (`w:lock` content-editable, innermost wins) run, everything else rolls back; row-wrapped controls and control deletion remain refused; `review.restrict` reports `formFields`; Find Next Region covers form fields (the pane button shows for forms documents); under active protection, `developer.properties` refuses `lockEdit`/`lockDelete` changes, as Word blocks property edits until Stop Protection; under forms protection it refuses every property, and inserting or removing content controls is refused too (Word greys out the Developer tab's control buttons and Properties and offers no Remove Content Control), while under the other modes the title, tag and placeholder stay editable; a file command that changes the open document is checked like any other, so the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password rather than the snapshot's stale ones | | Legacy form fields (`w:ffData`), check boxes, drop-downs | 🟡 | ✅ | The field definition is kept raw and saved back. Check boxes (`FORMCHECKBOX`) are drawn as Word draws them: a square as tall as the text's line, inset 1.16 pt, 0.72 pt border, an X when checked (`w:checked`, else `w:default`), `w:size` honoured; drop-downs (`FORMDROPDOWN`) show their selected entry; text fields (`FORMTEXT`) show their result. Not yet: filling them in, Protect Document's forms mode for legacy fields | | DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through | | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | @@ -233,6 +233,7 @@ Imported text fields use the first nonempty cached result run's formatting on th | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | Restrict Editing: a file command that changes the open document is checked like any other — the privacy options, the properties and the inspector's removals are refused on a protected document — and restoring a stored version keeps the protection and its password instead of the snapshot's stale ones | | 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | | 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | | 2026-10-18 | trivial | A Word 2003 XML or Flat OPC document, or a web page, saved under a `.doc`, `.docx` or `.rtf` name opens by what it holds, as in Word, instead of going to the Word 97-2003 reader and showing a blank page (#2853) | diff --git a/docs/gaps.md b/docs/gaps.md index a65a6d80c..61958969d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -106,6 +106,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | Restrict Editing: a file command that changes the open document is checked like any other (the privacy options, the properties, the inspector's removals), and restoring a stored version keeps the protection and its password. No gap row (enforcement fix); estimates unchanged | | 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | | 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | From b58348e738f9771411e6072a1b14f498d01b7b84 Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 13:26:50 +0100 Subject: [PATCH 02/13] Restrict Editing: the user name can't change under protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `file.setAuthor` sets the name a protected document's named exception ranges are checked against, and the author new revisions and comments are recorded under. `always_allowed` waved through every `file.*` id, so on a protected document the name could be changed: an agent could take the identity of a named editor and unlock their exception range, and forge the authorship of the revisions it leaves behind. A protected document refuses the change now, with the rule Word uses for a content control's properties: stop protection first. The check is in the command itself — `file.setAuthor` is a pure command, so `check_before` never sees it — and in `check_before` for the day it isn't. Word lets the user name change under protection: it is a preference there. This project doesn't, because that identity gates what may be edited. Co-Authored-By: opencode --- crates/engine/src/cmd/file.rs | 2 ++ crates/engine/src/cmd/protect.rs | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 118ed41b5..187587c47 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -17,7 +17,9 @@ pub const MAX_AUTHOR_CHARS: usize = 255; /// Sets the user name that new tracked changes and comments are recorded under. The name is /// trimmed, stripped of control characters (they can't go into the saved XML) and cut to /// [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always carry an author. +/// A protected document refuses it: the name is the identity its exceptions are checked against. pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult { + super::protect::check_user_name(s)?; let name: String = p::req_str(v, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect(); let name = name.trim_end(); if name.is_empty() { diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index 57541444e..e07aaa902 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -215,12 +215,26 @@ fn always_allowed(id: &str, params: &Value) -> bool { } match id { "file.privacyOptions" | "file.properties" => false, + // The user name says who may edit the document and who the revisions belong to: it is + // checked like any other change, not waved through. + "file.setAuthor" => false, // The inspector reads the document; its removals change it. "file.inspect" => params.get("remove").and_then(Value::as_array).is_none_or(|a| a.is_empty()), _ => true, } } +/// The user name (`file.setAuthor`) is the identity a named exception range is checked against, +/// and the author recorded on new revisions and comments: it can't change while the document is +/// protected. Word lets the user name change under protection (it is a preference there); this +/// project doesn't, because the name says who may edit what (#1327 follow-up). +pub(crate) fn check_user_name(s: &Session) -> Result<(), CmdError> { + if enforced(&s.doc) { + return Err(CmdError::Disabled("file.setAuthor: stop protection before changing the user name".into())); + } + Ok(()) +} + /// What a protected document lets command `id` do before it runs: `Err` refuses it; `Ok(true)` /// lets it run as long as it only changes the exceptions (checked afterwards with /// [`outside_unchanged`]). @@ -229,6 +243,13 @@ pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result Date: Tue, 20 Oct 2026 13:28:20 +0100 Subject: [PATCH 03/13] Restrict Editing: a protected document's protection needs an explicit stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard in `restrict` refused a protected document only when its protection had a password, so a password-less one could have its protection changed — and `file.protect {"mode": "none"}` stopped it outright. An agent could strip a user's protection silently and then edit the document freely. Two rules now, as Word has them (Stop Protection first): - The protection of a protected document doesn't change, whatever the mode. The formatting-only protection doesn't count: it stores the mode "none", and it limits the styles, not the editing. - Stopping the protection is explicit. The "none" mode is the shorthand for it, but on a protected document only `{"stop": true}` stops it — the way the pane's Stop Protection button does it — so a command that merely names a mode can't strip it. The password still guards the stop, and the formatting-only path (the mode "none" with the formatting restriction) still works. `cmd::tools::tests::protection_blocks_edits` stops the protection with `{"mode": "none"}`; it has to stop it explicitly now. Co-Authored-By: opencode --- crates/engine/src/cmd/protect.rs | 65 ++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index e07aaa902..71972079a 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -47,6 +47,13 @@ pub fn enforced(doc: &Document) -> bool { doc.settings.protection.is_some() } +/// Is the document's editing restricted? The formatting-only protection stores the mode "none" +/// (`w:documentProtection w:edit="none"` with `w:formatting="1"`), so it doesn't count here: it +/// limits the styles, not the editing. +pub fn editing_restricted(doc: &Document) -> bool { + doc.settings.protection.as_deref().is_some_and(|m| m != "none") +} + /// May `author` edit inside the range? fn editable_by(r: &PermRange, author: &str) -> bool { r.everyone() || (!r.editor.is_empty() && r.editor.eq_ignore_ascii_case(author.trim())) @@ -82,7 +89,14 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult { return Err(CmdError::Params(format!("unknown `mode` {m:?}: none, readOnly, comments, trackedChanges or forms"))); } let formatting = p::bool(v, "formatting"); - let stop = p::bool(v, "stop") == Some(true) || (mode == Some("none") && formatting != Some(true)); + let explicit_stop = p::bool(v, "stop") == Some(true); + // Stopping the protection is explicit: the "none" mode is the shorthand for it, but on a + // protected document only `{"stop": true}` stops it — a command that merely names a mode + // can't strip a user's protection (Word asks for Stop Protection first). + if mode == Some("none") && formatting != Some(true) && !explicit_stop && enforced(&s.doc) { + return Err(CmdError::Disabled("the document is protected: stop protection first (\"stop\": true)".into())); + } + let stop = explicit_stop || (mode == Some("none") && formatting != Some(true)); if stop { return stop_protection(s, p::str(v, "password").unwrap_or("")); } @@ -90,8 +104,15 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult { // Only the locked styles changed (or nothing): report the state. return Ok(state(s)); } - if enforced(&s.doc) && !s.doc.settings.protect_hash.is_empty() { - return Err(CmdError::Disabled("the document is protected with a password: stop protection first".into())); + // The protection of a protected document doesn't change: Word asks for Stop Protection first. + // (The formatting-only protection isn't one: it limits the styles, not the editing.) + if editing_restricted(&s.doc) { + let why = if s.doc.settings.protect_hash.is_empty() { + "the document is protected: stop protection first" + } else { + "the document is protected with a password: stop protection first" + }; + return Err(CmdError::Disabled(why.into())); } let formatting = formatting.unwrap_or(false); let mode = mode.unwrap_or("none"); @@ -778,6 +799,42 @@ mod tests { assert_eq!(s.author, "Alice"); } + /// The protection of a protected document doesn't change: Word asks for Stop Protection first, + /// and only the explicit way stops it — a command that merely names a mode can't strip it + /// (#1327 follow-up). + #[test] + fn a_protected_documents_protection_needs_an_explicit_stop() { + let mut s = Session::new(Document::from_text("Some text.")); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + // The "none" shorthand stops the protection: refused, and the protection stays. + let e = s.run("file.protect", &json!({"mode": "none"})).unwrap_err().to_string(); + assert!(e.contains("stop protection"), "{e}"); + assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly")); + // Changing the mode of a protected document is refused too. + let e = s.run("review.restrict", &json!({"mode": "comments"})).unwrap_err().to_string(); + assert!(e.contains("stop protection"), "{e}"); + assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly")); + // Stopping it explicitly works. + s.run("review.restrict", &json!({"stop": true})).unwrap(); + assert!(s.doc.settings.protection.is_none()); + } + + /// A password-protected document still needs its password to stop, and the formatting-only + /// protection (the mode "none") is still how the styles are limited. + #[test] + fn a_password_guards_the_protection_and_the_formatting_only_path_still_works() { + let mut s = Session::new(Document::from_text("Some text.")); + s.run("review.restrict", &json!({"mode": "readOnly", "password": "pw"})).unwrap(); + assert!(s.run("review.restrict", &json!({"stop": true, "password": "wrong"})).is_err()); + assert!(s.run("review.restrict", &json!({"stop": true})).is_err()); + assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly")); + // The formatting-only protection: the mode "none" with the formatting restriction. + let mut t = Session::new(Document::from_text("Some text.")); + t.run("review.restrict", &json!({"mode": "none", "formatting": true})).unwrap(); + assert_eq!(t.doc.settings.protection.as_deref(), Some("none")); + assert!(t.doc.settings.protect_formatting); + } + /// Reading the document stays allowed under protection: the inspector without removals, the /// privacy settings, the info, and saving a version snapshot. #[test] @@ -1079,6 +1136,8 @@ mod tests { assert_eq!(start, first.content_start(), "wraps round to the first control"); assert_eq!(end, first.end); // Without forms mode, Find Next Region is about perm ranges: none here, nothing to find. + // (The protection is stopped first: the mode of a protected document doesn't change.) + s.run("review.restrict", &json!({"stop": true})).unwrap(); s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); assert_eq!(next_region(&s, &s.doc.start_of(StoryRef::Body)), None); } From 9aa9eca9e827b9b00f74b9908fe4ec69b60bc3fd Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 13:35:55 +0100 Subject: [PATCH 04/13] Restrict Editing: the post-check compares the settings and the metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `outside_unchanged` is the check that rolls a command back when it changed anything outside the editable regions. It compared the text, the formatting, the page setup, the document defaults and the styles — but not the document's settings, its metadata, its custom properties, the resolved flag of its comments, its list definitions or its bibliography sources. A command that changed the metadata of a protected document was accepted and never rolled back: `file.properties {"title": "Forged title"}` on a protected document with an exception changed the title and stayed. The settings, the core properties, the custom properties, the comments' resolved flags, the list definitions and the sources are compared now, so a command that changes them is rolled back like any other change outside the editable regions. The revisions and the media store stay out of it on purpose: an edit inside an exception legitimately leaves a revision, and a picture inserted there adds to the media store. The list definitions and the sources are compared like the styles: the ones the document had must be unchanged, but a new one may be added — a list or a citation inserted inside an editable region adds its definition, like applying a style adds one. Saving is unaffected: it writes the metadata, but `file.save` is a pure command and isn't post-checked, and the protection itself is changed by `review.restrict`, which is always allowed. Co-Authored-By: opencode --- crates/engine/src/cmd/protect.rs | 56 +++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index 71972079a..b05fc193c 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -370,7 +370,7 @@ enum HoleMode { /// run formatting and paragraph formatting outside the ranges `author` may edit, in every story, /// and the formatting of every table, row and cell that holds any of that protected content, and what /// reaches the whole document: page setup, the document defaults and the existing styles -/// (applying a style may add one). +/// (applying a style may add one), the document's settings and its metadata. pub(crate) fn outside_unchanged(before: &Document, after: &Document, author: &str) -> bool { // Protection can't change mid-command (`protection_lifted` guards undo and redo). let mode = if Some("forms") == before.settings.protection.as_deref() { HoleMode::Controls } else { HoleMode::Perms(author.into()) }; @@ -380,9 +380,35 @@ pub(crate) fn outside_unchanged(before: &Document, after: &Document, author: &st && b.default_chr == a.default_chr && b.default_para == a.default_para && b.styles.iter().all(|st| a.get(&st.id) == Some(st)) + && before.settings == after.settings + && before.core == after.core + && before.custom_props == after.custom_props + && comments_unchanged(before, after) + && lists_unchanged(before, after) + && sources_unchanged(before, after) && outside(before, &mode) == outside(after, &mode) } +/// The comments' resolved flags: a comment's text is a part, and the parts are compared with the +/// rest of the document; the flag is the comment's own state. +fn comments_unchanged(before: &Document, after: &Document) -> bool { + before.comments.iter().all(|(id, c)| after.comments.get(id).is_some_and(|a| a.resolved == c.resolved)) +} + +/// The list definitions: the ones the document had must be unchanged — a list's format reaches +/// every paragraph that uses it — but a new one may be added, like a new style: applying a list +/// inside an editable region adds its definition. +fn lists_unchanged(before: &Document, after: &Document) -> bool { + before.numbering.abstracts.iter().all(|x| after.numbering.abstracts.contains(x)) + && before.numbering.nums.iter().all(|x| after.numbering.nums.contains(x)) +} + +/// The bibliography sources: the ones the document had must be unchanged; a new one may be added +/// (a citation inserted inside an editable region). +fn sources_unchanged(before: &Document, after: &Document) -> bool { + before.sources.iter().all(|x| after.sources.contains(x)) +} + /// The section properties paragraphs end, in order. fn sections(doc: &Document) -> Vec<&wordcraft_doc::SectionProps> { doc.para_paths(StoryRef::Body).iter().filter_map(|path| doc.para(StoryRef::Body, path)?.section.as_deref()).collect() @@ -835,6 +861,34 @@ mod tests { assert!(t.doc.settings.protect_formatting); } + /// The settings and the metadata of a protected document are part of what stays as it was: a + /// command that changes them is rolled back, like any other change outside the editable + /// regions (#1327 follow-up). + #[test] + fn a_protected_document_keeps_its_settings_and_metadata() { + let mut s = Session::new(Document::from_text("Locked intro.\nFill in here.")); + select(&mut s, "Fill in here."); + s.run("review.restrict", &json!({"exception": "add"})).unwrap(); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + let before = s.doc.clone(); + // The properties: the change is rolled back, and the command says so. + let e = s.run("file.properties", &json!({"title": "Forged title"})).unwrap_err().to_string(); + assert!(e.contains("only the editable regions"), "{e}"); + assert_eq!(s.doc, before); + assert_ne!(s.doc.core.title, "Forged title"); + // A custom property too. + assert!(s.run("file.properties", &json!({"custom": {"Owner": "Alice"}})).is_err()); + assert_eq!(s.doc, before); + // An edit inside the exception still works, and isn't rolled back. + select(&mut s, "here"); + s.run("text.insert", &json!({"text": "there"})).unwrap(); + assert!(s.doc.plain_text(StoryRef::Body).contains("Fill in there.")); + // Without protection the same command works. + let mut t = Session::new(Document::from_text("Some text.")); + t.run("file.properties", &json!({"title": "A title"})).unwrap(); + assert_eq!(t.doc.core.title, "A title"); + } + /// Reading the document stays allowed under protection: the inspector without removals, the /// privacy settings, the info, and saving a version snapshot. #[test] From 823684807b0cc5c04a87eb27a681f208609e9980 Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 13:41:58 +0100 Subject: [PATCH 05/13] The protection is stopped explicitly, and the docs say so The test that stopped the protection used the "none" shorthand, which a protected document now refuses; it stops it the explicit way instead, and pins that the shorthand doesn't stop it. `file.protect` says the same: it starts enforcement, and "none" doesn't stop a protected document. Co-Authored-By: opencode --- crates/engine/src/cmd/tools.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index c09a9c534..7536ec0ef 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -465,7 +465,7 @@ pub fn specs() -> Vec { let mode = p::str(v, "mode").unwrap_or("readOnly"); s.run("review.restrict", &json!({"mode": mode})) }) - .params(r#"{"mode": "none|readOnly|comments|trackedChanges"}"#), + .params(r#"{"mode": "none|readOnly|comments|trackedChanges"} (starting enforcement; "none" doesn't stop a protected document — use review.restrict {"stop": true})"#), CommandSpec::new("file.versions", "Version History", "File › Info", |s, v| { if let Some(label) = p::str(v, "save") { s.versions.push((label.to_string(), super::now_iso(), s.doc.clone())); @@ -2034,7 +2034,11 @@ mod tests { let mut s = Session::new(Document::from_text("locked")); s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); assert!(s.run("text.insert", &json!({"text": "x"})).is_err()); - s.run("review.restrict", &json!({"mode": "none"})).unwrap(); + // The "none" mode is the shorthand for stopping the protection, and it doesn't stop a + // protected document: only the explicit stop does (#1327 follow-up). + assert!(s.run("review.restrict", &json!({"mode": "none"})).is_err()); + assert!(s.run("text.insert", &json!({"text": "x"})).is_err()); + s.run("review.restrict", &json!({"stop": true})).unwrap(); assert!(s.run("text.insert", &json!({"text": "x"})).is_ok()); } From 376a517cb0a4f3034b4677c37ddc08dc3141cd2f Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 13:44:43 +0100 Subject: [PATCH 06/13] Docs: the Restrict Editing gaps that are closed Co-Authored-By: opencode --- ROADMAP.md | 2 ++ docs/file-format-parity.md | 2 +- docs/gaps.md | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4d9971143..5269b39fe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,6 +141,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-20 | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop (`review.restrict {"stop": true}`; the "none" mode is the shorthand and doesn't stop it), the user name can't change while the document is protected, and the post-check compares the document's settings, metadata, custom properties, list definitions, bibliography sources and the comments' resolved flags as well as its text and formatting | | 2026-10-20 | Restrict Editing holds for the file commands too: the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password instead of the snapshot's stale ones | | 2026-10-19 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. | | 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). | @@ -507,6 +508,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop, the user name can't change under protection, and the post-check compares the settings and the metadata as well as the text and the formatting | | 2026-10-20 | minor | Restrict Editing: a file command that changes the open document is checked like any other — the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version no longer lifts the protection or drops its password | | 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control | | 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d5ad1cdd8..fa33f1295 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -83,7 +83,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. Textual `REF` fields with `\h` export internal PDF links without refreshing cached text; existing enclosing links take precedence. `wordcraft-cli convert --update-fields` explicitly runs field updates before saving; ordinary conversion preserves caches and locked fields stay unchanged. Instruction-only paragraphs carry hidden marks instead of blank lines; an empty result paragraph keeps its line. Imported TOCs preserve actual result boundaries, formatted entries and nested fields without relying on TOC styles. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465) | | Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. ODT/PDF export the result (moved-away text hidden like deletions); RTF writes a move as a plain tracked deletion and insertion | | Content controls (`w:sdt`) | ✅ | ✅ | Block, inline, row and cell level, nested; title, tag, id, lock, placeholder, temporary, rich/plain text, `w14:checkbox`, combo box, drop-down list, date, picture, building block gallery, `w15` repeating sections; other `w:sdtPr` children (data binding, appearance) kept verbatim (#381). A TOC's control is rebuilt by the TOC. Replace All respects content-edit and control-deletion locks and refuses a batch before making any edit | -| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges; forms mode (`w:edit="forms"`) lets edits inside fillable content controls (`w:lock` content-editable, innermost wins) run, everything else rolls back; row-wrapped controls and control deletion remain refused; `review.restrict` reports `formFields`; Find Next Region covers form fields (the pane button shows for forms documents); under active protection, `developer.properties` refuses `lockEdit`/`lockDelete` changes, as Word blocks property edits until Stop Protection; under forms protection it refuses every property, and inserting or removing content controls is refused too (Word greys out the Developer tab's control buttons and Properties and offers no Remove Content Control), while under the other modes the title, tag and placeholder stay editable; a file command that changes the open document is checked like any other, so the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password rather than the snapshot's stale ones | +| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges; forms mode (`w:edit="forms"`) lets edits inside fillable content controls (`w:lock` content-editable, innermost wins) run, everything else rolls back; row-wrapped controls and control deletion remain refused; `review.restrict` reports `formFields`; Find Next Region covers form fields (the pane button shows for forms documents); under active protection, `developer.properties` refuses `lockEdit`/`lockDelete` changes, as Word blocks property edits until Stop Protection; under forms protection it refuses every property, and inserting or removing content controls is refused too (Word greys out the Developer tab's control buttons and Properties and offers no Remove Content Control), while under the other modes the title, tag and placeholder stay editable; a file command that changes the open document is checked like any other, so the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password rather than the snapshot's stale ones; the protection of a protected document only changes through the explicit stop (`review.restrict {"stop": true}` — the "none" mode is the shorthand for it and doesn't stop a protected document), the user name can't change while the document is protected, and the post-check compares the document's settings, metadata, custom properties, list definitions, bibliography sources and the comments' resolved flags as well as its text and formatting | | Legacy form fields (`w:ffData`), check boxes, drop-downs | 🟡 | ✅ | The field definition is kept raw and saved back. Check boxes (`FORMCHECKBOX`) are drawn as Word draws them: a square as tall as the text's line, inset 1.16 pt, 0.72 pt border, an X when checked (`w:checked`, else `w:default`), `w:size` honoured; drop-downs (`FORMDROPDOWN`) show their selected entry; text fields (`FORMTEXT`) show their result. Not yet: filling them in, Protect Document's forms mode for legacy fields | | DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through | | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | diff --git a/docs/gaps.md b/docs/gaps.md index 61958969d..afbfc204d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -106,6 +106,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop, the user name can't change under protection, and the post-check compares the settings and the metadata. No gap row (enforcement fix); estimates unchanged | | 2026-10-20 | trivial | Restrict Editing: a file command that changes the open document is checked like any other (the privacy options, the properties, the inspector's removals), and restoring a stored version keeps the protection and its password. No gap row (enforcement fix); estimates unchanged | | 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | | 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | From f36339ab4b617386d59c477e344d28e43ffaecd9 Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 17:20:53 +0100 Subject: [PATCH 07/13] A password guards the protection whatever its mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard that stops the protection of a protected document from changing asked whether the editing was restricted, so a formatting-only protection — the mode "none", which limits the styles rather than the editing — was exempted. A formatting-only protection can carry a password, and that password guarded it: it was exempt from the guard, so `{"mode": "readOnly", "password": "other"}` replaced the protection and the password with ones of the caller's choosing, and the protection could then be stopped with them. A password guards the protection whatever its mode, as it did before. Co-Authored-By: opencode --- crates/engine/src/cmd/protect.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index b05fc193c..bbbaae824 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -105,8 +105,9 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult { return Ok(state(s)); } // The protection of a protected document doesn't change: Word asks for Stop Protection first. - // (The formatting-only protection isn't one: it limits the styles, not the editing.) - if editing_restricted(&s.doc) { + // (The formatting-only protection isn't one: it limits the styles, not the editing — but a + // password on it still guards it, so it can't be replaced either.) + if editing_restricted(&s.doc) || !s.doc.settings.protect_hash.is_empty() { let why = if s.doc.settings.protect_hash.is_empty() { "the document is protected: stop protection first" } else { @@ -861,6 +862,27 @@ mod tests { assert!(t.doc.settings.protect_formatting); } + /// A password guards the protection whatever its mode: a formatting-only protection with a + /// password can't have that password replaced (and then removed) without knowing it. + #[test] + fn a_password_guards_the_formatting_only_protection_too() { + let mut s = Session::new(Document::from_text("Some text.")); + s.run("review.restrict", &json!({"mode": "none", "formatting": true, "password": "pw"})).unwrap(); + assert_eq!(s.doc.settings.protection.as_deref(), Some("none")); + let hash = s.doc.settings.protect_hash.clone(); + assert!(!hash.is_empty(), "the password is set"); + // Replacing the protection would replace the password with one of the caller's choosing. + assert!(s.run("review.restrict", &json!({"mode": "readOnly", "password": "other"})).is_err()); + assert_eq!(s.doc.settings.protect_hash, hash, "the password was replaced"); + assert_eq!(s.doc.settings.protection.as_deref(), Some("none"), "the protection was replaced"); + // And stopping it needs the password. + assert!(s.run("review.restrict", &json!({"stop": true})).is_err()); + assert!(s.run("review.restrict", &json!({"stop": true, "password": "wrong"})).is_err()); + assert_eq!(s.doc.settings.protection.as_deref(), Some("none"), "the protection was stopped"); + s.run("review.restrict", &json!({"stop": true, "password": "pw"})).unwrap(); + assert!(s.doc.settings.protection.is_none()); + } + /// The settings and the metadata of a protected document are part of what stays as it was: a /// command that changes them is rolled back, like any other change outside the editable /// regions (#1327 follow-up). From d809c838c4b10bdbabeb75ba8593589adf4fef39 Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 17:20:53 +0100 Subject: [PATCH 08/13] The CLI's --author names the session, it doesn't change the document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--author` dispatched `file.setAuthor`, which a protected document refuses now that the name is checked as the identity its exceptions are matched against — so a script that named itself on a protected file failed before it ran a single command. The flag names the session, not the open document, so it sets the name directly, with the same normalisation and blank-name check the command uses. `file.setAuthor` keeps its protection check for the callers that mean to change the document's identity. Co-Authored-By: opencode --- apps/wordcraft-cli/src/main.rs | 13 ++++++++++++- crates/engine/src/cmd/file.rs | 22 ++++++++++++++-------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 446b902fa..1a273dda4 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -180,8 +180,12 @@ fn listed_text(doc: &wordcraft_doc::Document) -> Result { } /// `--author NAME`: the author recorded on tracked changes and comments (`file.setAuthor`). +/// The name is set on the session directly: the caller is naming itself, not changing the open +/// document, so the protection that refuses `file.setAuthor` doesn't apply here. fn set_author(s: &mut Session, name: &str) -> Result<(), String> { - s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}")) + let name = wordcraft_engine::cmd::file::normalize_author(name).map_err(|e| format!("--author: {e}"))?; + s.author = name; + Ok(()) } fn arg_value(args: &[String], name: &str) -> Option { @@ -804,6 +808,13 @@ mod tests { assert!(run(&args(&["run", "--author", "Claude", "--cmd", "review.trackChanges={\"value\":true}"])).is_ok()); // The app's user name is the person's own; a bridge flag must not rewrite it. assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author"))); + // The flag names the session, so a protected document doesn't refuse it — changing the + // document's identity is a different thing, and that is still refused. + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + assert!(set_author(&mut s, "Claude").is_ok()); + assert_eq!(s.author, "Claude"); + assert!(s.run("file.setAuthor", &json!({"name": "Claude"})).is_err()); } #[test] diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 187587c47..167d0dbbc 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -14,18 +14,24 @@ pub const FORMAT_FLAT_OPC: &str = "flatopc"; pub const MAX_AUTHOR_CHARS: usize = 255; -/// Sets the user name that new tracked changes and comments are recorded under. The name is -/// trimmed, stripped of control characters (they can't go into the saved XML) and cut to -/// [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always carry an author. -/// A protected document refuses it: the name is the identity its exceptions are checked against. -pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult { - super::protect::check_user_name(s)?; - let name: String = p::req_str(v, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect(); +/// The user name as it is stored: trimmed, stripped of control characters (they can't go into +/// the saved XML) and cut to [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always +/// carry an author. +pub fn normalize_author(name: &str) -> Result { + let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect(); let name = name.trim_end(); if name.is_empty() { return Err(CmdError::Params("`name` must not be blank".into())); } - s.author = name.to_string(); + Ok(name.to_string()) +} + +/// Sets the user name that new tracked changes and comments are recorded under. The name is +/// normalised by [`normalize_author`]. +/// A protected document refuses it: the name is the identity its exceptions are checked against. +pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult { + super::protect::check_user_name(s)?; + s.author = normalize_author(p::req_str(v, "name")?)?; let mut r = sel_result(s)?; if let Some(o) = r.as_object_mut() { o.insert("name".into(), json!(s.author)); From 3a15b19e7f2103c65bd2d10df1e31becd117f19b Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 17:20:53 +0100 Subject: [PATCH 09/13] Docs: the progress docs' status lines carry today's date Co-Authored-By: opencode --- docs/file-format-parity.md | 2 +- docs/gaps.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fa33f1295..57cf77eb3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** trivial (RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-20 · **Change:** trivial (the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the user name can't change under protection, and the post-check compares the settings and the metadata; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; diff --git a/docs/gaps.md b/docs/gaps.md index afbfc204d..45b44955f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** trivial (the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the user name can't change under protection, and the post-check compares the settings and the metadata; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. From f7f78cadd6bc03d370d91534f10d1965b3f553fa Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 21:59:54 +0100 Subject: [PATCH 10/13] The document's properties and a version restore need the protection stopped Two gaps the review found, both of them a change to a protected document that went through without the protection seeing it. The properties (`file.properties`) and the privacy options (`file.privacyOptions`) are document-wide state, and they aren't tracked: under tracked changes they changed the document with no revision to show for it, and under the other modes they were only rolled back by the post-check, after the command had run. They are refused now, before the command runs, as the content control's properties are. A version restore (`file.versions`) replaces the whole document, including the content the protection kept read-only, and it did that without the password: `file.versions` is a pure command, so the protection never saw it. Keeping the live protection and its password stopped them being dropped, but the content underneath was still replaced. A restore needs the protection stopped first, and the password with it when the document has one. The live protection still wins over the snapshot's stale one, so a restore can't lock the user out of their own document either. Co-Authored-By: opencode --- crates/engine/src/cmd/protect.rs | 111 +++++++++++++++++++++++++------ crates/engine/src/cmd/tools.rs | 8 ++- 2 files changed, 98 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index bbbaae824..610d1cccc 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -257,6 +257,17 @@ pub(crate) fn check_user_name(s: &Session) -> Result<(), CmdError> { Ok(()) } +/// A version restore replaces the whole document, including the content the protection kept +/// read-only, so it needs the protection stopped first — and the password with it, when the +/// document has one. (`file.versions` is a pure command, so `check_before` doesn't see it; the +/// command checks this itself — see [`check_version_restore`].) +pub(crate) fn check_version_restore(s: &Session) -> Result<(), CmdError> { + if enforced(&s.doc) { + return Err(CmdError::Disabled("file.versions: stop protection before restoring a version".into())); + } + Ok(()) +} + /// What a protected document lets command `id` do before it runs: `Err` refuses it; `Ok(true)` /// lets it run as long as it only changes the exceptions (checked afterwards with /// [`outside_unchanged`]). @@ -280,6 +291,17 @@ pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result return Err(CmdError::Disabled(format!("{id}: stop protection before changing the document's properties"))), + "file.privacyOptions" => return Err(CmdError::Disabled(format!("{id}: stop protection before changing the privacy options"))), + _ => {} + } + } // Observed in Word for Mac: with Filling in forms enforced, the Developer tab's control // buttons and Properties are greyed out and a field's shortcut menu offers no Remove Content // Control. The form's fields stay as they are; only their content is filled in. @@ -770,25 +792,61 @@ mod tests { assert_eq!(s.doc, before); } - /// Restoring a stored version replaces the whole document, and a snapshot's own protection - /// is stale: the live document's protection, and its password, still hold after a restore - /// (#1327 follow-up). + /// A version restore replaces the whole document, including the content the protection kept + /// read-only, so it needs the protection stopped first — and the password with it. #[test] - fn a_version_restore_keeps_the_protection() { + fn a_version_restore_needs_the_protection_stopped_first() { + for mode in ["readOnly", "forms", "comments", "trackedChanges"] { + let mut s = Session::new(Document::from_text("Original text.")); + s.run("file.versions", &json!({"save": "before"})).unwrap(); + s.run("review.restrict", &json!({"mode": mode, "password": "pw"})).unwrap(); + let before = s.doc.clone(); + let e = s.run("file.versions", &json!({"restore": 0})).unwrap_err().to_string(); + assert!(e.contains("stop protection"), "{mode}: {e}"); + assert_eq!(s.doc, before, "{mode}: the restore changed the document"); + assert_eq!(s.versions.len(), 1, "{mode}: the snapshot is still there"); + } + } + + /// A restore never changes the protection: the live document's choice stands, and a + /// snapshot taken while the document was protected doesn't bring that protection back. + #[test] + fn a_version_restore_keeps_the_live_protection() { let mut s = Session::new(Document::from_text("Original text.")); s.run("file.versions", &json!({"save": "before"})).unwrap(); s.run("review.restrict", &json!({"mode": "readOnly", "password": "pw"})).unwrap(); - assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly")); - let hash = s.doc.settings.protect_hash.clone(); - assert!(!hash.is_empty(), "the password is set"); - + // The protection is stopped, with its password, and the version restored after that. + s.run("review.restrict", &json!({"stop": true, "password": "pw"})).unwrap(); + assert!(s.doc.settings.protection.is_none()); s.run("file.versions", &json!({"restore": 0})).unwrap(); - assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"), "the restore lifted the protection"); - assert_eq!(s.doc.settings.protect_hash, hash, "the restore dropped the password"); - // The document is still protected: an edit outside the exceptions is refused. - let before = s.doc.clone(); - assert!(s.run("text.insert", &json!({"text": "edited "})).is_err()); - assert_eq!(s.doc, before); + assert!(s.doc.settings.protection.is_none(), "the restore brought the protection back"); + assert!(s.doc.settings.protect_hash.is_empty(), "the restore brought the password back"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Original text."); + } + + /// A document-wide change that isn't tracked — the properties, the privacy options — is + /// refused while the document is protected: under tracked changes it would change the + /// document with no revision to show for it, and under the other modes the document may not + /// change at all. + #[test] + fn a_document_wide_change_that_is_not_tracked_is_refused_under_protection() { + for mode in ["readOnly", "forms", "comments", "trackedChanges"] { + for (id, params) in [("file.properties", json!({"title": "Forged title"})), ("file.privacyOptions", json!({"removePersonalInfo": true}))] + { + let mut s = Session::new(Document::from_text("Some text.")); + s.run("review.restrict", &json!({"mode": mode})).unwrap(); + let before = s.doc.clone(); + let e = s.run(id, ¶ms).unwrap_err().to_string(); + assert!(e.contains("stop protection"), "{mode} {id}: {e}"); + assert_eq!(s.doc, before, "{mode} {id}: the document changed"); + } + } + // Without protection they work. + let mut s = Session::new(Document::from_text("Some text.")); + s.run("file.properties", &json!({"title": "A title"})).unwrap(); + assert_eq!(s.doc.core.title, "A title"); + s.run("file.privacyOptions", &json!({"removePersonalInfo": true})).unwrap(); + assert!(s.doc.settings.remove_personal_info); } /// A file command that changes the document is checked like any other: the privacy options @@ -883,9 +941,9 @@ mod tests { assert!(s.doc.settings.protection.is_none()); } - /// The settings and the metadata of a protected document are part of what stays as it was: a - /// command that changes them is rolled back, like any other change outside the editable - /// regions (#1327 follow-up). + /// The settings and the metadata of a protected document are part of what stays as it was: + /// a command that changes them is refused, or rolled back, like any other change outside + /// the editable regions (#1327 follow-up). #[test] fn a_protected_document_keeps_its_settings_and_metadata() { let mut s = Session::new(Document::from_text("Locked intro.\nFill in here.")); @@ -893,9 +951,9 @@ mod tests { s.run("review.restrict", &json!({"exception": "add"})).unwrap(); s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); let before = s.doc.clone(); - // The properties: the change is rolled back, and the command says so. + // The properties: the change is refused, and the command says why. let e = s.run("file.properties", &json!({"title": "Forged title"})).unwrap_err().to_string(); - assert!(e.contains("only the editable regions"), "{e}"); + assert!(e.contains("stop protection"), "{e}"); assert_eq!(s.doc, before); assert_ne!(s.doc.core.title, "Forged title"); // A custom property too. @@ -924,6 +982,21 @@ mod tests { } } + /// The post-check compares the document's settings and its metadata, so a command that + /// changed either is rolled back even where the command itself isn't refused — the + /// backstop behind the refusal the properties and the privacy options get. + #[test] + fn the_post_check_compares_the_settings_and_the_metadata() { + let before = Document::from_text("Some text."); + let mut after = before.clone(); + after.core.title = "Forged".into(); + assert!(!super::outside_unchanged(&before, &after, "someone"), "the metadata isn't compared"); + let mut after = before.clone(); + after.settings.remove_personal_info = true; + assert!(!super::outside_unchanged(&before, &after, "someone"), "the settings aren't compared"); + assert!(super::outside_unchanged(&before, &before.clone(), "someone"), "an unchanged document isn't unchanged"); + } + /// Read only, no exceptions: going to a header the document doesn't have doesn't create /// one, and loading or filtering recipients doesn't rewrite the merge fields' shown results /// (both used to change the document from a non-mutating command, #1327). diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 7536ec0ef..142cbc6ac 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -475,10 +475,14 @@ pub fn specs() -> Vec { } if let Some(i) = p::u64(v, "restore") { let (_, _, d) = s.versions.get(i as usize).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?; + // The restore replaces the whole document, including the content the protection + // kept read-only, so the protection is stopped first (with its password, when it + // has one) — a restore must not go around it. + super::protect::check_version_restore(s)?; s.checkpoint("Restore Version"); // A snapshot's own protection is stale: it was taken at some earlier moment, and - // restoring it must not lift the protection the live document is under, nor - // its password (#1327 follow-up). + // restoring it must not bring a protection back that the live document doesn't + // have (#1327 follow-up). let protection = s.doc.settings.protection.clone(); let protect_hash = s.doc.settings.protect_hash.clone(); let protect_formatting = s.doc.settings.protect_formatting; From ec1b53939561b2f544e986c3b7b05fd606785ab8 Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Thu, 22 Oct 2026 23:51:19 +0100 Subject: [PATCH 11/13] The inspector's removals are refused in every protected mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `file.inspect` with removals takes content out of the document — the comments, the revisions, the properties, the hidden text, the headers. Under readOnly and comments it was refused, or rolled back by the post-check where the document had an exception; under trackedChanges nothing stopped it, so a document that tracks changes could have its comments and revisions taken out with no revision to show for it. The removals are refused now, in every mode a protected document can be in, next to the properties and the privacy options. An inspection without removals only reads the document, and still works. The protection test covers the modes now, not only readOnly. Co-Authored-By: opencode --- crates/engine/src/cmd/protect.rs | 37 +++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index f272ecd0f..61d57c292 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -312,10 +312,16 @@ pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result