UI: ask for the password of an encrypted workbook (#11)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lloyd 2026-10-11 23:08:09 +02:00
parent aad58d4c75
commit c882d169ff
5 changed files with 162 additions and 7 deletions

1
Cargo.lock generated
View File

@ -1619,6 +1619,7 @@ dependencies = [
"epaint_default_fonts",
"gridcraft-chart",
"gridcraft-engine",
"gridcraft-xlsx",
"image",
"log",
"serde",

View File

@ -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.

View File

@ -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 {

View File

@ -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();

View File

@ -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<u8> {
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<String> {
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);
}