From c882d169ffc7a231f899f5ca1c6899e6b9f0831c Mon Sep 17 00:00:00 2001 From: lloyd Date: Sun, 11 Oct 2026 23:08:09 +0200 Subject: [PATCH] UI: ask for the password of an encrypted workbook (#11) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/dialogs.rs | 21 +++++ crates/ui-egui/src/lib.rs | 26 ++++-- crates/ui-egui/tests/open_encrypted.rs | 120 +++++++++++++++++++++++++ 5 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/tests/open_encrypted.rs diff --git a/Cargo.lock b/Cargo.lock index 4d67553..f159fee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1619,6 +1619,7 @@ dependencies = [ "epaint_default_fonts", "gridcraft-chart", "gridcraft-engine", + "gridcraft-xlsx", "image", "log", "serde", diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index e5847f0..bfce89e 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -27,6 +27,7 @@ zbus = "=5.19.0" [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } epaint_default_fonts = { workspace = true } +gridcraft-xlsx = { workspace = true, features = ["test-support"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # The desktop's preferred language for the first-run interface language. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c3f0ea6..de04998 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -236,6 +236,19 @@ impl Dialog { vec![Password { key: "password", label: "Password:" }], json!({"on": false}), ), + "openPassword" => { + // The file rides along in the values (`path` on the desktop, `name` and `base64` + // on the web) and goes back to `file.open` with the password. + let source = p("path").as_str().or(p("name").as_str()).map(str::to_string).unwrap_or_default(); + let file = std::path::Path::new(&source).file_name().and_then(|n| n.to_str()).unwrap_or("This workbook").to_string(); + Dialog::form( + "openPassword", + "Password", + "file.open", + vec![Note(format!("'{file}' is protected.")), Password { key: "password", label: "Password:" }], + params.clone(), + ) + } "unprotectSheet" => Dialog::form( "unprotectSheet", "Unprotect Sheet", @@ -1101,12 +1114,20 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { open = false; if d.name == "solver" { app.open_dialog("solverResults", r); + } else if d.name == "openPassword" { + let path = d.values.get("path").and_then(Json::as_str).map(str::to_string); + app.after_open(path.as_deref(), &r); } } // Data Analysis asks before replacing data in its output range. Err(e) if d.name == "analysisToolPak" && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => { d.values.insert("askOverwrite".into(), json!(true)); } + // A wrong password: say so, clear the field and stay open for another try. + Err(e) if d.name == "openPassword" => { + d.values.insert("password".into(), json!("")); + app.message = Some((d.title.clone(), crate::clean_error(&e))); + } Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), } } else if confirm { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9ef0d50..0ad05a6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -624,17 +624,25 @@ impl SheetApp { pub fn open_path(&mut self, path: &str) { match self.run("file.open", json!({"path": path})) { - Ok(r) => { - self.ui.recent.retain(|p| p != path); - self.ui.recent.insert(0, path.to_string()); - self.ui.recent.truncate(20); - self.show_open_warnings(&r); - } + Ok(r) => self.after_open(Some(path), &r), + // A password-protected workbook: ask for the password; the dialog opens it. + Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => self.open_dialog("openPassword", json!({"path": path})), Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), } self.after_engine(); } + /// What follows a successful `file.open`: the Recent list (for a file on disk) and the open + /// warnings notice. Shared by File › Open and the password dialog. + pub fn after_open(&mut self, path: Option<&str>, r: &Json) { + if let Some(path) = path { + self.ui.recent.retain(|p| p != path); + self.ui.recent.insert(0, path.to_string()); + self.ui.recent.truncate(20); + } + self.show_open_warnings(r); + } + /// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the /// most important one (a preserved VBA project outranks, say, a skipped optional part). /// Kept deliberately generic: any open-time warning can reach the user this way. @@ -893,12 +901,16 @@ impl SheetApp { .unwrap_or_default(); for (name, bytes) in arrived { let b64 = gridcraft_engine::io::base64_encode(&bytes); - match self.run("file.open", json!({"name": name, "base64": b64})) { + match self.run("file.open", json!({"name": &name, "base64": &b64})) { // A file picked from File › Open (web) arrives here: show it, not the Backstage. Ok(r) => { self.backstage = None; self.show_open_warnings(&r); } + Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => { + self.backstage = None; + self.open_dialog("openPassword", json!({"name": name, "base64": b64})); + } Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), } self.after_engine(); diff --git a/crates/ui-egui/tests/open_encrypted.rs b/crates/ui-egui/tests/open_encrypted.rs new file mode 100644 index 0000000..6baeac1 --- /dev/null +++ b/crates/ui-egui/tests/open_encrypted.rs @@ -0,0 +1,120 @@ +//! Opening a password-protected workbook asks for its password (#11). + +use egui::accesskit::Role; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::{Session, core::CellRef, core::Value}; +use gridcraft_ui_egui::{Inbox, Services, SheetApp}; +use serde_json::json; + +const PW: &str = "synthetic-pw-8842"; + +fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, services), + ); + h.run_steps(4); + h +} + +/// A workbook with 42 in A1, saved as `.xlsx` and encrypted with `PW`. +fn encrypted_bytes() -> Vec { + let mut s = Session::new(); + s.new_workbook(); + s.run("cell.set", json!({"cell": "A1", "input": "42"})).unwrap(); + let xlsx = gridcraft_engine::io::save_bytes(&s.active().unwrap().wb, "book.xlsx").unwrap(); + gridcraft_xlsx::testing::encrypt_workbook(&xlsx, PW) +} + +/// `encrypted_bytes()` written to `Secret.xlsx` in a fresh temp dir named after `tag`. +fn encrypted_file(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("gridcraft-encrypted-ui-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Secret.xlsx"); + std::fs::write(&path, encrypted_bytes()).unwrap(); + path +} + +fn dialog(h: &egui_kittest::Harness<'static, SheetApp>) -> Option { + h.state().dialog.as_ref().map(|d| d.name().to_string()) +} + +fn a1(h: &egui_kittest::Harness<'static, SheetApp>) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("A1").unwrap()) +} + +#[test] +fn opening_an_encrypted_file_asks_for_its_password() { + let path = encrypted_file("desktop"); + let p = path.to_string_lossy().to_string(); + let mut h = harness(Services::default()); + h.state_mut().open_path(&p); + h.run_steps(3); + assert_eq!(dialog(&h).as_deref(), Some("openPassword")); + assert!(h.state().message.is_none(), "no error box: the dialog asks instead"); + assert!(h.query_by_role(Role::PasswordInput).is_some(), "the password is masked"); + + // A wrong password says so, clears the field, and keeps the dialog and its file. + h.get_by_role(Role::PasswordInput).click(); + h.run_steps(2); + h.input_mut().events.push(egui::Event::Text("wrong-guess".into())); + h.run_steps(2); + h.get_by_label(" OK ").click(); + h.run_steps(3); + let msg = h.state().message.as_ref().map(|(_, m)| m.clone()).unwrap_or_default(); + assert!(msg.contains(gridcraft_engine::io::WRONG_PASSWORD), "{msg}"); + assert!(!msg.contains("wrong-guess"), "the password is never quoted: {msg}"); + assert_eq!(dialog(&h).as_deref(), Some("openPassword")); + assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("password")), Some(&json!(""))); + assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("path")), Some(&json!(p))); + h.state_mut().message = None; + + // The right one opens the workbook: no save path, in Recent, with the notice. + h.state_mut().dialog.as_mut().unwrap().values.insert("password".into(), json!(PW)); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert_eq!(dialog(&h), None); + assert_eq!(a1(&h), Value::Number(42.0)); + assert_eq!(h.state().session.active().unwrap().path, None); + assert_eq!(h.state().ui.recent.first(), Some(&p)); + assert!(h.state().notice.as_ref().is_some_and(|n| n.text.contains("password-protected"))); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); +} + +#[test] +fn cancel_leaves_nothing_open() { + let path = encrypted_file("cancel"); + let mut h = harness(Services::default()); + let before = h.state().session.documents().len(); + h.state_mut().open_path(&path.to_string_lossy()); + h.run_steps(3); + assert_eq!(dialog(&h).as_deref(), Some("openPassword")); + h.get_by_label(" Cancel ").click(); + h.run_steps(3); + assert_eq!(dialog(&h), None); + assert_eq!(h.state().session.documents().len(), before); + assert!(h.state().message.is_none()); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); +} + +#[test] +fn a_file_picked_on_the_web_asks_for_its_password_too() { + let inbox: Inbox = Default::default(); + let mut h = harness(Services { inbox: Some(inbox.clone()), ..Default::default() }); + inbox.lock().unwrap().push(("Secret.xlsx".into(), encrypted_bytes())); + h.run_steps(3); + assert_eq!(dialog(&h).as_deref(), Some("openPassword")); + assert!(h.state().dialog.as_ref().is_some_and(|d| d.values.contains_key("base64"))); + h.state_mut().dialog.as_mut().unwrap().values.insert("password".into(), json!(PW)); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert_eq!(dialog(&h), None); + assert_eq!(a1(&h), Value::Number(42.0)); + assert_eq!(h.state().session.active().unwrap().path, None); +}