From bcce02ab369c4a29864dc9ee0e472ea8b050fe05 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:56:36 +0100 Subject: [PATCH] #3692: confined automation adds or removes fonts only when the font store is inside its write root Finding 44: font.add stored the file in the user font folder and font.remove deleted stored files directly, bypassing the automation write root (#769). Both now check the user font folder against the write policy in force before touching the database or the disk; with no folder (headless) a font still lasts for the session only. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/font.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/engine/src/cmd/font.rs b/crates/engine/src/cmd/font.rs index dfb7a4816..6160f8471 100644 --- a/crates/engine/src/cmd/font.rs +++ b/crates/engine/src/cmd/font.rs @@ -43,6 +43,15 @@ fn read_font_file(path: &str) -> Result, CmdError> { crate::io::read_file_capped(path, wordcraft_fonts::MAX_FONT_FILE_BYTES as u64, "font file").map_err(CmdError::Failed) } +/// Adding or removing a font writes to the user font folder: automation confined to a write root +/// (#769) may do that only when the folder is inside it. +fn check_font_store(dir: Option<&std::path::Path>) -> Result<(), CmdError> { + match dir { + Some(dir) => crate::file_access::check_write(&dir.to_string_lossy()).map_err(CmdError::Failed), + None => Ok(()), + } +} + fn add(s: &mut Session, v: &Value) -> CmdResult { if p::str(v, "url").is_some() { return Err(CmdError::Failed( @@ -71,6 +80,7 @@ fn add(s: &mut Session, v: &Value) -> CmdResult { s.push_ui_request(json!({"open": "addFont"})); return Ok(json!({"pending": true})); }; + check_font_store(FontDb::global().user_font_dir().as_deref())?; let r = FontDb::global().install_font(&name, &bytes).map_err(|e| CmdError::Failed(e.to_string()))?; // Text already set in the family (shown in a fallback until now) takes the new font. s.relayout(); @@ -100,8 +110,33 @@ fn list(_: &mut Session, v: &Value) -> CmdResult { fn remove(s: &mut Session, v: &Value) -> CmdResult { let family = p::str(v, "family").filter(|f| !f.trim().is_empty()).ok_or_else(|| CmdError::Params("family is required".into()))?; + check_font_store(FontDb::global().user_font_dir().as_deref())?; let gone = FontDb::global().remove_user_font(family.trim()).map_err(|e| CmdError::Failed(e.to_string()))?; s.relayout(); s.status = format!("Removed {}", family.trim()); Ok(json!({"removed": gone.iter().map(font_json).collect::>()})) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn confined_automation_changes_only_a_font_store_inside_its_write_root() { + let root = std::env::temp_dir().join(format!("wordcraft-font-store-{}", std::process::id())); + let outside = std::env::temp_dir().join(format!("wordcraft-font-store-out-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let write = crate::file_access::AutomationRoots::new(None, Some(&root)).unwrap(); + let read_only = crate::file_access::AutomationRoots::new(Some(&root), None).unwrap(); + crate::file_access::confine(write.as_ref(), || { + assert!(check_font_store(None).is_ok(), "no folder: the font lasts for the session"); + assert!(check_font_store(Some(&root.join("fonts"))).is_ok()); + assert!(check_font_store(Some(&outside)).is_err()); + }); + crate::file_access::confine(read_only.as_ref(), || assert!(check_font_store(Some(&root)).is_err())); + assert!(check_font_store(Some(&outside)).is_ok(), "unconfined: the person at the keyboard"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&outside); + } +}