Open configured vault folders

This commit is contained in:
2026-08-10 17:21:35 +02:00
parent c301d9a3df
commit 491d7b1557
11 changed files with 712 additions and 19 deletions

40
Cargo.lock generated
View File

@@ -289,6 +289,20 @@ dependencies = [
"libloading", "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]] [[package]]
name = "askama" name = "askama"
version = "0.16.0" version = "0.16.0"
@@ -4321,10 +4335,13 @@ dependencies = [
name = "ironstorage-desktop" name = "ironstorage-desktop"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ashpd",
"iced", "iced",
"ironstorage", "ironstorage",
"muda", "muda",
"rfd",
"tempfile", "tempfile",
"url",
"zeroize", "zeroize",
] ]
@@ -6595,6 +6612,29 @@ dependencies = [
"subtle", "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]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"

View File

@@ -16,6 +16,7 @@ rust-version = "1.92"
[workspace.dependencies] [workspace.dependencies]
apple-native-keyring-store = { version = "1.0", default-features = false, features = ["keychain", "protected"] } apple-native-keyring-store = { version = "1.0", default-features = false, features = ["keychain", "protected"] }
arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] } 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-std = "4.0"
cap-tempfile = "4.0" cap-tempfile = "4.0"
clap = { version = "4.6", features = ["derive"] } clap = { version = "4.6", features = ["derive"] }
@@ -36,6 +37,7 @@ qrcode = { version = "0.14", default-features = false }
rand = "0.8" rand = "0.8"
regex = "1.13" regex = "1.13"
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] } reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }
rfd = { version = "0.17", default-features = false }
rqrr = { version = "0.10", default-features = false } rqrr = { version = "0.10", default-features = false }
rpassword = "7.5" rpassword = "7.5"
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] } ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] }

View File

@@ -19,6 +19,7 @@ The current direct dependencies are:
| [Ratatui 0.30](https://crates.io/crates/ratatui/0.30.2) | TUI | MIT | | [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 | | [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 | | [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 | | [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 | | [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 | | [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`. | | 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 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 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. | | 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. | | 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. | | 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. |

View File

@@ -18,5 +18,12 @@ zeroize.workspace = true
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
muda.workspace = true 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] [dev-dependencies]
tempfile = "3" tempfile = "3"

View File

