diff --git a/Cargo.lock b/Cargo.lock index 65fba2f..28fe17a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -289,6 +289,20 @@ dependencies = [ "libloading", ] +[[package]] +name = "ashpd" +version = "0.13.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8421aaa9644a5faf26735f258b669b15f063313ef8f8e2bdb28912a1a6f111" +dependencies = [ + "enumflags2", + "futures-util", + "getrandom 0.4.3", + "serde", + "tokio", + "zbus", +] + [[package]] name = "askama" version = "0.16.0" @@ -4321,10 +4335,13 @@ dependencies = [ name = "ironstorage-desktop" version = "0.1.0" dependencies = [ + "ashpd", "iced", "ironstorage", "muda", + "rfd", "tempfile", + "url", "zeroize", ] @@ -6595,6 +6612,29 @@ dependencies = [ "subtle", ] +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.61.2", +] + [[package]] name = "ring" version = "0.17.14" diff --git a/Cargo.toml b/Cargo.toml index b7a55d7..ad63563 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ rust-version = "1.92" [workspace.dependencies] apple-native-keyring-store = { version = "1.0", default-features = false, features = ["keychain", "protected"] } arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] } +ashpd = { version = "0.13", default-features = false, features = ["tokio"] } cap-std = "4.0" cap-tempfile = "4.0" clap = { version = "4.6", features = ["derive"] } @@ -36,6 +37,7 @@ qrcode = { version = "0.14", default-features = false } rand = "0.8" regex = "1.13" reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] } +rfd = { version = "0.17", default-features = false } rqrr = { version = "0.10", default-features = false } rpassword = "7.5" ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] } diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 1754247..bd2a709 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -19,6 +19,7 @@ The current direct dependencies are: | [Ratatui 0.30](https://crates.io/crates/ratatui/0.30.2) | TUI | MIT | | [Iced 0.14](https://crates.io/crates/iced/0.14.0) | Desktop UI | MIT | | [muda 0.19](https://crates.io/crates/muda/0.19.3) | Safe native macOS application menus and standard roles | MIT OR Apache-2.0 | +| [rfd 0.17](https://crates.io/crates/rfd/0.17.2), [ashpd 0.13](https://crates.io/crates/ashpd/0.13.13) | Native macOS/Windows folder picker and Linux XDG portal folder picker | MIT | | [gix 0.86](https://crates.io/crates/gix/0.86.0), [gix-config 0.59](https://crates.io/crates/gix-config/0.59.0) | Embedded Git objects, index, references, fetch, and merge | MIT OR Apache-2.0 | | [reqwest 0.13](https://crates.io/crates/reqwest/0.13.4) | HTTPS smart-Git transport with Rustls | MIT OR Apache-2.0 | | [flate2 1.1](https://crates.io/crates/flate2/1.1.9), [sha1 0.10](https://crates.io/crates/sha1/0.10.7) | Git pack compression and checksums | MIT OR Apache-2.0 | @@ -55,6 +56,7 @@ decision. | TOTP and HOTP | [`hmac` 0.12](https://crates.io/crates/hmac/0.12.1), [`sha1` 0.10](https://crates.io/crates/sha1/0.10.7), [`sha2` 0.10](https://crates.io/crates/sha2/0.10.9), [`data-encoding` 2.11](https://crates.io/crates/data-encoding/2.11.1) | MIT or MIT OR Apache-2.0 | Selected for a small storage-owned implementation with RFC 4226/6238 vectors. Handled URIs retain exact bytes while decoded secrets zeroize; `totp-rs` rejects HOTP URIs and cannot cover all of `pass-otp`. | | Native desktop clipboard | [`arboard` 3.6](https://crates.io/crates/arboard/3.6.1) | MIT OR Apache-2.0 | Selected with image support disabled and Wayland data-control enabled. Storage owns timeout, cleanup, and newer-content race policy; the safe adapter provides macOS, Windows, X11, and Wayland text access without helper processes. | | Native macOS menu bar | [`muda` 0.19](https://crates.io/crates/muda/0.19.3) | MIT OR Apache-2.0 | Selected only on macOS. Its safe `NSApp` adapter supplies standard roles and accelerators; Linux and Windows use the same action registry through an in-window Iced menu bar, avoiding GTK and Win32 integration dependencies. | +| Native folder picker | [`rfd` 0.17](https://crates.io/crates/rfd/0.17.2), [`ashpd` 0.13](https://crates.io/crates/ashpd/0.13.13) | MIT | `rfd` is selected without default features for safe native macOS and Windows panels. Linux uses `ashpd` directly over the XDG Desktop Portal so the application never takes `rfd`'s `zenity` subprocess fallback. | | CLI cancellation | [`ctrlc` 3.5](https://crates.io/crates/ctrlc/3.5.2) | MIT OR Apache-2.0 | Selected for cross-platform interruption of the blocking clipboard lease. Ctrl-C requests storage cleanup before the CLI returns cancellation. | | Hidden CLI input | [`rpassword` 7.5](https://crates.io/crates/rpassword/7.5.4) | Apache-2.0 | Selected for portable terminal input with echo disabled. The CLI immediately moves returned strings into storage-owned zeroizing OTP input objects; it does not own validation or confirmation policy. | | QR output and desktop image input | [`qrcode` 0.14](https://crates.io/crates/qrcode/0.14.1), [`rqrr` 0.10](https://crates.io/crates/rqrr/0.10.1) | MIT OR Apache-2.0; second crate also includes ISC | `qrcode` selected without image features for storage-owned matrices and terminal rendering. `rqrr` is test-only round-trip verification. Apple camera scanning should use AVFoundation and pass only decoded bytes to Rust. | diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml index 4ca0a3a..51cc24e 100644 --- a/apps/desktop/Cargo.toml +++ b/apps/desktop/Cargo.toml @@ -18,5 +18,12 @@ zeroize.workspace = true [target.'cfg(target_os = "macos")'.dependencies] muda.workspace = true +[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] +rfd.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +ashpd.workspace = true +url.workspace = true + [dev-dependencies] tempfile = "3" diff --git a/apps/desktop/src/action.rs b/apps/desktop/src/action.rs index f5b149b..e9584fe 100644 --- a/apps/desktop/src/action.rs +++ b/apps/desktop/src/action.rs @@ -7,6 +7,7 @@ pub enum UiAction { About, Settings, NewEntry, + OpenFolder, OpenEntry, Save, CloseWindow, @@ -34,6 +35,7 @@ impl UiAction { Self::About => "about", Self::Settings => "settings", Self::NewEntry => "new-entry", + Self::OpenFolder => "open-folder", Self::OpenEntry => "open-entry", Self::Save => "save", Self::CloseWindow => "close-window", @@ -104,6 +106,7 @@ pub struct ActionContext { pub editing: bool, pub dirty: bool, pub saving: bool, + pub switching_vault: bool, pub focused_field: bool, pub focused_sensitive: bool, pub entry_path: bool, @@ -127,7 +130,13 @@ pub const ACTIONS: &[ActionSpec] = &[ Some("⌘Q"), ), spec(UiAction::NewEntry, MenuGroup::File, "New Entry", Some("⌘N")), - spec(UiAction::OpenEntry, MenuGroup::File, "Open", Some("⌘O")), + spec( + UiAction::OpenFolder, + MenuGroup::File, + "Open Folder…", + Some("⌘O"), + ), + spec(UiAction::OpenEntry, MenuGroup::Entry, "Open Entry", None), spec(UiAction::Save, MenuGroup::File, "Save", Some("⌘S")), spec( UiAction::CloseWindow, @@ -221,20 +230,53 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool { UiAction::About | UiAction::Settings | UiAction::Help => true, // Entry creation is not valid until the dedicated workflow exists. UiAction::NewEntry => false, - UiAction::OpenEntry => context.storage_ready && context.entry_path && !context.saving, - UiAction::Save => context.unlocked && context.editing && context.dirty && !context.saving, - UiAction::CloseWindow | UiAction::Quit | UiAction::Minimize => true, + UiAction::OpenFolder => { + context.storage_ready && !context.saving && !context.switching_vault + } + UiAction::OpenEntry => { + context.storage_ready + && context.entry_path + && !context.saving + && !context.switching_vault + } + UiAction::Save => { + context.unlocked + && context.editing + && context.dirty + && !context.saving + && !context.switching_vault + } + UiAction::CloseWindow | UiAction::Quit => !context.switching_vault, + UiAction::Minimize => true, UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste | UiAction::Find => false, UiAction::CopyField => { - context.unlocked && context.document_open && !context.editing && context.focused_field + context.unlocked + && context.document_open + && !context.editing + && !context.switching_vault + && context.focused_field + } + UiAction::CopyEditedField => { + context.unlocked && context.editing && !context.switching_vault && context.focused_field } - UiAction::CopyEditedField => context.unlocked && context.editing && context.focused_field, UiAction::TogglePaneFocus => true, - UiAction::Refresh => context.storage_ready && !context.tree_loading, - UiAction::ReloadEntry => context.unlocked && context.document_open && !context.saving, - UiAction::EditEntry => context.unlocked && context.document_open && !context.editing, + UiAction::Refresh => { + context.storage_ready && !context.tree_loading && !context.switching_vault + } + UiAction::ReloadEntry => { + context.unlocked && context.document_open && !context.saving && !context.switching_vault + } + UiAction::EditEntry => { + context.unlocked + && context.document_open + && !context.editing + && !context.switching_vault + } UiAction::ToggleReveal => { - context.unlocked && context.document_open && context.focused_sensitive + context.unlocked + && context.document_open + && !context.switching_vault + && context.focused_sensitive } UiAction::Lock => context.unlocked, } @@ -253,7 +295,7 @@ pub fn shortcut_action(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> O match key.as_ref() { keyboard::Key::Character(",") => Some(UiAction::Settings), keyboard::Key::Character("n" | "N") => Some(UiAction::NewEntry), - keyboard::Key::Character("o" | "O") => Some(UiAction::OpenEntry), + keyboard::Key::Character("o" | "O") => Some(UiAction::OpenFolder), keyboard::Key::Character("s" | "S") => Some(UiAction::Save), keyboard::Key::Character("w" | "W") => Some(UiAction::CloseWindow), keyboard::Key::Character("q" | "Q") => Some(UiAction::Quit), @@ -285,6 +327,7 @@ mod tests { editing: true, dirty: true, saving: false, + switching_vault: false, focused_field: true, focused_sensitive: true, entry_path: true, @@ -350,13 +393,37 @@ mod tests { ..ready } )); + assert!(!enabled( + UiAction::OpenFolder, + ActionContext { + switching_vault: true, + ..ready + } + )); + let switching = ActionContext { + switching_vault: true, + ..ready + }; + for action in [ + UiAction::Save, + UiAction::CloseWindow, + UiAction::Quit, + UiAction::CopyField, + UiAction::CopyEditedField, + UiAction::Refresh, + UiAction::ReloadEntry, + UiAction::EditEntry, + UiAction::ToggleReveal, + ] { + assert!(!enabled(action, switching), "{action:?}"); + } } #[test] fn conventional_shortcuts_resolve_to_the_registered_actions() { let primary = keyboard::Modifiers::COMMAND; for (key, expected) in [ - ("o", UiAction::OpenEntry), + ("o", UiAction::OpenFolder), ("s", UiAction::Save), ("f", UiAction::Find), ("n", UiAction::NewEntry), diff --git a/apps/desktop/src/folder_picker.rs b/apps/desktop/src/folder_picker.rs new file mode 100644 index 0000000..b4eb274 --- /dev/null +++ b/apps/desktop/src/folder_picker.rs @@ -0,0 +1,69 @@ +//! Platform folder pickers without helper processes. + +use std::path::PathBuf; + +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub async fn pick_folder(initial: Option) -> Result, String> { + let mut dialog = rfd::AsyncFileDialog::new().set_title("Open Password Store"); + if let Some(initial) = initial.filter(|path| path.is_dir()) { + dialog = dialog.set_directory(initial); + } + Ok(dialog + .pick_folder() + .await + .map(|folder| folder.path().to_owned())) +} + +#[cfg(target_os = "linux")] +pub async fn pick_folder(initial: Option) -> Result, String> { + use ashpd::{ + PortalError, + desktop::{file_chooser::SelectedFiles, request::ResponseError}, + }; + + let mut request = SelectedFiles::open_file() + .title("Open Password Store") + .accept_label("Open") + .modal(true) + .multiple(false) + .directory(true); + if let Some(initial) = initial.filter(|path| path.is_dir()) { + request = request + .current_folder(initial) + .map_err(|error| error.to_string())?; + } + let response = request.send().await.and_then(|request| request.response()); + let selected = match response { + Ok(selected) => selected, + Err(ashpd::Error::Response(ResponseError::Cancelled)) + | Err(ashpd::Error::Portal(PortalError::Cancelled(_))) => return Ok(None), + Err(error) => return Err(error.to_string()), + }; + selected + .uris() + .first() + .ok_or_else(|| "folder portal returned no selection".to_owned()) + .and_then(|uri| file_uri_path(uri.as_str())) + .map(Some) +} + +#[cfg(target_os = "linux")] +fn file_uri_path(uri: &str) -> Result { + let uri = url::Url::parse(uri).map_err(|_| "folder portal returned an invalid URI")?; + uri.to_file_path() + .map_err(|()| "folder portal returned a non-file URI".to_owned()) +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + + #[test] + fn portal_file_uris_are_decoded_and_other_schemes_are_rejected() { + assert_eq!( + file_uri_path("file:///tmp/password%20store").expect("file URI"), + PathBuf::from("/tmp/password store") + ); + assert!(file_uri_path("https://example.test/store").is_err()); + } +} diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index 7c1aa40..4959cb5 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -3,11 +3,13 @@ mod action; mod editor; +mod folder_picker; #[cfg(target_os = "macos")] mod native_menu; mod navigation; use std::{ + path::PathBuf, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, @@ -58,6 +60,11 @@ enum Message { ToggleMenu(MenuGroup), DismissUtility, WindowResolved(UiAction, Option), + FolderPicked(Result, String>), + VaultSwitched { + generation: u64, + result: Box>, + }, #[cfg(target_os = "macos")] PollNativeMenu, StartupLoaded(Box>), @@ -161,6 +168,7 @@ struct App { authentication_generation: u64, operation_generation: u64, tree_generation: u64, + vault_generation: u64, panes: pane_grid::State, pane_focus: PaneFocus, navigation: NavigationTree, @@ -170,6 +178,7 @@ struct App { editor: Option, content_mode: ContentMode, saving: bool, + switching_vault: bool, confirmation: Option, after_save: Option, generate_confirmation: Option, @@ -216,6 +225,7 @@ enum TreeState { #[derive(Clone, Debug, Eq, PartialEq)] enum PendingAction { + OpenVault(PathBuf), OpenEntry(String), Reload(String), CloseWindow(window::Id), @@ -259,6 +269,7 @@ impl App { authentication_generation: 0, operation_generation: 0, tree_generation: 0, + vault_generation: 0, panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { axis: pane_grid::Axis::Vertical, ratio: 0.28, @@ -273,6 +284,7 @@ impl App { editor: None, content_mode: ContentMode::Viewer, saving: false, + switching_vault: false, confirmation: None, after_save: None, generate_confirmation: None, @@ -321,6 +333,53 @@ impl App { _ => Task::none(), }; } + Message::FolderPicked(result) => match result { + Ok(Some(path)) => return self.request_action(PendingAction::OpenVault(path)), + Ok(None) => { + self.status = "Open Folder cancelled; current vault unchanged.".to_owned(); + } + Err(error) => { + self.status = + format!("Folder picker failed: {error}. Current vault unchanged."); + } + }, + Message::VaultSwitched { generation, result } => { + if generation != self.vault_generation { + return Task::none(); + } + self.switching_vault = false; + match *result { + Ok(storage) => { + let vault = storage.vault().display().to_string(); + self.operation_generation = self.operation_generation.wrapping_add(1); + self.authentication_generation = + self.authentication_generation.wrapping_add(1); + self.sensitive.clear(); + if let Some(session) = &self.session { + let _ignored = session.manual_lock(); + } + self.storage = Some(storage); + self.handle = None; + self.authentication = AuthenticationView::Locked; + self.editor = None; + self.content_mode = ContentMode::Viewer; + self.navigation = NavigationTree::default(); + self.tree_state = TreeState::Loading; + self.entry_path.clear(); + self.confirmation = None; + self.after_save = None; + self.after_authentication = None; + self.generate_confirmation = None; + self.conflict = false; + self.status = format!("Opened password store at {vault}."); + return self.begin_tree_refresh(); + } + Err(error) => { + self.status = + format!("Open Folder failed: {error}. Current vault unchanged."); + } + } + } #[cfg(target_os = "macos")] Message::PollNativeMenu => { if let Some(action) = self.native_menu.as_ref().and_then(NativeMenu::poll) { @@ -648,6 +707,7 @@ impl App { editing: self.content_mode == ContentMode::Editor, dirty: self.editor.as_ref().is_some_and(EntryEditor::is_dirty), saving: self.saving, + switching_vault: self.switching_vault, focused_field: focused.is_some(), focused_sensitive: focused .is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive), @@ -680,6 +740,14 @@ impl App { UiAction::About => self.utility = Some(UtilityView::About), UiAction::Settings => self.utility = Some(UtilityView::Settings), UiAction::Help => self.utility = Some(UtilityView::Help), + UiAction::OpenFolder => { + let initial = self + .storage + .as_ref() + .map(|storage| storage.vault().to_owned()); + self.status = "Choose a password-store folder…".to_owned(); + return Task::perform(folder_picker::pick_folder(initial), Message::FolderPicked); + } UiAction::OpenEntry => return self.update(Message::OpenEntry), UiAction::Save => return self.begin_save(), UiAction::CloseWindow | UiAction::Quit | UiAction::Minimize => { @@ -746,6 +814,11 @@ impl App { } fn request_action(&mut self, action: PendingAction) -> Task { + if self.switching_vault && matches!(action, PendingAction::CloseWindow(_)) { + self.status = + "Wait for vault validation to finish before closing IronStorage.".to_owned(); + return Task::none(); + } if self.saving { self.after_save = Some(action); self.status = "Waiting for the active save to finish…".to_owned(); @@ -764,6 +837,7 @@ impl App { fn execute_action(&mut self, action: PendingAction) -> Task { match action { + PendingAction::OpenVault(path) => self.begin_vault_switch(path), PendingAction::OpenEntry(entry) | PendingAction::Reload(entry) => { self.editor = None; self.content_mode = ContentMode::Viewer; @@ -776,6 +850,27 @@ impl App { } } + fn begin_vault_switch(&mut self, path: PathBuf) -> Task { + let Some(storage) = self.storage.clone() else { + self.status = "Load a valid shared configuration before opening a folder.".to_owned(); + return Task::none(); + }; + self.vault_generation = self.vault_generation.wrapping_add(1); + let generation = self.vault_generation; + self.switching_vault = true; + self.status = format!("Validating password store at {}…", path.display()); + Task::perform( + async move { + Box::new( + storage + .switch_vault(&path) + .map_err(|error| error.to_string()), + ) + }, + move |result| Message::VaultSwitched { generation, result }, + ) + } + fn begin_open(&mut self, entry: String) -> Task { let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { self.after_authentication = Some(PendingAction::OpenEntry(entry)); @@ -997,6 +1092,14 @@ impl App { column![ row![ text(authentication), + text( + self.storage + .as_ref() + .map_or("No configured vault".to_owned(), |storage| { + storage.vault().display().to_string() + }) + ) + .size(13), text(&self.status).size(14), text("Tab changes pane focus").size(12), ] @@ -1074,7 +1177,23 @@ fn utility_view(app: &App, utility: UtilityView) -> Element<'_, Message> { UtilityView::Settings => { content = content.push(text("Settings").size(28)); if let Some(storage) = &app.storage { + let editor = storage.configured_editor().map_or_else( + || "Environment or built-in fallback".to_owned(), + |editor| { + std::iter::once(editor.program()) + .chain(editor.arguments().iter().map(String::as_str)) + .collect::>() + .join(" ") + }, + ); content = content + .push(text(format!( + "Configuration: {}", + storage.config_source().display() + ))) + .push(text(format!("Vault: {}", storage.vault().display()))) + .push(text(format!("Default key: {}", storage.default_key()))) + .push(text(format!("Editor: {editor}"))) .push(text(format!( "Authentication inactivity timeout: {} seconds", storage.authentication_timeout().duration().as_secs() @@ -1202,12 +1321,19 @@ fn content_view(app: &App) -> Element<'_, Message> { .on_input(Message::EntryPathChanged) .on_submit(Message::Action(UiAction::OpenEntry)), button("Open").on_press(Message::Action(UiAction::OpenEntry)), + button("Open Folder…").on_press(Message::Action(UiAction::OpenFolder)), button("Reload").on_press(Message::Action(UiAction::ReloadEntry)), button("Lock").on_press(Message::Action(UiAction::Lock)), ] .spacing(8); - let body: Element<'_, Message> = if !authentication_allows_content(&app.authentication) { + let body: Element<'_, Message> = if app.switching_vault { + container(text( + "Validating the selected password store and shared configuration…", + )) + .center(Length::Fill) + .into() + } else if !authentication_allows_content(&app.authentication) { container(text( "Protected entry content is locked. Select an entry to authenticate and open it.", )) @@ -1458,6 +1584,7 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> { fn confirmation_view(action: &PendingAction) -> Element<'_, Message> { let description = match action { + PendingAction::OpenVault(path) => format!("Open {}", path.display()), PendingAction::OpenEntry(entry) => format!("Open {entry}"), PendingAction::Reload(entry) => format!("Reload {entry}"), PendingAction::CloseWindow(_) => "Close IronStorage".to_owned(), @@ -1790,6 +1917,7 @@ mod tests { authentication_generation: 0, operation_generation: 0, tree_generation: 0, + vault_generation: 0, panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { axis: pane_grid::Axis::Vertical, ratio: 0.28, @@ -1804,6 +1932,7 @@ mod tests { editor, content_mode: ContentMode::Viewer, saving: false, + switching_vault: false, confirmation: None, after_save: None, generate_confirmation: None, @@ -1968,13 +2097,15 @@ mod tests { editor.add_after(None).expect("line"); assert_eq!(dirty_decision(Some(&editor)), DirtyDecision::Confirm); for action in [ + PendingAction::OpenVault(PathBuf::from("/selected-vault")), PendingAction::OpenEntry("other".to_owned()), PendingAction::Reload("draft".to_owned()), PendingAction::CloseWindow(window::Id::unique()), ] { assert!(matches!( action, - PendingAction::OpenEntry(_) + PendingAction::OpenVault(_) + | PendingAction::OpenEntry(_) | PendingAction::Reload(_) | PendingAction::CloseWindow(_) )); @@ -1984,6 +2115,18 @@ mod tests { let draft = editor.document().serialize().expose().to_vec(); let mut app = test_app(Some(editor)); + let _task = app.update(Message::FolderPicked(Ok(None))); + assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty)); + let selected_vault = PathBuf::from("/selected-vault"); + let _task = app.update(Message::FolderPicked(Ok(Some(selected_vault.clone())))); + assert_eq!( + app.confirmation, + Some(PendingAction::OpenVault(selected_vault)) + ); + assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty)); + let _task = app.update(Message::CancelDiscard); + assert!(app.confirmation.is_none()); + let draft_id = TreeNodeId::Entry(EntryPath::parse("draft").expect("draft path")); let other_id = TreeNodeId::Entry(EntryPath::parse("other").expect("other path")); app.navigation.replace_test_nodes(vec![ @@ -2044,6 +2187,49 @@ mod tests { let _task = app.update(Message::WindowResolved(UiAction::CloseWindow, Some(id))); assert_eq!(app.confirmation, Some(PendingAction::CloseWindow(id))); assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty)); + + app.confirmation = None; + app.switching_vault = true; + let _task = app.update(Message::RequestClose(id)); + assert!(app.confirmation.is_none()); + assert!(app.status.contains("Wait for vault validation")); + } + + #[test] + fn vault_switch_result_replaces_state_only_after_storage_success() { + let (_temporary, storage) = fixture_storage(); + let mut failed = test_app(None); + failed.storage = Some(storage.clone()); + failed.switching_vault = true; + failed.vault_generation = 4; + let original = failed.storage.as_ref().expect("storage").vault().to_owned(); + let _task = failed.update(Message::VaultSwitched { + generation: 4, + result: Box::new(Err("injected failure".to_owned())), + }); + assert_eq!(failed.storage.as_ref().expect("storage").vault(), original); + assert!(!failed.switching_vault); + assert!(failed.status.contains("Current vault unchanged")); + + let mut editor = empty_editor(&storage, "draft"); + editor.add_after(None).expect("dirty line"); + let mut succeeded = test_app(Some(editor)); + succeeded.authentication = AuthenticationView::Unlocked(Duration::from_secs(60)); + succeeded.switching_vault = true; + succeeded.vault_generation = 5; + succeeded.operation_generation = 8; + let _task = succeeded.update(Message::VaultSwitched { + generation: 5, + result: Box::new(Ok(storage)), + }); + assert!(succeeded.editor.is_none()); + assert!(matches!( + succeeded.authentication, + AuthenticationView::Locked + )); + assert_eq!(succeeded.tree_state, TreeState::Loading); + assert!(!succeeded.switching_vault); + assert_eq!(succeeded.operation_generation, 9); } #[test] diff --git a/apps/desktop/src/native_menu.rs b/apps/desktop/src/native_menu.rs index 400ea8e..7da36e1 100644 --- a/apps/desktop/src/native_menu.rs +++ b/apps/desktop/src/native_menu.rs @@ -116,7 +116,7 @@ fn accelerator(action: UiAction) -> Option { let (modifiers, code) = match action { UiAction::Settings => (command, Code::Comma), UiAction::NewEntry => (command, Code::KeyN), - UiAction::OpenEntry => (command, Code::KeyO), + UiAction::OpenFolder => (command, Code::KeyO), UiAction::Save => (command, Code::KeyS), UiAction::CloseWindow => (command, Code::KeyW), UiAction::Quit => (command, Code::KeyQ), @@ -132,6 +132,7 @@ fn accelerator(action: UiAction) -> Option { | UiAction::CopyField | UiAction::CopyEditedField | UiAction::TogglePaneFocus + | UiAction::OpenEntry | UiAction::ReloadEntry | UiAction::EditEntry | UiAction::ToggleReveal diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index 7e82e5b..904518e 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -5,10 +5,13 @@ use std::{ env, error::Error, fmt, fs, + io::Write, path::{Component, Path, PathBuf}, time::Duration, }; +use cap_std::{ambient_authority, fs::Dir}; +use cap_tempfile::TempFile; use serde::Deserialize; use url::Url; @@ -20,9 +23,10 @@ const CONFIG_FILE: &str = "config.toml"; const MAX_CONFIG_BYTES: u64 = 1024 * 1024; /// Validated application configuration. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct Config { source: PathBuf, + document: toml::Value, vault: PathBuf, default_key: KeyIdentity, key_material: PathBuf, @@ -32,6 +36,53 @@ pub struct Config { git_remotes: Vec, } +// Accepted fields contain no floating-point TOML values, so validated documents +// preserve the equivalence relation required by `Eq`. +impl Eq for Config {} + +/// Editable, secret-free values shared by every frontend. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfigSettings { + vault: PathBuf, + default_key: String, + editor: Option>, + authentication_timeout: Duration, +} + +impl ConfigSettings { + pub fn vault(&self) -> &Path { + &self.vault + } + + pub fn set_vault(&mut self, vault: PathBuf) { + self.vault = vault; + } + + pub fn default_key(&self) -> &str { + &self.default_key + } + + pub fn set_default_key(&mut self, default_key: String) { + self.default_key = default_key; + } + + pub fn editor(&self) -> Option<&[String]> { + self.editor.as_deref() + } + + pub fn set_editor(&mut self, editor: Option>) { + self.editor = editor; + } + + pub fn authentication_timeout(&self) -> Duration { + self.authentication_timeout + } + + pub fn set_authentication_timeout(&mut self, timeout: Duration) { + self.authentication_timeout = timeout; + } +} + impl Config { /// Load an explicit configuration file, or the native per-user default. pub fn load(explicit: Option<&Path>) -> Result { @@ -71,6 +122,20 @@ impl Config { &self.git_remotes } + pub fn settings(&self) -> ConfigSettings { + let editor = self.editor.as_ref().map(|editor| { + std::iter::once(editor.program.clone()) + .chain(editor.arguments.iter().cloned()) + .collect() + }); + ConfigSettings { + vault: self.vault.clone(), + default_key: self.default_key.0.clone(), + editor, + authentication_timeout: self.authentication_timeout.duration(), + } + } + /// Select a configured remote by name, or the configured default (first /// remote) when no name was requested. pub fn git_remote(&self, requested: Option<&str>) -> Option<&GitRemote> { @@ -123,6 +188,100 @@ impl Config { source: EditorSource::Fallback, }) } + + pub(crate) fn with_settings(&self, settings: ConfigSettings) -> Result { + let vault = + fs::canonicalize(&settings.vault).map_err(|_| ConfigError::VaultUnavailable { + path: settings.vault.clone(), + })?; + if !vault.is_dir() { + return Err(ConfigError::VaultIsNotDirectory { path: vault }); + } + let vault = vault + .to_str() + .ok_or(ConfigError::InvalidField { field: "vault" })?; + let mut document = self.document.clone(); + let root = document + .as_table_mut() + .ok_or_else(|| ConfigError::Malformed { + path: self.source.clone(), + })?; + root.insert("vault".to_owned(), toml::Value::String(vault.to_owned())); + root.insert( + "default_key".to_owned(), + toml::Value::String(settings.default_key), + ); + match settings.editor { + Some(editor) => { + root.insert( + "editor".to_owned(), + toml::Value::Array(editor.into_iter().map(toml::Value::String).collect()), + ); + } + None => { + root.remove("editor"); + } + } + if settings.authentication_timeout.subsec_nanos() != 0 { + return Err(ConfigError::InvalidField { + field: "security.inactivity_timeout_seconds", + }); + } + let authentication_timeout = i64::try_from(settings.authentication_timeout.as_secs()) + .map_err(|_| ConfigError::InvalidField { + field: "security.inactivity_timeout_seconds", + })?; + let security = root + .entry("security") + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .ok_or(ConfigError::InvalidField { field: "security" })?; + security.insert( + "inactivity_timeout_seconds".to_owned(), + toml::Value::Integer(authentication_timeout), + ); + let raw = document + .clone() + .try_into::() + .map_err(|_| ConfigError::Malformed { + path: self.source.clone(), + })?; + validate_config(self.source.clone(), document, raw) + } + + pub(crate) fn persist(&self) -> Result<(), ConfigError> { + let parent = self.source.parent().ok_or_else(|| ConfigError::Write { + path: self.source.clone(), + })?; + let name = self.source.file_name().ok_or_else(|| ConfigError::Write { + path: self.source.clone(), + })?; + let directory = + Dir::open_ambient_dir(parent, ambient_authority()).map_err(|_| ConfigError::Write { + path: self.source.clone(), + })?; + let permissions = directory + .metadata(name) + .map_err(|_| ConfigError::Write { + path: self.source.clone(), + })? + .permissions(); + let contents = toml::to_string_pretty(&self.document).map_err(|_| ConfigError::Write { + path: self.source.clone(), + })?; + let mut temporary = TempFile::new(&directory).map_err(|_| ConfigError::Write { + path: self.source.clone(), + })?; + temporary + .as_file() + .set_permissions(permissions) + .and_then(|()| temporary.write_all(contents.as_bytes())) + .and_then(|()| temporary.as_file().sync_all()) + .and_then(|()| temporary.replace(name)) + .map_err(|_| ConfigError::Write { + path: self.source.clone(), + }) + } } /// Deterministic path context for configuration loading. @@ -186,11 +345,12 @@ impl ConfigLoader { reject_insecure_fields(&value, "")?; validate_known_fields(&value, &source)?; let raw = value + .clone() .try_into::() .map_err(|_| ConfigError::Malformed { path: source.clone(), })?; - validate_config(source, raw) + validate_config(source, value, raw) } } @@ -311,7 +471,9 @@ pub enum ConfigError { InsecureField { field: String }, MissingField { field: &'static str }, InvalidField { field: &'static str }, + VaultUnavailable { path: PathBuf }, VaultIsNotDirectory { path: PathBuf }, + Write { path: PathBuf }, KeyMaterialNotFound { path: PathBuf }, InvalidKeyMaterial { path: PathBuf }, DuplicateRemote { name: String }, @@ -363,6 +525,11 @@ impl fmt::Display for ConfigError { Self::InvalidField { field } => { write!(formatter, "invalid configuration field: {field}") } + Self::VaultUnavailable { path } => write!( + formatter, + "vault folder is missing or inaccessible: {}", + path.display() + ), Self::VaultIsNotDirectory { path } => { write!( formatter, @@ -370,6 +537,13 @@ impl fmt::Display for ConfigError { path.display() ) } + Self::Write { path } => { + write!( + formatter, + "cannot update configuration file: {}", + path.display() + ) + } Self::KeyMaterialNotFound { path } => write!( formatter, "exported key material does not exist: {}", @@ -463,7 +637,11 @@ struct RawGitRemote { application_id: String, } -fn validate_config(source: PathBuf, raw: RawConfig) -> Result { +fn validate_config( + source: PathBuf, + document: toml::Value, + raw: RawConfig, +) -> Result { let base = source .parent() .ok_or(ConfigError::InvalidField { field: "source" })?; @@ -512,6 +690,7 @@ fn validate_config(source: PathBuf, raw: RawConfig) -> Result &Path { + self.config.vault() + } + + pub fn config_source(&self) -> &Path { + self.config.source() + } + + pub fn default_key(&self) -> &str { + self.config.default_key().as_str() + } + + pub fn configured_editor(&self) -> Option<&EditorCommand> { + self.config.configured_editor() + } + + pub fn settings(&self) -> ConfigSettings { + self.config.settings() + } + + pub fn update_settings(&self, mut settings: ConfigSettings) -> Result { + let repository = Repository::open(settings.vault()) + .map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))?; + settings.set_vault(repository.root_path().to_owned()); + let config = self + .config + .with_settings(settings) + .map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))?; + let storage = Self { config }; + let keys = storage.keys()?; + keys.resolve(storage.config.default_key().as_str()) + .map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?; + storage.tree()?; + storage + .config + .persist() + .map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))?; + Ok(storage) + } + + /// Validate a selected folder through the same repository and key path as + /// normal desktop reads, then atomically update the shared configuration. + pub fn switch_vault(&self, vault: &Path) -> Result { + let mut settings = self.settings(); + settings.set_vault(vault.to_owned()); + self.update_settings(settings) + } + pub fn tree(&self) -> Result { let repository = self.repository()?; let keys = self.keys()?; diff --git a/crates/storage/tests/config_contract.rs b/crates/storage/tests/config_contract.rs index d606943..e339107 100644 --- a/crates/storage/tests/config_contract.rs +++ b/crates/storage/tests/config_contract.rs @@ -6,6 +6,7 @@ use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT; use ironstorage::{ authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT}, config::{ConfigError, ConfigLoader, EditorSource}, + desktop::DesktopStorage, }; use tempfile::TempDir; @@ -177,6 +178,97 @@ fn native_default_path_is_used_without_an_explicit_path() -> TestResult { Ok(()) } +#[test] +fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult { + let fixture = ConfigurationFixture::new()?; + fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?; + let selected = fixture.temporary.path().join("selected-vault"); + fs::create_dir(&selected)?; + let keys = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/compatibility/keys"); + let contents = fixture + .valid_contents() + .replace( + "0123456789ABCDEF0123456789ABCDEF01234567", + "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30", + ) + .replace( + "key_material = \"keys\"", + &format!("key_material = {keys:?}"), + ) + .replace( + "editor = [\"code\", \"--wait\"]", + "editor = [\"code\", \"--wait\"]\n[security]\ninactivity_timeout_seconds = 300", + ); + fixture.write_explicit(&contents)?; + + let storage = DesktopStorage::load(Some(&fixture.explicit_path()))?; + let switched = storage.switch_vault(&selected)?; + assert_eq!(switched.vault(), fs::canonicalize(&selected)?); + + let mut settings = switched.settings(); + settings.set_default_key("B37027B56FC406BD3F6A622B2AC03492B992D06F".to_owned()); + settings.set_editor(Some(vec!["nano".to_owned(), "-w".to_owned()])); + settings.set_authentication_timeout(Duration::from_secs(600)); + let updated = switched.update_settings(settings)?; + + let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?; + assert_eq!(reloaded.vault(), fs::canonicalize(&selected)?); + assert_eq!( + reloaded.default_key().as_str(), + "B37027B56FC406BD3F6A622B2AC03492B992D06F" + ); + assert_eq!( + reloaded.authentication_timeout().duration(), + Duration::from_secs(600) + ); + let editor = reloaded.configured_editor().expect("configured editor"); + assert_eq!(editor.program(), "nano"); + assert_eq!(editor.arguments(), ["-w"]); + assert_eq!(reloaded.git_remotes().len(), 1); + + let before_rejection = fs::read(fixture.explicit_path())?; + let mut invalid = updated.settings(); + invalid.set_default_key("missing-key".to_owned()); + assert!(updated.update_settings(invalid).is_err()); + assert_eq!(fs::read(fixture.explicit_path())?, before_rejection); + Ok(()) +} + +#[test] +fn rejected_vault_switches_leave_configuration_and_session_unchanged() -> TestResult { + let fixture = ConfigurationFixture::new()?; + fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?; + fixture.write_explicit(fixture.valid_contents())?; + let before = fs::read(fixture.explicit_path())?; + let storage = DesktopStorage::load(Some(&fixture.explicit_path()))?; + let current = storage.vault().to_owned(); + + for invalid in [ + fixture.temporary.path().join("missing"), + fixture.temporary.path().join("ordinary-file"), + ] { + if invalid.file_name() == Some(OsStr::new("ordinary-file")) { + fs::write(&invalid, b"not a folder")?; + } + assert!(storage.switch_vault(&invalid).is_err()); + assert_eq!(storage.vault(), current); + assert_eq!(fs::read(fixture.explicit_path())?, before); + } + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + + let target = fixture.temporary.path().join("symlink-target"); + let link = fixture.temporary.path().join("symlink-vault"); + fs::create_dir(&target)?; + symlink(&target, &link)?; + assert!(storage.switch_vault(&link).is_err()); + assert_eq!(storage.vault(), current); + assert_eq!(fs::read(fixture.explicit_path())?, before); + } + Ok(()) +} + #[test] fn editor_precedence_and_argument_splitting_are_storage_owned() -> TestResult { let fixture = ConfigurationFixture::new()?;