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) |