@@ -7,6 +7,7 @@ pub enum UiAction {
About, About,
Settings, Settings,
NewEntry, NewEntry,
OpenFolder,
OpenEntry, OpenEntry,
Save, Save,
CloseWindow, CloseWindow,
@@ -34,6 +35,7 @@ impl UiAction {
Self::About => "about", Self::About => "about",
Self::Settings => "settings", Self::Settings => "settings",
Self::NewEntry => "new-entry", Self::NewEntry => "new-entry",
Self::OpenFolder => "open-folder",
Self::OpenEntry => "open-entry", Self::OpenEntry => "open-entry",
Self::Save => "save", Self::Save => "save",
Self::CloseWindow => "close-window", Self::CloseWindow => "close-window",
@@ -104,6 +106,7 @@ pub struct ActionContext {
pub editing: bool, pub editing: bool,
pub dirty: bool, pub dirty: bool,
pub saving: bool, pub saving: bool,
pub switching_vault: bool,
pub focused_field: bool, pub focused_field: bool,
pub focused_sensitive: bool, pub focused_sensitive: bool,
pub entry_path: bool, pub entry_path: bool,
@@ -127,7 +130,13 @@ pub const ACTIONS: &[ActionSpec] = &[
Some("⌘Q"), Some("⌘Q"),
), ),
spec(UiAction::NewEntry, MenuGroup::File, "New Entry", Some("⌘N")), 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::Save, MenuGroup::File, "Save", Some("⌘S")),
spec( spec(
UiAction::CloseWindow, UiAction::CloseWindow,
@@ -221,20 +230,53 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
UiAction::About | UiAction::Settings | UiAction::Help => true, UiAction::About | UiAction::Settings | UiAction::Help => true,
// Entry creation is not valid until the dedicated workflow exists. // Entry creation is not valid until the dedicated workflow exists.
UiAction::NewEntry => false, UiAction::NewEntry => false,
UiAction::OpenEntry => context.storage_ready && context.entry_path && !context.saving, UiAction::OpenFolder => {
UiAction::Save => context.unlocked && context.editing && context.dirty && !context.saving, context.storage_ready && !context.saving && !context.switching_vault
UiAction::CloseWindow | UiAction::Quit | UiAction::Minimize => true, }
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::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste | UiAction::Find => false,
UiAction::CopyField => { 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::TogglePaneFocus => true,
UiAction::Refresh => context.storage_ready && !context.tree_loading, UiAction::Refresh => {
UiAction::ReloadEntry => context.unlocked && context.document_open && !context.saving, context.storage_ready && !context.tree_loading && !context.switching_vault
UiAction::EditEntry => context.unlocked && context.document_open && !context.editing, }
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 => { 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, UiAction::Lock => context.unlocked,
} }
@@ -253,7 +295,7 @@ pub fn shortcut_action(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> O
match key.as_ref() { match key.as_ref() {
keyboard::Key::Character(",") => Some(UiAction::Settings), keyboard::Key::Character(",") => Some(UiAction::Settings),
keyboard::Key::Character("n" | "N") => Some(UiAction::NewEntry), 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("s" | "S") => Some(UiAction::Save),
keyboard::Key::Character("w" | "W") => Some(UiAction::CloseWindow), keyboard::Key::Character("w" | "W") => Some(UiAction::CloseWindow),
keyboard::Key::Character("q" | "Q") => Some(UiAction::Quit), keyboard::Key::Character("q" | "Q") => Some(UiAction::Quit),
@@ -285,6 +327,7 @@ mod tests {
editing: true, editing: true,
dirty: true, dirty: true,
saving: false, saving: false,
switching_vault: false,
focused_field: true, focused_field: true,
focused_sensitive: true, focused_sensitive: true,
entry_path: true, entry_path: true,
@@ -350,13 +393,37 @@ mod tests {
..ready ..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] #[test]
fn conventional_shortcuts_resolve_to_the_registered_actions() { fn conventional_shortcuts_resolve_to_the_registered_actions() {
let primary = keyboard::Modifiers::COMMAND; let primary = keyboard::Modifiers::COMMAND;
for (key, expected) in [ for (key, expected) in [
("o", UiAction::OpenEntry), ("o", UiAction::OpenFolder),
("s", UiAction::Save), ("s", UiAction::Save),
("f", UiAction::Find), ("f", UiAction::Find),
("n", UiAction::NewEntry), ("n", UiAction::NewEntry),

View File

@@ -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<PathBuf>) -> Result<Option<PathBuf>, 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<PathBuf>) -> Result<Option<PathBuf>, 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<PathBuf, String> {
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());
}
}

View File

@@ -3,11 +3,13 @@
mod action; mod action;
mod editor; mod editor;
mod folder_picker;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod native_menu; mod native_menu;
mod navigation; mod navigation;
use std::{ use std::{
path::PathBuf,
sync::{ sync::{
Arc, Mutex, Arc, Mutex,
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
@@ -58,6 +60,11 @@ enum Message {
ToggleMenu(MenuGroup), ToggleMenu(MenuGroup),
DismissUtility, DismissUtility,
WindowResolved(UiAction, Option<window::Id>), WindowResolved(UiAction, Option<window::Id>),
FolderPicked(Result<Option<PathBuf>, String>),
VaultSwitched {
generation: u64,
result: Box<Result<DesktopStorage, String>>,
},
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
PollNativeMenu, PollNativeMenu,
StartupLoaded(Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>), StartupLoaded(Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>),
@@ -161,6 +168,7 @@ struct App {
authentication_generation: u64, authentication_generation: u64,
operation_generation: u64, operation_generation: u64,
tree_generation: u64, tree_generation: u64,
vault_generation: u64,
panes: pane_grid::State<PaneKind>, panes: pane_grid::State<PaneKind>,
pane_focus: PaneFocus, pane_focus: PaneFocus,
navigation: NavigationTree, navigation: NavigationTree,
@@ -170,6 +178,7 @@ struct App {
editor: Option<EntryEditor>, editor: Option<EntryEditor>,
content_mode: ContentMode, content_mode: ContentMode,
saving: bool, saving: bool,
switching_vault: bool,
confirmation: Option<PendingAction>, confirmation: Option<PendingAction>,
after_save: Option<PendingAction>, after_save: Option<PendingAction>,
generate_confirmation: Option<EntryFieldId>, generate_confirmation: Option<EntryFieldId>,
@@ -216,6 +225,7 @@ enum TreeState {
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
enum PendingAction { enum PendingAction {
OpenVault(PathBuf),
OpenEntry(String), OpenEntry(String),
Reload(String), Reload(String),
CloseWindow(window::Id), CloseWindow(window::Id),
@@ -259,6 +269,7 @@ impl App {
authentication_generation: 0, authentication_generation: 0,
operation_generation: 0, operation_generation: 0,
tree_generation: 0, tree_generation: 0,
vault_generation: 0,
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
axis: pane_grid::Axis::Vertical, axis: pane_grid::Axis::Vertical,
ratio: 0.28, ratio: 0.28,
@@ -273,6 +284,7 @@ impl App {
editor: None, editor: None,
content_mode: ContentMode::Viewer, content_mode: ContentMode::Viewer,
saving: false, saving: false,
switching_vault: false,
confirmation: None, confirmation: None,
after_save: None, after_save: None,
generate_confirmation: None, generate_confirmation: None,
@@ -321,6 +333,53 @@ impl App {
_ => Task::none(), _ => 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")] #[cfg(target_os = "macos")]
Message::PollNativeMenu => { Message::PollNativeMenu => {
if let Some(action) = self.native_menu.as_ref().and_then(NativeMenu::poll) { 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, editing: self.content_mode == ContentMode::Editor,
dirty: self.editor.as_ref().is_some_and(EntryEditor::is_dirty), dirty: self.editor.as_ref().is_some_and(EntryEditor::is_dirty),
saving: self.saving, saving: self.saving,
switching_vault: self.switching_vault,
focused_field: focused.is_some(), focused_field: focused.is_some(),
focused_sensitive: focused focused_sensitive: focused
.is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive), .is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive),
@@ -680,6 +740,14 @@ impl App {
UiAction::About => self.utility = Some(UtilityView::About), UiAction::About => self.utility = Some(UtilityView::About),
UiAction::Settings => self.utility = Some(UtilityView::Settings), UiAction::Settings => self.utility = Some(UtilityView::Settings),
UiAction::Help => self.utility = Some(UtilityView::Help), 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::OpenEntry => return self.update(Message::OpenEntry),
UiAction::Save => return self.begin_save(), UiAction::Save => return self.begin_save(),
UiAction::CloseWindow | UiAction::Quit | UiAction::Minimize => { UiAction::CloseWindow | UiAction::Quit | UiAction::Minimize => {
@@ -746,6 +814,11 @@ impl App {
} }
fn request_action(&mut self, action: PendingAction) -> Task<Message> { fn request_action(&mut self, action: PendingAction) -> Task<Message> {
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 { if self.saving {
self.after_save = Some(action); self.after_save = Some(action);
self.status = "Waiting for the active save to finish…".to_owned(); 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<Message> { fn execute_action(&mut self, action: PendingAction) -> Task<Message> {
match action { match action {
PendingAction::OpenVault(path) => self.begin_vault_switch(path),
PendingAction::OpenEntry(entry) | PendingAction::Reload(entry) => { PendingAction::OpenEntry(entry) | PendingAction::Reload(entry) => {
self.editor = None; self.editor = None;
self.content_mode = ContentMode::Viewer; self.content_mode = ContentMode::Viewer;
@@ -776,6 +850,27 @@ impl App {
} }
} }
fn begin_vault_switch(&mut self, path: PathBuf) -> Task<Message> {
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<Message> { fn begin_open(&mut self, entry: String) -> Task<Message> {
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
self.after_authentication = Some(PendingAction::OpenEntry(entry)); self.after_authentication = Some(PendingAction::OpenEntry(entry));
@@ -997,6 +1092,14 @@ impl App {
column![ column![
row![ row![
text(authentication), 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(&self.status).size(14),
text("Tab changes pane focus").size(12), text("Tab changes pane focus").size(12),
] ]
@@ -1074,7 +1177,23 @@ fn utility_view(app: &App, utility: UtilityView) -> Element<'_, Message> {
UtilityView::Settings => { UtilityView::Settings => {
content = content.push(text("Settings").size(28)); content = content.push(text("Settings").size(28));
if let Some(storage) = &app.storage { 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::<Vec<_>>()
.join(" ")
},
);
content = content 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!( .push(text(format!(
"Authentication inactivity timeout: {} seconds", "Authentication inactivity timeout: {} seconds",
storage.authentication_timeout().duration().as_secs() storage.authentication_timeout().duration().as_secs()
@@ -1202,12 +1321,19 @@ fn content_view(app: &App) -> Element<'_, Message> {
.on_input(Message::EntryPathChanged) .on_input(Message::EntryPathChanged)
.on_submit(Message::Action(UiAction::OpenEntry)), .on_submit(Message::Action(UiAction::OpenEntry)),
button("Open").on_press(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("Reload").on_press(Message::Action(UiAction::ReloadEntry)),
button("Lock").on_press(Message::Action(UiAction::Lock)), button("Lock").on_press(Message::Action(UiAction::Lock)),
] ]
.spacing(8); .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( container(text(
"Protected entry content is locked. Select an entry to authenticate and open it.", "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> { fn confirmation_view(action: &PendingAction) -> Element<'_, Message> {
let description = match action { let description = match action {
PendingAction::OpenVault(path) => format!("Open {}", path.display()),
PendingAction::OpenEntry(entry) => format!("Open {entry}"), PendingAction::OpenEntry(entry) => format!("Open {entry}"),
PendingAction::Reload(entry) => format!("Reload {entry}"), PendingAction::Reload(entry) => format!("Reload {entry}"),
PendingAction::CloseWindow(_) => "Close IronStorage".to_owned(), PendingAction::CloseWindow(_) => "Close IronStorage".to_owned(),
@@ -1790,6 +1917,7 @@ mod tests {
authentication_generation: 0, authentication_generation: 0,
operation_generation: 0, operation_generation: 0,
tree_generation: 0, tree_generation: 0,
vault_generation: 0,
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
axis: pane_grid::Axis::Vertical, axis: pane_grid::Axis::Vertical,
ratio: 0.28, ratio: 0.28,
@@ -1804,6 +1932,7 @@ mod tests {
editor, editor,
content_mode: ContentMode::Viewer, content_mode: ContentMode::Viewer,
saving: false, saving: false,
switching_vault: false,
confirmation: None, confirmation: None,
after_save: None, after_save: None,
generate_confirmation: None, generate_confirmation: None,
@@ -1968,13 +2097,15 @@ mod tests {
editor.add_after(None).expect("line"); editor.add_after(None).expect("line");
assert_eq!(dirty_decision(Some(&editor)), DirtyDecision::Confirm); assert_eq!(dirty_decision(Some(&editor)), DirtyDecision::Confirm);
for action in [ for action in [
PendingAction::OpenVault(PathBuf::from("/selected-vault")),
PendingAction::OpenEntry("other".to_owned()), PendingAction::OpenEntry("other".to_owned()),
PendingAction::Reload("draft".to_owned()), PendingAction::Reload("draft".to_owned()),
PendingAction::CloseWindow(window::Id::unique()), PendingAction::CloseWindow(window::Id::unique()),
] { ] {
assert!(matches!( assert!(matches!(
action, action,
PendingAction::OpenEntry(_) PendingAction::OpenVault(_)
| PendingAction::OpenEntry(_)
| PendingAction::Reload(_) | PendingAction::Reload(_)
| PendingAction::CloseWindow(_) | PendingAction::CloseWindow(_)
)); ));
@@ -1984,6 +2115,18 @@ mod tests {
let draft = editor.document().serialize().expose().to_vec(); let draft = editor.document().serialize().expose().to_vec();
let mut app = test_app(Some(editor)); 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 draft_id = TreeNodeId::Entry(EntryPath::parse("draft").expect("draft path"));
let other_id = TreeNodeId::Entry(EntryPath::parse("other").expect("other path")); let other_id = TreeNodeId::Entry(EntryPath::parse("other").expect("other path"));
app.navigation.replace_test_nodes(vec![ app.navigation.replace_test_nodes(vec![
@@ -2044,6 +2187,49 @@ mod tests {
let _task = app.update(Message::WindowResolved(UiAction::CloseWindow, Some(id))); let _task = app.update(Message::WindowResolved(UiAction::CloseWindow, Some(id)));
assert_eq!(app.confirmation, Some(PendingAction::CloseWindow(id))); assert_eq!(app.confirmation, Some(PendingAction::CloseWindow(id)));
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty)); 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] #[test]

View File

@@ -116,7 +116,7 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
let (modifiers, code) = match action { let (modifiers, code) = match action {
UiAction::Settings => (command, Code::Comma), UiAction::Settings => (command, Code::Comma),
UiAction::NewEntry => (command, Code::KeyN), UiAction::NewEntry => (command, Code::KeyN),
UiAction::OpenEntry => (command, Code::KeyO), UiAction::OpenFolder => (command, Code::KeyO),
UiAction::Save => (command, Code::KeyS), UiAction::Save => (command, Code::KeyS),
UiAction::CloseWindow => (command, Code::KeyW), UiAction::CloseWindow => (command, Code::KeyW),
UiAction::Quit => (command, Code::KeyQ), UiAction::Quit => (command, Code::KeyQ),
@@ -132,6 +132,7 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
| UiAction::CopyField | UiAction::CopyField
| UiAction::CopyEditedField | UiAction::CopyEditedField
| UiAction::TogglePaneFocus | UiAction::TogglePaneFocus
| UiAction::OpenEntry
| UiAction::ReloadEntry | UiAction::ReloadEntry
| UiAction::EditEntry | UiAction::EditEntry
| UiAction::ToggleReveal | UiAction::ToggleReveal

View File

@@ -5,10 +5,13 @@ use std::{
env, env,
error::Error, error::Error,
fmt, fs, fmt, fs,
io::Write,
path::{Component, Path, PathBuf}, path::{Component, Path, PathBuf},
time::Duration, time::Duration,
}; };
use cap_std::{ambient_authority, fs::Dir};
use cap_tempfile::TempFile;
use serde::Deserialize; use serde::Deserialize;
use url::Url; use url::Url;
@@ -20,9 +23,10 @@ const CONFIG_FILE: &str = "config.toml";
const MAX_CONFIG_BYTES: u64 = 1024 * 1024; const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
/// Validated application configuration. /// Validated application configuration.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct Config { pub struct Config {
source: PathBuf, source: PathBuf,
document: toml::Value,
vault: PathBuf, vault: PathBuf,
default_key: KeyIdentity, default_key: KeyIdentity,
key_material: PathBuf, key_material: PathBuf,
@@ -32,6 +36,53 @@ pub struct Config {
git_remotes: Vec<GitRemote>, git_remotes: Vec<GitRemote>,
} }
// 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<Vec<String>>,
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<Vec<String>>) {
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 { impl Config {
/// Load an explicit configuration file, or the native per-user default. /// Load an explicit configuration file, or the native per-user default.
pub fn load(explicit: Option<&Path>) -> Result<Self, ConfigError> { pub fn load(explicit: Option<&Path>) -> Result<Self, ConfigError> {
@@ -71,6 +122,20 @@ impl Config {
&self.git_remotes &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 /// Select a configured remote by name, or the configured default (first
/// remote) when no name was requested. /// remote) when no name was requested.
pub fn git_remote(&self, requested: Option<&str>) -> Option<&GitRemote> { pub fn git_remote(&self, requested: Option<&str>) -> Option<&GitRemote> {
@@ -123,6 +188,100 @@ impl Config {
source: EditorSource::Fallback, source: EditorSource::Fallback,
}) })
} }
pub(crate) fn with_settings(&self, settings: ConfigSettings) -> Result<Self, ConfigError> {
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::<RawConfig>()
.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. /// Deterministic path context for configuration loading.
@@ -186,11 +345,12 @@ impl ConfigLoader {
reject_insecure_fields(&value, "")?; reject_insecure_fields(&value, "")?;
validate_known_fields(&value, &source)?; validate_known_fields(&value, &source)?;
let raw = value let raw = value
.clone()
.try_into::<RawConfig>() .try_into::<RawConfig>()
.map_err(|_| ConfigError::Malformed { .map_err(|_| ConfigError::Malformed {
path: source.clone(), path: source.clone(),
})?; })?;
validate_config(source, raw) validate_config(source, value, raw)
} }
} }
@@ -311,7 +471,9 @@ pub enum ConfigError {
InsecureField { field: String }, InsecureField { field: String },
MissingField { field: &'static str }, MissingField { field: &'static str },
InvalidField { field: &'static str }, InvalidField { field: &'static str },
VaultUnavailable { path: PathBuf },
VaultIsNotDirectory { path: PathBuf }, VaultIsNotDirectory { path: PathBuf },
Write { path: PathBuf },
KeyMaterialNotFound { path: PathBuf }, KeyMaterialNotFound { path: PathBuf },
InvalidKeyMaterial { path: PathBuf }, InvalidKeyMaterial { path: PathBuf },
DuplicateRemote { name: String }, DuplicateRemote { name: String },
@@ -363,6 +525,11 @@ impl fmt::Display for ConfigError {
Self::InvalidField { field } => { Self::InvalidField { field } => {
write!(formatter, "invalid configuration field: {field}") write!(formatter, "invalid configuration field: {field}")
} }
Self::VaultUnavailable { path } => write!(
formatter,
"vault folder is missing or inaccessible: {}",
path.display()
),
Self::VaultIsNotDirectory { path } => { Self::VaultIsNotDirectory { path } => {
write!( write!(
formatter, formatter,
@@ -370,6 +537,13 @@ impl fmt::Display for ConfigError {
path.display() path.display()
) )
} }
Self::Write { path } => {
write!(
formatter,
"cannot update configuration file: {}",
path.display()
)
}
Self::KeyMaterialNotFound { path } => write!( Self::KeyMaterialNotFound { path } => write!(
formatter, formatter,
"exported key material does not exist: {}", "exported key material does not exist: {}",
@@ -463,7 +637,11 @@ struct RawGitRemote {
application_id: String, application_id: String,
} }
fn validate_config(source: PathBuf, raw: RawConfig) -> Result<Config, ConfigError> { fn validate_config(
source: PathBuf,
document: toml::Value,
raw: RawConfig,
) -> Result<Config, ConfigError> {
let base = source let base = source
.parent() .parent()
.ok_or(ConfigError::InvalidField { field: "source" })?; .ok_or(ConfigError::InvalidField { field: "source" })?;
@@ -512,6 +690,7 @@ fn validate_config(source: PathBuf, raw: RawConfig) -> Result<Config, ConfigErro
Ok(Config { Ok(Config {
source, source,
document,
vault, vault,
default_key, default_key,
key_material, key_material,

View File

@@ -6,7 +6,7 @@ use crate::{
authentication::{ authentication::{
AuthenticationTimeout, NativeAuthenticationHandle, NativeAuthenticationSession, AuthenticationTimeout, NativeAuthenticationHandle, NativeAuthenticationSession,
}, },
config::Config, config::{Config, ConfigSettings, EditorCommand},
crypto::{KeyInfo, KeyStore, SecretProvider}, crypto::{KeyInfo, KeyStore, SecretProvider},
document::{DocumentError, EntryDocument, EntryDocumentService}, document::{DocumentError, EntryDocument, EntryDocumentService},
git::{AutomaticEntryCommitter, GitIdentity}, git::{AutomaticEntryCommitter, GitIdentity},
@@ -117,6 +117,54 @@ impl DesktopStorage {
self.config.authentication_timeout() self.config.authentication_timeout()
} }
pub fn vault(&self) -> &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<Self, DesktopError> {
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<Self, DesktopError> {
let mut settings = self.settings();
settings.set_vault(vault.to_owned());
self.update_settings(settings)
}
pub fn tree(&self) -> Result<TreeModel, DesktopError> { pub fn tree(&self) -> Result<TreeModel, DesktopError> {
let repository = self.repository()?; let repository = self.repository()?;
let keys = self.keys()?; let keys = self.keys()?;

View File

@@ -6,6 +6,7 @@ use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT;
use ironstorage::{ use ironstorage::{
authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT}, authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT},
config::{ConfigError, ConfigLoader, EditorSource}, config::{ConfigError, ConfigLoader, EditorSource},
desktop::DesktopStorage,
}; };
use tempfile::TempDir; use tempfile::TempDir;
@@ -177,6 +178,97 @@ fn native_default_path_is_used_without_an_explicit_path() -> TestResult {
Ok(()) 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] #[test]
fn editor_precedence_and_argument_splitting_are_storage_owned() -> TestResult { fn editor_precedence_and_argument_splitting_are_storage_owned() -> TestResult {
let fixture = ConfigurationFixture::new()?; let fixture = ConfigurationFixture::new()?;