#![forbid(unsafe_code)] #![deny(clippy::disallowed_types)] //! Presentation-only state, rendering, and event plumbing for the terminal UI. pub mod action; pub mod app; pub mod command; pub mod editor; pub mod runtime; pub mod search; pub mod sidebar; pub mod terminal; pub mod ui; pub mod viewer; pub mod workflow; use std::{ io, path::PathBuf, sync::mpsc::{self, Receiver, Sender}, time::{Duration, Instant}, }; use crossterm::event::{self, Event, KeyEventKind}; use ratatui::DefaultTerminal; use zeroize::Zeroize as _; use crate::{ action::{KeyResolution, KeyResolver}, app::{ App, AppEffect, AsyncPayload, EditorSaveFailure, EditorSaveFailureKind, StartupData, WorkflowSuccess, }, runtime::{ AsyncExecutor, AuthenticationCoordinator, AuthenticationEvent, AuthenticationTarget, }, workflow::WorkflowSubmission, }; const TICK_INTERVAL: Duration = Duration::from_millis(250); struct ClipboardCancellation { sender: Sender<()>, finished: Receiver>, } #[derive(Default)] struct ClipboardCancellations(Option); impl ClipboardCancellations { fn replace( &mut self, cancellation: Sender<()>, finished: Receiver>, ) -> Option>> { let previous = self.0.take().map(|active| { let _ignored = active.sender.send(()); active.finished }); self.0 = Some(ClipboardCancellation { sender: cancellation, finished, }); previous } fn cancel_all(&mut self) { if let Some(cancellation) = self.0.take() { let _ignored = cancellation.sender.send(()); } } } impl Drop for ClipboardCancellations { fn drop(&mut self) { self.cancel_all(); } } /// Run the interactive event loop. Polling keeps input responsive while storage /// work runs on the executor and periodic repaints are pending. pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { let mut app = App::new(); let executor = AsyncExecutor::new(); let mut clipboard_cancellations = ClipboardCancellations::default(); let mut authentication = None; let mut git_control = None; let mut authentication_initialized = false; let mut key_resolver = KeyResolver::default(); let color_capability = ui::ColorCapability::detect(); let startup = app.begin_latest_request(); executor.submit(startup, || { load_startup().map(|startup| AsyncPayload::Startup(Box::new(startup))) }); while !app.should_quit() { for result in executor.drain() { app.apply_result(result); } if let Some(effect) = app.take_clipboard_effect() { apply_app_effect( &mut app, effect, &mut authentication, &executor, &mut git_control, &mut clipboard_cancellations, ); } if !app.git_pending() { git_control = None; } if !authentication_initialized && let (Some(config), Some(key)) = (app.config(), app.default_key()) { authentication_initialized = true; match AuthenticationCoordinator::system(config.authentication_timeout(), key.clone()) { Ok(coordinator) => authentication = Some(coordinator), Err(error) => app.authentication_failed(error), } } if let Some(coordinator) = authentication.as_mut() && let Some(event) = coordinator.completion() { apply_authentication_event( &mut app, coordinator, &executor, &mut git_control, &mut clipboard_cancellations, event, ); } let unix_seconds = current_unix_seconds().map_err(io::Error::other)?; app.observe_time(unix_seconds); schedule_totp_refresh(&mut app, authentication.as_ref(), &executor); let size = terminal.size()?; app.resize(size.width, size.height); terminal.draw(|frame| ui::draw_with_color_capability(frame, &app, color_capability))?; if !event::poll(TICK_INTERVAL)? { app.tick(); if let Some(coordinator) = authentication.as_mut() { if let Some(event) = coordinator.poll_lease() { apply_authentication_event( &mut app, coordinator, &executor, &mut git_control, &mut clipboard_cancellations, event, ); } app.update_remaining_lease(coordinator.remaining_time()); } continue; } match event::read()? { Event::Key(key) if is_dispatchable_key_kind(key.kind) => { if let Some(coordinator) = authentication.as_mut() && let Some(event) = coordinator.touch_user_activity() { apply_authentication_event( &mut app, coordinator, &executor, &mut git_control, &mut clipboard_cancellations, event, ); } if let Some(effect) = app.handle_command_input(key.code, key.modifiers) { key_resolver.reset(); apply_app_effect( &mut app, effect, &mut authentication, &executor, &mut git_control, &mut clipboard_cancellations, ); continue; } if let Some(effect) = app.handle_workflow_input(key.code, key.modifiers) { key_resolver.reset(); apply_app_effect( &mut app, effect, &mut authentication, &executor, &mut git_control, &mut clipboard_cancellations, ); continue; } if !key.modifiers.intersects( crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT | crossterm::event::KeyModifiers::SUPER, ) && app.handle_editor_input(key.code) { key_resolver.reset(); continue; } if app.sidebar().is_editing_filter() { key_resolver.reset(); if handle_filter_key(&mut app, key.code) { submit_filter(&mut app, &executor); } } else { match key_resolver.feed(app.mode(), key.code, key.modifiers) { KeyResolution::Action(action) => { let effect = app.dispatch(action); apply_app_effect( &mut app, effect, &mut authentication, &executor, &mut git_control, &mut clipboard_cancellations, ); } KeyResolution::Pending => { app.report_status("Key sequence pending; Esc cancels".to_owned()); } KeyResolution::Unavailable => { app.report_status("Key is unavailable in the current mode".to_owned()); } } } } Event::Resize(width, height) => app.resize(width, height), Event::Paste(mut pasted) => { if let Some(coordinator) = authentication.as_mut() && let Some(event) = coordinator.touch_user_activity() { apply_authentication_event( &mut app, coordinator, &executor, &mut git_control, &mut clipboard_cancellations, event, ); } let filtering = app.sidebar().is_editing_filter(); if app.handle_paste(&pasted) && filtering { submit_filter(&mut app, &executor); } pasted.zeroize(); } Event::FocusLost => { handle_terminal_ownership_lost( &mut app, &mut authentication, &mut git_control, &mut clipboard_cancellations, ); } _ => {} } } Ok(()) } fn current_unix_seconds() -> Result { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs()) } fn schedule_totp_refresh( app: &mut App, authentication: Option<&AuthenticationCoordinator>, executor: &AsyncExecutor, ) { let Some((entry, field)) = app.begin_totp_refresh() else { return; }; let (Some(handle), Some(config)) = ( authentication.and_then(AuthenticationCoordinator::handle), app.config().cloned(), ) else { app.authentication_failed("authentication lease expired".to_owned()); return; }; let token = app.begin_request(); executor.submit(token, move || { execute_otp_ui( &config, crate::app::OtpUiRequest { request: ironstorage::command::OtpRequest::Code( ironstorage::command::OtpCodeRequest { entry, clipboard: false, }, ), confirmed_hotp: false, field: Some(field), }, handle, ) }); } fn handle_terminal_ownership_lost( app: &mut App, authentication: &mut Option, git_control: &mut Option, clipboard_cancellations: &mut ClipboardCancellations, ) { clipboard_cancellations.cancel_all(); if let Some(control) = git_control.take() { control.cancel(); } if let Some(coordinator) = authentication.as_mut() { let _ignored = coordinator.lock(); } app.forced_relock("terminal ownership was lost"); } fn apply_app_effect( app: &mut App, effect: AppEffect, authentication: &mut Option, executor: &AsyncExecutor, git_control: &mut Option, clipboard_cancellations: &mut ClipboardCancellations, ) { match effect { AppEffect::RefreshTree => { if let Some(config) = app.config().cloned() { let token = app.begin_latest_request(); executor.submit(token, move || { load_tree(&config).map(AsyncPayload::Refreshed) }); } } AppEffect::AuthenticateEntry(entry) => { if let Some(coordinator) = authentication.as_mut() { coordinator.request_entry(entry); } else { app.authentication_failed( "operating-system secure storage is unavailable".to_owned(), ); } } AppEffect::AuthenticateWorkflow(submission) => { if let Some(coordinator) = authentication.as_mut() { coordinator.request_workflow(submission); } else { app.workflow_authentication_failed( "operating-system secure storage is unavailable".to_owned(), ); } } AppEffect::AuthenticateGit(request) => { if let Some(coordinator) = authentication.as_mut() { app.begin_git_operation(crate::command::operation_name( &ironstorage::command::CommandRequest::Git(request.clone()), )); coordinator.request_git(request); } else { app.authentication_failed( "operating-system secure storage is unavailable".to_owned(), ); } } AppEffect::AuthenticateOtp(request) => { if let Some(coordinator) = authentication.as_mut() { coordinator.request_otp(request); } else { app.authentication_failed( "operating-system secure storage is unavailable".to_owned(), ); } } AppEffect::AuthenticateShow(request) => { if let Some(coordinator) = authentication.as_mut() { coordinator.request_show(request); } else { app.authentication_failed( "operating-system secure storage is unavailable".to_owned(), ); } } AppEffect::CancelGit => { if let Some(control) = git_control.as_ref() { control.cancel(); } } AppEffect::ResolveGit(resolutions) => { if let Some(config) = app.config().cloned() { let token = app.begin_request(); let control = ironstorage::git::GitOperationControl::new(executor.progress_reporter(token)); *git_control = Some(control.clone()); executor.submit(token, move || { execute_git_resolution(&config, &resolutions, &control) }); } } AppEffect::CopyFocused { presentation, value, } => { let token = app.begin_request(); let Some(config) = app.config().cloned() else { app.apply_result(crate::app::AsyncResult { token, payload: Ok(AsyncPayload::ClipboardFinished { presentation, result: Err(ironstorage::presentation::ClipboardError::Unavailable), }), }); return; }; let (cancel, cancellation) = mpsc::channel(); let (finished, completion) = mpsc::channel(); let previous = clipboard_cancellations.replace(cancel, completion); let started = executor.clipboard_started_reporter(token); executor.submit(token, move || { // Finish the old cleanup before taking the new clipboard snapshot, // otherwise the new presentation could later restore an older secret. let previous = previous.map_or(Ok(()), |previous| { previous.recv().unwrap_or(Err( ironstorage::presentation::ClipboardError::CleanupFailed, )) }); let payload = match (previous, cancellation.try_recv()) { (Err(error), _) => AsyncPayload::ClipboardFinished { presentation, result: Err(error), }, (Ok(()), Err(mpsc::TryRecvError::Empty)) => run_clipboard_presentation( ironstorage::presentation::NativeClipboardManager::system( config.clipboard_timeout(), ), presentation, value, started, move |deadline| match cancellation .recv_timeout(deadline.saturating_duration_since(Instant::now())) { Err(mpsc::RecvTimeoutError::Timeout) => { ironstorage::presentation::ClipboardWait::Elapsed } Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => { ironstorage::presentation::ClipboardWait::Cancelled } }, ), (Ok(()), Ok(())) | (Ok(()), Err(mpsc::TryRecvError::Disconnected)) => { AsyncPayload::ClipboardFinished { presentation, result: Err(ironstorage::presentation::ClipboardError::Cancelled), } } }; let cleanup = match &payload { AsyncPayload::ClipboardFinished { result: Ok(_) | Err(ironstorage::presentation::ClipboardError::Cancelled), .. } => Ok(()), AsyncPayload::ClipboardFinished { result: Err(error), .. } => Err(*error), _ => unreachable!("clipboard worker returns a clipboard result"), }; let _ignored = finished.send(cleanup); Ok(payload) }); } AppEffect::GenerateField(target) => { let token = app.begin_request(); executor.submit(token, move || { ironstorage::generate::GeneratorConfig::pass_defaults() .generate_secret(None, false) .map(|password| AsyncPayload::GeneratedField { target, password }) .map_err(|error| error.to_string()) }); } AppEffect::SaveDocument { config, entry, editor, } => { let token = app.begin_request(); executor.submit(token, move || Ok(save_document(&config, entry, editor))); } AppEffect::ManualLock => { clipboard_cancellations.cancel_all(); if let Some(coordinator) = authentication.as_mut() && let Err(error) = coordinator.lock() { app.forced_relock("manual lock requested"); app.report_status(format!("locked after secure-store cleanup failed: {error}")); } } AppEffect::RunCommand(ironstorage::command::CommandRequest::Find(request)) => { if let Some(config) = app.config().cloned() { let query = request.terms.join(" "); let token = app.begin_latest_request(); executor.submit(token, move || { let repository = ironstorage::repository::Repository::open(config.vault()) .map_err(|error| error.to_string())?; let keys = ironstorage::crypto::KeyStore::load(config.key_material()) .map_err(|error| error.to_string())?; let results = ironstorage::read::VaultReader::new(&repository, &keys) .find(&request.terms) .map_err(|error| error.to_string())?; Ok(AsyncPayload::Found { query, results }) }); } } AppEffect::RunCommand(ironstorage::command::CommandRequest::Git(request)) => { if git_requires_authentication(&request) { if let Some(coordinator) = authentication.as_mut() { app.begin_git_operation(crate::command::operation_name( &ironstorage::command::CommandRequest::Git(request.clone()), )); coordinator.request_git(request); } else { app.authentication_failed( "operating-system secure storage is unavailable".to_owned(), ); } } else if let Some(config) = app.config().cloned() { app.begin_git_operation(crate::command::operation_name( &ironstorage::command::CommandRequest::Git(request.clone()), )); let token = app.begin_request(); let control = ironstorage::git::GitOperationControl::new(executor.progress_reporter(token)); *git_control = Some(control.clone()); executor.submit(token, move || execute_git(&config, request, None, &control)); } } AppEffect::RunCommand(ironstorage::command::CommandRequest::Otp( ironstorage::command::OtpRequest::Validate { uri }, )) => { let token = app.begin_request(); executor.submit(token, move || { ironstorage::otp::OtpService::validate(&uri) .map(|()| AsyncPayload::OtpValidated) .map_err(|error| error.to_string()) }); } AppEffect::RunCommand(request) => { app.report_status(format!( "{} is not implemented by this terminal workflow yet", crate::command::operation_name(&request) )); } AppEffect::None => {} } } fn run_clipboard_presentation( clipboard: Result< ironstorage::presentation::ClipboardManager, ironstorage::presentation::ClipboardError, >, presentation: crate::app::ClipboardPresentationId, value: ironstorage::repository::SecretBytes, started: S, wait: W, ) -> AsyncPayload where B: ironstorage::presentation::ClipboardBackend, S: FnOnce(crate::app::ClipboardPresentationId, Instant), W: FnOnce(Instant) -> ironstorage::presentation::ClipboardWait, { let result = clipboard.and_then(|mut clipboard| { clipboard.copy_with(&value, |duration| { let deadline = Instant::now() + duration; started(presentation, deadline); wait(deadline) }) }); AsyncPayload::ClipboardFinished { presentation, result, } } fn is_dispatchable_key_kind(kind: KeyEventKind) -> bool { matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat) } fn git_requires_authentication(request: &ironstorage::command::GitRequest) -> bool { matches!( request, ironstorage::command::GitRequest::Diff { .. } | ironstorage::command::GitRequest::Fetch { .. } | ironstorage::command::GitRequest::Pull { .. } | ironstorage::command::GitRequest::Push { .. } | ironstorage::command::GitRequest::Sync { .. } ) } fn execute_git( config: &ironstorage::config::Config, request: ironstorage::command::GitRequest, mut credentials: Option, control: &ironstorage::git::GitOperationControl, ) -> Result { use ironstorage::{ command::{GitConfigRequest, GitRemoteRequest, GitRequest}, git::{EmbeddedFetchTransport, GitError, GitIdentity, GitRepository, ReqwestGitTransport}, repository::{Repository, SecretBytes}, }; control .report(ironstorage::git::GitProgressPhase::Validating) .map_err(|error| error.to_string())?; let repository = Repository::open(config.vault()).map_err(|error| error.to_string())?; let snapshot_remote = match &request { GitRequest::Fetch { remote } | GitRequest::Pull { remote, .. } | GitRequest::Push { remote, .. } | GitRequest::Sync { remote } => remote.clone(), _ => None, }; let mut git = match request { GitRequest::Init => GitRepository::init(&repository, GitIdentity::ironstorage()), _ => GitRepository::open(&repository, GitIdentity::ironstorage()), } .map_err(|error| error.to_string())?; let mut changed_snapshot = false; let mut details = None; let operation = match request { GitRequest::Init => "Initialized embedded Git repository".to_owned(), GitRequest::Status => "Git status refreshed".to_owned(), GitRequest::Log { maximum } => { let log = git .log(maximum.map(std::num::NonZeroUsize::get)) .map_err(|error| error.to_string())?; details = Some(SecretBytes::new( log.iter() .map(|entry| { format!( "{} {}\n", &entry.id()[..entry.id().len().min(12)], entry.message() ) }) .collect::() .into_bytes(), )); format!("Git log: {} commit(s)", log.len()) } GitRequest::Diff { paths } => { let handle = credentials .as_mut() .ok_or_else(|| "Git diff requires authentication".to_owned())?; let keys = ironstorage::crypto::KeyStore::load(config.key_material()) .map_err(|error| error.to_string())?; details = Some( git.render_diff( &paths.into_iter().map(PathBuf::from).collect::>(), &keys, handle, ) .map_err(|error| error.to_string())?, ); "Rendered helper-free Git diff".to_owned() } GitRequest::Add { paths } => { git.stage(&paths.into_iter().map(PathBuf::from).collect::>()) .map_err(|error| error.to_string())?; "Staged Git paths".to_owned() } GitRequest::Commit { message } => { let id = git.commit(&message).map_err(|error| error.to_string())?; format!("Created commit {}", &id[..id.len().min(12)]) } GitRequest::Remote(remote) => match remote { GitRemoteRequest::List => { details = Some(SecretBytes::new(git.remotes().join("\n").into_bytes())); "Listed Git remotes".to_owned() } GitRemoteRequest::GetUrl { name } => { details = Some(SecretBytes::new( git.remote_url(&name) .map_err(|error| error.to_string())? .into_bytes(), )); format!("Git remote {name}") } GitRemoteRequest::Add { name, url } => { git.add_remote(&name, &url) .map_err(|error| error.to_string())?; format!("Added Git remote {name}") } GitRemoteRequest::SetUrl { name, url } => { git.set_remote_url(&name, &url) .map_err(|error| error.to_string())?; format!("Updated Git remote {name}") } GitRemoteRequest::Remove { name } => { git.remove_remote(&name) .map_err(|error| error.to_string())?; format!("Removed Git remote {name}") } }, GitRequest::Config(request) => match request { GitConfigRequest::Get { key } => { let value = git .config_get(&key) .map_err(|error| error.to_string())? .unwrap_or_default(); details = Some(SecretBytes::new(value.into_bytes())); format!("Git configuration {key}") } GitConfigRequest::Set { key, value } => { git.config_set(&key, &value) .map_err(|error| error.to_string())?; format!("Updated Git configuration {key}") } }, GitRequest::Fetch { remote } => { let configured = config .git_remote(remote.as_deref()) .ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?; let handle = credentials .as_ref() .ok_or_else(|| "Git fetch requires authentication".to_owned())?; let outcome = git .fetch_with_transport_controlled( configured, handle, &EmbeddedFetchTransport, control, ) .map_err(|error| error.to_string())?; format!( "Fetched {}{}", outcome.remote(), if outcome.received_pack() { " (received objects)" } else { " (up to date)" } ) } GitRequest::Pull { remote, branch } => { let configured = config .git_remote(remote.as_deref()) .ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?; let handle = credentials .as_ref() .ok_or_else(|| "Git pull requires authentication".to_owned())?; match git.pull_with_transport_controlled( configured, branch.as_deref(), handle, &EmbeddedFetchTransport, control, ) { Ok(outcome) => { changed_snapshot = true; format!("Git pull completed: {outcome:?}") } Err(GitError::MergeConflicts { conflicts }) => { let snapshot = git .snapshot(Some(configured), 10) .map_err(|error| error.to_string())?; return Ok(AsyncPayload::GitFinished { snapshot: Box::new(snapshot), tree: None, message: format!( "Git pull requires resolution of {} conflict(s)", conflicts.len() ), conflicts, details: None, }); } Err(error) => return Err(error.to_string()), } } GitRequest::Push { remote, branch } => { let configured = config .git_remote(remote.as_deref()) .ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?; let handle = credentials .as_ref() .ok_or_else(|| "Git push requires authentication".to_owned())?; let outcome = git .push_with_transport_controlled( configured, branch.as_deref(), handle, &ReqwestGitTransport, control, ) .map_err(|error| error.to_string())?; format!( "Pushed {} {}", outcome.remote(), &outcome.new_id()[..outcome.new_id().len().min(12)] ) } GitRequest::Sync { remote } => { let configured = config .git_remote(remote.as_deref()) .ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?; let handle = credentials .as_ref() .ok_or_else(|| "Git synchronization requires authentication".to_owned())?; let pull = match git.pull_with_transport_controlled( configured, None, handle, &EmbeddedFetchTransport, control, ) { Ok(outcome) => outcome, Err(GitError::MergeConflicts { conflicts }) => { let snapshot = git .snapshot(Some(configured), 10) .map_err(|error| error.to_string())?; return Ok(AsyncPayload::GitFinished { snapshot: Box::new(snapshot), tree: None, message: format!( "Git synchronization requires resolution of {} conflict(s)", conflicts.len() ), conflicts, details: None, }); } Err(error) => return Err(error.to_string()), }; let push = git .push_with_transport_controlled( configured, None, handle, &ReqwestGitTransport, control, ) .map_err(|error| error.to_string())?; changed_snapshot = true; format!( "Git synchronization completed: {pull:?}; pushed {}", &push.new_id()[..push.new_id().len().min(12)] ) } }; control .report(ironstorage::git::GitProgressPhase::Refreshing) .map_err(|error| error.to_string())?; let configured = config.git_remote(snapshot_remote.as_deref()); let snapshot = git .snapshot(configured, 10) .map_err(|error| error.to_string())?; let tree = if changed_snapshot { Some(load_tree(config)?) } else { None }; Ok(AsyncPayload::GitFinished { snapshot: Box::new(snapshot), tree, message: operation, conflicts: Vec::new(), details, }) } fn execute_git_resolution( config: &ironstorage::config::Config, resolutions: &[ironstorage::git::GitConflictResolution], control: &ironstorage::git::GitOperationControl, ) -> Result { use ironstorage::git::{GitIdentity, GitRepository}; control .report(ironstorage::git::GitProgressPhase::Integrating) .map_err(|error| error.to_string())?; let configured = config .git_remote(None) .ok_or_else(|| "no HTTPS Git remote is configured".to_owned())?; let repository = ironstorage::repository::Repository::open(config.vault()) .map_err(|error| error.to_string())?; let git = GitRepository::open(&repository, GitIdentity::ironstorage()) .map_err(|error| error.to_string())?; git.resolve_fetched(configured, None, resolutions) .map_err(|error| error.to_string())?; control .report(ironstorage::git::GitProgressPhase::Refreshing) .map_err(|error| error.to_string())?; let snapshot = git .snapshot(Some(configured), 10) .map_err(|error| error.to_string())?; let tree = load_tree(config)?; Ok(AsyncPayload::GitFinished { snapshot: Box::new(snapshot), tree: Some(tree), message: "Merge conflicts resolved and committed".to_owned(), conflicts: Vec::new(), details: None, }) } fn execute_otp_ui( config: &ironstorage::config::Config, request: crate::app::OtpUiRequest, mut provider: ironstorage::authentication::NativeAuthenticationHandle, ) -> Result { use ironstorage::{ command::{OtpRequest, OtpUriPresentation}, otp::{OtpKind, OtpService}, presentation::QrMatrix, }; let repository = ironstorage::repository::Repository::open(config.vault()) .map_err(|error| error.to_string())?; let keys = ironstorage::crypto::KeyStore::load(config.key_material()) .map_err(|error| error.to_string())?; let service = OtpService::new(&repository, &keys); let field = request.field; match request.request { OtpRequest::Code(code_request) => { let uri = service .uri(&code_request.entry, &mut provider) .map_err(|error| error.to_string())?; if uri.kind() == OtpKind::Hotp && !request.confirmed_hotp { return Err("HOTP generation requires explicit confirmation".to_owned()); } let unix_seconds = current_unix_seconds() .map_err(|_| "the system clock is before the Unix epoch".to_owned())?; let outcome = service .code_automatic(&code_request.entry, unix_seconds, None, &mut provider) .map_err(|error| error.to_string())?; let validity = outcome.validity(); let code = ironstorage::repository::SecretBytes::new(outcome.code().expose().to_vec()); let tree = validity.counter().map(|_| load_tree(config)).transpose()?; Ok(AsyncPayload::OtpCodeFinished { entry: code_request.entry, field, code, validity, observed_at: unix_seconds, clipboard: code_request.clipboard, tree, }) } OtpRequest::Uri(uri_request) => { let uri = service .uri(&uri_request.entry, &mut provider) .map_err(|error| error.to_string())?; let payload = ironstorage::repository::SecretBytes::new(uri.encoded().expose().to_vec()); let (presentation, qr) = match uri_request.presentation { OtpUriPresentation::Terminal => (crate::app::OtpPresentationTarget::Terminal, None), OtpUriPresentation::Clipboard => { (crate::app::OtpPresentationTarget::Clipboard, None) } OtpUriPresentation::QrCode => ( crate::app::OtpPresentationTarget::Qr, Some(QrMatrix::encode(&payload).map_err(|error| error.to_string())?), ), }; Ok(AsyncPayload::OtpUriFinished { entry: uri_request.entry, presentation, payload, qr, }) } _ => Err("this OTP request is not a presentation operation".to_owned()), } } fn execute_show_presentation( config: &ironstorage::config::Config, request: ironstorage::command::ShowRequest, mut provider: impl ironstorage::crypto::SecretProvider, ) -> Result { use ironstorage::{ presentation::QrMatrix, read::{PresentationChannel, ShowOutput, VaultReader}, }; let repository = ironstorage::repository::Repository::open(config.vault()) .map_err(|error| error.to_string())?; let keys = ironstorage::crypto::KeyStore::load(config.key_material()) .map_err(|error| error.to_string())?; let ShowOutput::Present(secret) = VaultReader::new(&repository, &keys) .execute_show(&request, &mut provider) .map_err(|error| error.to_string())? else { return Err("the show request did not select a presentation channel".to_owned()); }; let status = format!("Presented {} line {}", secret.entry(), secret.line()); let presentation = match secret.channel() { PresentationChannel::Clipboard => crate::app::SecretPresentation::Clipboard( ironstorage::repository::SecretBytes::new(secret.contents().expose().to_vec()), ), PresentationChannel::QrCode => crate::app::SecretPresentation::Qr { title: "Entry QR".to_owned(), matrix: QrMatrix::encode(secret.contents()).map_err(|error| error.to_string())?, }, }; Ok(AsyncPayload::SecretPresented { status, presentation, }) } fn load_startup() -> Result { let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?; let repository = ironstorage::repository::Repository::open(config.vault()) .map_err(|error| error.to_string())?; let keys = ironstorage::crypto::KeyStore::load(config.key_material()) .map_err(|error| error.to_string())?; let key_handle = keys .resolve(config.default_key().as_str()) .map_err(|error| error.to_string())?; let key = keys .infos() .find(|key| key.fingerprint() == key_handle.fingerprint()) .ok_or_else(|| "the configured OpenPGP key is unavailable".to_owned())?; let all_keys = keys.infos().collect::>(); let tree = ironstorage::read::VaultReader::new(&repository, &keys) .list(&ironstorage::repository::DirectoryPath::root()) .map_err(|error| error.to_string())?; Ok(StartupData { config, tree, key, keys: all_keys, }) } fn apply_authentication_event( app: &mut App, coordinator: &AuthenticationCoordinator, executor: &AsyncExecutor, git_control: &mut Option, clipboard_cancellations: &mut ClipboardCancellations, event: AuthenticationEvent, ) { match event { AuthenticationEvent::Granted(AuthenticationTarget::Entry(entry)) => { if app.authentication_granted(entry.clone()) { let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle()) else { app.authentication_failed( "authentication completed without an active secure-store lease".to_owned(), ); return; }; let token = app.begin_latest_request(); executor.submit(token, move || { load_document(&config, &entry, handle).map(|document| { AsyncPayload::DocumentLoaded { entry, document: Box::new(document), } }) }); } } AuthenticationEvent::Granted(AuthenticationTarget::Workflow(submission)) => { if !app.workflow_authentication_granted() { return; } let (Some(config), Some(mut handle)) = (app.config().cloned(), coordinator.handle()) else { app.workflow_authentication_failed( "authentication completed without an active secure-store lease".to_owned(), ); return; }; let token = app.begin_request(); executor.submit(token, move || { Ok(AsyncPayload::WorkflowFinished( execute_workflow(&config, *submission, &mut handle).map(Box::new), )) }); } AuthenticationEvent::Granted(AuthenticationTarget::Git(request)) => { let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle()) else { app.authentication_failed( "authentication completed without an active secure-store lease".to_owned(), ); return; }; let token = app.begin_request(); let control = ironstorage::git::GitOperationControl::new(executor.progress_reporter(token)); *git_control = Some(control.clone()); executor.submit(token, move || { execute_git(&config, request, Some(handle), &control) }); } AuthenticationEvent::Granted(AuthenticationTarget::Otp(request)) => { let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle()) else { app.authentication_failed( "authentication completed without an active secure-store lease".to_owned(), ); return; }; let token = app.begin_request(); executor.submit(token, move || execute_otp_ui(&config, request, handle)); } AuthenticationEvent::Granted(AuthenticationTarget::Show(request)) => { let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle()) else { app.authentication_failed( "authentication completed without an active secure-store lease".to_owned(), ); return; }; let token = app.begin_request(); executor.submit(token, move || { execute_show_presentation(&config, request, handle) }); } AuthenticationEvent::Failed { workflow, message } => { if workflow { app.workflow_authentication_failed(message); } else { app.authentication_failed(message); } } AuthenticationEvent::Expired => { handle_authentication_expiry(app, clipboard_cancellations); } } } fn handle_authentication_expiry( app: &mut App, clipboard_cancellations: &mut ClipboardCancellations, ) { clipboard_cancellations.cancel_all(); app.forced_relock("authentication lease expired"); } fn load_document( config: &ironstorage::config::Config, entry: &str, mut handle: ironstorage::authentication::NativeAuthenticationHandle, ) -> Result { let repository = ironstorage::repository::Repository::open(config.vault()) .map_err(|error| error.to_string())?; let keys = ironstorage::crypto::KeyStore::load(config.key_material()) .map_err(|error| error.to_string())?; ironstorage::document::EntryDocumentService::new(&repository, &keys) .open(entry, &mut handle) .map_err(|error| error.to_string()) } fn execute_workflow( config: &ironstorage::config::Config, submission: WorkflowSubmission, provider: &mut impl ironstorage::crypto::SecretProvider, ) -> Result { use ironstorage::{ generate::{GeneratorConfig, PasswordGenerator}, git::{ AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitIdentity, }, mutation::TreeMutator, otp::OtpService, recipient::RecipientPolicyManager, write::VaultWriter, }; let repository = ironstorage::repository::Repository::open(config.vault()) .map_err(|error| error.to_string())?; let keys = ironstorage::crypto::KeyStore::load(config.key_material()) .map_err(|error| error.to_string())?; let identity = GitIdentity::ironstorage(); let mut selection = None; let mut grep = None; let mut presentation = None; let mut refresh_tree = true; let (entry, mut status) = match submission { WorkflowSubmission::Init(request) => { let directory = request.path.as_deref().unwrap_or_default(); let mut committer = AutomaticPolicyCommitter::for_directory(&repository, directory, identity) .map_err(|error| error.to_string())?; let outcome = RecipientPolicyManager::new(&repository, &keys) .apply_init(&request, None, provider, &mut committer) .map_err(|error| error.to_string())?; ( None, format!( "Initialized {} recipient(s) for {}", outcome.recipients().len(), outcome.directory() ), ) } WorkflowSubmission::Insert { request, contents, overwrite, } => { let entry = request.entry.clone(); let mut committer = AutomaticEntryCommitter::for_entry(&repository, &entry, identity) .map_err(|error| error.to_string())?; VaultWriter::new(&repository, &keys) .insert(&request, contents, overwrite, None, &mut committer) .map_err(|error| error.to_string())?; (Some(entry.clone()), format!("Inserted {entry}")) } WorkflowSubmission::Generate { request, overwrite } => { let entry = request.entry.clone(); let mut committer = AutomaticEntryCommitter::for_entry(&repository, &entry, identity) .map_err(|error| error.to_string())?; let outcome = PasswordGenerator::new(&repository, &keys, GeneratorConfig::pass_defaults()) .generate(&request, overwrite, None, provider, &mut committer) .map_err(|error| error.to_string())?; presentation = match outcome.channel() { ironstorage::generate::GeneratedChannel::Terminal => None, ironstorage::generate::GeneratedChannel::Clipboard => { Some(crate::app::SecretPresentation::Clipboard( ironstorage::repository::SecretBytes::new( outcome.password().expose().to_vec(), ), )) } ironstorage::generate::GeneratedChannel::QrCode => { Some(crate::app::SecretPresentation::Qr { title: "Generated password QR".to_owned(), matrix: ironstorage::presentation::QrMatrix::encode(outcome.password()) .map_err(|error| error.to_string())?, }) } }; ( Some(entry.clone()), format!("Generated password for {entry}"), ) } WorkflowSubmission::Grep(request) => { let results = ironstorage::read::VaultReader::new(&repository, &keys) .grep(&request, provider) .map_err(|error| error.to_string())?; let count = results.entries().len(); grep = Some(results); refresh_tree = false; ( None, if count == 0 { "Decrypted search completed with no matches".to_owned() } else { format!("Decrypted search found {count} matching entries") }, ) } WorkflowSubmission::Kdbx { request, password } => { let outcome = ironstorage::kdbx::KdbxImporter::new(&repository, &keys) .import(&request, password, provider, identity) .map_err(|error| error.to_string())?; ( None, format!( "KDBX import: {} added, {} updated, {} unchanged, {} skipped", outcome.added(), outcome.updated(), outcome.unchanged(), outcome.skipped() ), ) } WorkflowSubmission::Remove(request) => { let target = request.entry.clone(); let mut committer = AutomaticTreeCommitter::for_source(&repository, &target, identity) .map_err(|error| error.to_string())?; let outcome = TreeMutator::new(&repository, &keys) .remove( &request, ironstorage::write::OverwriteDecision::Allow, &mut committer, ) .map_err(|error| error.to_string())?; selection = outcome .selection() .map(|selection| (selection.display_path(), selection.is_directory())); ( None, format!( "Removed {target} ({} encrypted entries)", outcome.entries().len() ), ) } WorkflowSubmission::Move { request, overwrite } => { let source = request.source.clone(); let destination = request.destination.clone(); let mut committer = AutomaticTreeCommitter::for_source(&repository, &source, identity) .map_err(|error| error.to_string())?; let outcome = TreeMutator::new(&repository, &keys) .move_tree(&request, overwrite, None, provider, &mut committer) .map_err(|error| error.to_string())?; selection = outcome .selection() .map(|selection| (selection.display_path(), selection.is_directory())); (None, format!("Moved {source} to {destination}")) } WorkflowSubmission::Copy { request, overwrite } => { let source = request.source.clone(); let destination = request.destination.clone(); let mut committer = AutomaticTreeCommitter::for_source(&repository, &source, identity) .map_err(|error| error.to_string())?; let outcome = TreeMutator::new(&repository, &keys) .copy(&request, overwrite, None, provider, &mut committer) .map_err(|error| error.to_string())?; selection = outcome .selection() .map(|selection| (selection.display_path(), selection.is_directory())); (None, format!("Copied {source} to {destination}")) } WorkflowSubmission::OtpInsert { request, input } => { let service = OtpService::new(&repository, &keys); let plan = service .prepare_insert(&request, input) .map_err(|error| error.to_string())?; let path = plan.path().to_string(); let mut committer = AutomaticEntryCommitter::for_entry(&repository, &path, GitIdentity::ironstorage()) .map_err(|error| error.to_string())?; let outcome = service .finish_insert( plan, ironstorage::write::OverwriteDecision::Allow, ironstorage::write::OverwriteDecision::Allow, None, &mut committer, ) .map_err(|error| error.to_string())?; selection = Some((outcome.path().to_string(), false)); (None, format!("Inserted OTP URI at {}", outcome.path())) } WorkflowSubmission::OtpAppend { request, input } => { let service = OtpService::new(&repository, &keys); let session = service .begin_append(&request, provider) .map_err(|error| error.to_string())?; let path = session.path().to_string(); let mut committer = AutomaticEntryCommitter::for_entry(&repository, &path, GitIdentity::ironstorage()) .map_err(|error| error.to_string())?; let outcome = service .finish_append( session, input, ironstorage::write::OverwriteDecision::Allow, None, &mut committer, ) .map_err(|error| error.to_string())?; selection = Some((outcome.path().to_string(), false)); (None, format!("Updated OTP URI at {}", outcome.path())) } WorkflowSubmission::OtpValidate { uri } => { OtpService::validate_input(uri).map_err(|error| error.to_string())?; refresh_tree = false; (None, "OTP URI is valid".to_owned()) } }; let tree = refresh_tree .then(|| { ironstorage::read::VaultReader::new(&repository, &keys) .list(&ironstorage::repository::DirectoryPath::root()) .map_err(|error| { status.push_str(&format!("; sidebar refresh failed: {error}")); }) .ok() }) .flatten(); let document = entry .as_deref() .map(|entry| { ironstorage::document::EntryDocumentService::new(&repository, &keys) .open(entry, provider) .map(Box::new) .map_err(|error| error.to_string()) }) .transpose() .map_err(|error| { status.push_str(&format!("; entry opening failed: {error}")); }) .ok() .flatten(); Ok(WorkflowSuccess { tree, selection, entry, document, grep, presentation, status, }) } fn save_document( config: &ironstorage::config::Config, entry: String, editor: Box, ) -> AsyncPayload { let result = save_document_inner(config, &entry, &editor); AsyncPayload::DocumentSaveFinished { entry, editor, result, } } fn save_document_inner( config: &ironstorage::config::Config, entry: &str, editor: &editor::EntryEditor, ) -> Result { let repository = ironstorage::repository::Repository::open(config.vault()).map_err(|error| { EditorSaveFailure::new(EditorSaveFailureKind::Storage, error.to_string()) })?; let keys = ironstorage::crypto::KeyStore::load(config.key_material()).map_err(|error| { EditorSaveFailure::new(EditorSaveFailureKind::Storage, error.to_string()) })?; let identity = ironstorage::git::GitIdentity::ironstorage(); let mut committer = ironstorage::git::AutomaticEntryCommitter::for_entry(&repository, entry, identity) .map_err(|error| { EditorSaveFailure::new(EditorSaveFailureKind::Storage, error.to_string()) })?; ironstorage::document::EntryDocumentService::new(&repository, &keys) .save_recoverable(editor.document(), None, &mut committer) .map_err(|error| { let kind = if matches!( &error, ironstorage::document::DocumentError::Write( ironstorage::write::WriteError::ConcurrentModification { .. } ) ) { EditorSaveFailureKind::Conflict } else if matches!( &error, ironstorage::document::DocumentError::Write( ironstorage::write::WriteError::Unchanged ) ) { EditorSaveFailureKind::Unchanged } else { EditorSaveFailureKind::Storage }; EditorSaveFailure::new(kind, error.to_string()) }) } fn load_tree(config: &ironstorage::config::Config) -> Result { let repository = ironstorage::repository::Repository::open(config.vault()) .map_err(|error| error.to_string())?; let keys = ironstorage::crypto::KeyStore::load(config.key_material()) .map_err(|error| error.to_string())?; ironstorage::read::VaultReader::new(&repository, &keys) .list(&ironstorage::repository::DirectoryPath::root()) .map_err(|error| error.to_string()) } fn submit_filter(app: &mut App, executor: &AsyncExecutor) { let query = app.sidebar().filter_query().to_owned(); if query.is_empty() { app.sidebar_mut().reset_filter_results(); return; } let Some(config) = app.config().cloned() else { return; }; let token = app.begin_latest_request(); executor.submit(token, move || { let repository = ironstorage::repository::Repository::open(config.vault()) .map_err(|error| error.to_string())?; let keys = ironstorage::crypto::KeyStore::load(config.key_material()) .map_err(|error| error.to_string())?; let results = ironstorage::read::VaultReader::new(&repository, &keys) .find(std::slice::from_ref(&query)) .map_err(|error| error.to_string())?; Ok(AsyncPayload::Filtered { query, results }) }); } fn handle_filter_key(app: &mut App, code: crossterm::event::KeyCode) -> bool { use crossterm::event::KeyCode; match code { KeyCode::Esc => { app.sidebar_mut().clear_filter_results(); false } KeyCode::Enter => { app.sidebar_mut().finish_filter(); false } KeyCode::Backspace => app.sidebar_mut().pop_filter_character(), KeyCode::Char(character) => app.sidebar_mut().push_filter_character(character), _ => false, } } #[cfg(test)] mod tests { use std::{fs, path::Path}; use ironstorage::{ command::{ CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest, InsertInput, InsertRequest, MoveRequest, Presentation, RemoveRequest, ShowRequest, }, crypto::{KeyInfo, SecretProvider, SecretProviderError}, repository::SecretBytes, write::{InsertContent, OverwriteDecision}, }; use super::*; use crate::{ action::Action, editor::EntryEditor, viewer::test_support::{fixture_document, fixture_document_from}, }; #[test] fn press_and_terminal_repeat_events_dispatch_but_release_does_not() { assert!(is_dispatchable_key_kind(KeyEventKind::Press)); assert!(is_dispatchable_key_kind(KeyEventKind::Repeat)); assert!(!is_dispatchable_key_kind(KeyEventKind::Release)); } #[test] fn production_tui_sources_preserve_the_presentation_only_boundary() { let sources = [ ("action.rs", include_str!("action.rs")), ("app.rs", include_str!("app.rs")), ("command.rs", include_str!("command.rs")), ("editor.rs", include_str!("editor.rs")), ("lib.rs", include_str!("lib.rs")), ("runtime.rs", include_str!("runtime.rs")), ("search.rs", include_str!("search.rs")), ("sidebar.rs", include_str!("sidebar.rs")), ("terminal.rs", include_str!("terminal.rs")), ("ui.rs", include_str!("ui.rs")), ("viewer.rs", include_str!("viewer.rs")), ("workflow.rs", include_str!("workflow.rs")), ]; for (name, source) in sources { let production = source.split("#[cfg(test)]").next().unwrap_or(source); let forbidden_tokens = [ ["std::", "process"].concat(), ["process", "::Command"].concat(), ["Command", "::new("].concat(), ["std::", "fs::"].concat(), ["fs::", "read("].concat(), ["fs::", "write("].concat(), [".read", "_entry("].concat(), [".write", "_entry("].concat(), ["OtpUri", "::parse"].concat(), ["qrcode", "::QrCode"].concat(), ["unsafe", " {"].concat(), ]; for forbidden in &forbidden_tokens { assert!( !production.contains(forbidden), "{name} crosses the TUI architecture boundary with {forbidden}" ); } } } #[test] fn terminal_ownership_loss_cancels_presentations_and_forces_relock() { let mut app = App::new(); app.open_test_document("email/personal", fixture_document("email/personal")); let presentation = match app.dispatch(Action::Copy) { AppEffect::CopyFocused { presentation, .. } => presentation, effect => panic!("unexpected effect: {effect:?}"), }; let mut authentication = None; let mut git_control = None; let mut clipboard = ClipboardCancellations::default(); let (first_cancel, first_cancelled) = std::sync::mpsc::channel(); let (first_finished, first_completion) = std::sync::mpsc::channel(); assert!(clipboard.replace(first_cancel, first_completion).is_none()); let (cancel, cancelled) = std::sync::mpsc::channel(); let (_finished, completion) = std::sync::mpsc::channel(); let previous = clipboard .replace(cancel, completion) .expect("previous clipboard completion"); first_cancelled .recv_timeout(std::time::Duration::from_secs(1)) .expect("replacement cancels the previous clipboard presentation"); first_finished.send(Ok(())).expect("finish first cleanup"); previous .recv_timeout(std::time::Duration::from_secs(1)) .expect("replacement waits for previous cleanup") .expect("previous cleanup succeeds"); handle_terminal_ownership_lost( &mut app, &mut authentication, &mut git_control, &mut clipboard, ); assert_eq!(app.mode(), crate::app::Mode::Locked); assert!(!app.clipboard_pending()); assert!(app.status().contains("terminal ownership was lost")); cancelled .recv_timeout(std::time::Duration::from_secs(1)) .expect("clipboard cancellation"); assert_eq!(presentation.0, 0); } #[test] fn authentication_expiry_cancels_the_active_clipboard_presentation() { let mut app = App::new(); app.open_test_document("email/personal", fixture_document("email/personal")); assert!(matches!( app.dispatch(Action::Copy), AppEffect::CopyFocused { .. } )); let mut clipboard = ClipboardCancellations::default(); let (cancel, cancelled) = std::sync::mpsc::channel(); let (_finished, completion) = std::sync::mpsc::channel(); assert!(clipboard.replace(cancel, completion).is_none()); handle_authentication_expiry(&mut app, &mut clipboard); cancelled .recv_timeout(Duration::from_secs(1)) .expect("clipboard cancellation on expiry"); assert_eq!(app.mode(), crate::app::Mode::Locked); assert!(!app.clipboard_pending()); } #[derive(Default)] struct MemoryClipboard(Vec); impl ironstorage::presentation::ClipboardBackend for MemoryClipboard { fn read( &mut self, ) -> Result< ironstorage::presentation::ClipboardContent, ironstorage::presentation::ClipboardError, > { if self.0.is_empty() { Ok(ironstorage::presentation::ClipboardContent::EmptyOrNonText) } else { Ok(ironstorage::presentation::ClipboardContent::text( self.0.clone(), )) } } fn write( &mut self, value: &SecretBytes, ) -> Result<(), ironstorage::presentation::ClipboardError> { self.0 = value.expose().to_vec(); Ok(()) } fn clear(&mut self) -> Result<(), ironstorage::presentation::ClipboardError> { self.0.clear(); Ok(()) } } #[test] fn clipboard_worker_reports_the_deadline_used_by_its_cleanup_wait() { let presentation = crate::app::ClipboardPresentationId(7); let timeout = ironstorage::presentation::ClipboardTimeout::new(Duration::from_secs(3)) .expect("timeout"); let mut started = None; let mut waited_until = None; let payload = run_clipboard_presentation( Ok(ironstorage::presentation::ClipboardManager::new( MemoryClipboard(b"previous".to_vec()), timeout, )), presentation, SecretBytes::new(b"copied".to_vec()), |reported, deadline| started = Some((reported, deadline)), |deadline| { waited_until = Some(deadline); ironstorage::presentation::ClipboardWait::Elapsed }, ); let (reported, deadline) = started.expect("clipboard start"); assert_eq!(reported, presentation); assert_eq!(waited_until, Some(deadline)); assert!(matches!( payload, AsyncPayload::ClipboardFinished { presentation: finished, result: Ok(ironstorage::presentation::ClipboardDisposition::RestoredPrevious), } if finished == presentation )); } #[test] fn show_and_generate_presentation_requests_reach_clipboard_and_qr_outputs() { let temporary = tempfile::tempdir().expect("temporary presentation store"); let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); let store = temporary.path().join("store"); copy_directory(&fixtures.join("stores/basic"), &store); let config_path = temporary.path().join("config.toml"); fs::write( &config_path, format!( "vault = {:?}\ndefault_key = \"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\"\nkey_material = {:?}\n", store, fixtures.join("keys"), ), ) .expect("write config"); let config = ironstorage::config::Config::load(Some(&config_path)).expect("config"); let shown = execute_show_presentation( &config, ShowRequest { entry: Some("email/personal".to_owned()), presentation: Presentation::Clipboard { line: std::num::NonZeroUsize::new(1).expect("nonzero"), }, }, FixtureSecrets, ) .expect("show clipboard"); assert!(matches!( shown, AsyncPayload::SecretPresented { presentation: crate::app::SecretPresentation::Clipboard(secret), .. } if secret.expose() == b"correct horse fixture" )); let shown_qr = execute_show_presentation( &config, ShowRequest { entry: Some("email/personal".to_owned()), presentation: Presentation::QrCode { line: std::num::NonZeroUsize::new(1).expect("nonzero"), }, }, FixtureSecrets, ) .expect("show QR"); assert!(matches!( shown_qr, AsyncPayload::SecretPresented { presentation: crate::app::SecretPresentation::Qr { matrix, .. }, .. } if matrix.width() > 0 )); let generated = execute_workflow( &config, WorkflowSubmission::Generate { request: GenerateRequest { entry: "generated/tui-clipboard".to_owned(), length: Some(std::num::NonZeroUsize::new(24).expect("nonzero")), no_symbols: true, force: false, in_place: false, presentation: GeneratedPresentation::Clipboard, }, overwrite: OverwriteDecision::Decline, }, &mut FixtureSecrets, ) .expect("generate clipboard"); assert!(matches!( generated.presentation, Some(crate::app::SecretPresentation::Clipboard(secret)) if secret.expose().len() == 24 )); } #[test] fn editor_save_encrypts_and_automatically_commits_entirely_in_storage() { let temporary = tempfile::tempdir().expect("temporary editor store"); let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); let store = temporary.path().join("store"); copy_directory(&fixtures.join("stores/basic"), &store); let repository = ironstorage::repository::Repository::open(&store).expect("repository"); let identity = ironstorage::git::GitIdentity::ironstorage(); let git = ironstorage::git::GitRepository::init(&repository, identity.clone()) .expect("initialize git"); let initial_commits = git.log(None).expect("initial log").len(); assert!(initial_commits >= 1); drop(git); let config_path = temporary.path().join("config.toml"); fs::write( &config_path, format!( "vault = {:?}\ndefault_key = \"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\"\nkey_material = {:?}\n", store, fixtures.join("keys"), ), ) .expect("write config"); let config = ironstorage::config::Config::load(Some(&config_path)).expect("config"); let mut editor = EntryEditor::new(fixture_document_from(&store, "email/personal")); editor.begin_input(); editor.insert_character('x'); let outcome = save_document_inner(&config, "email/personal", &editor).expect("save"); assert_eq!(outcome.path().to_string(), "email/personal"); let reopened = fixture_document_from(&store, "email/personal"); assert!( reopened .password() .expect("password") .value() .ends_with(b"x") ); let git = ironstorage::git::GitRepository::open(&repository, identity).expect("reopen git"); assert_eq!(git.log(None).expect("saved log").len(), initial_commits + 1); } struct FixtureSecrets; impl SecretProvider for FixtureSecrets { fn secret_for(&mut self, key: &KeyInfo) -> Result { match key.fingerprint().as_str() { "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30" => { Ok(SecretBytes::new(b"fixture-alice-passphrase".to_vec())) } "B37027B56FC406BD3F6A622B2AC03492B992D06F" => { Ok(SecretBytes::new(b"fixture-bob-passphrase".to_vec())) } _ => Err(SecretProviderError::Unavailable), } } } struct UnavailableSecrets; impl SecretProvider for UnavailableSecrets { fn secret_for(&mut self, _key: &KeyInfo) -> Result { Err(SecretProviderError::Unavailable) } } #[test] fn write_workflows_use_storage_rules_refresh_and_commit_in_isolation() { let temporary = tempfile::tempdir().expect("temporary workflow store"); let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); let store = temporary.path().join("store"); copy_directory(&fixtures.join("stores/basic"), &store); let repository = ironstorage::repository::Repository::open(&store).expect("repository"); let identity = ironstorage::git::GitIdentity::ironstorage(); ironstorage::git::GitRepository::init(&repository, identity.clone()) .expect("initialize git"); let config_path = temporary.path().join("config.toml"); fs::write( &config_path, format!( "vault = {:?}\ndefault_key = \"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\"\nkey_material = {:?}\n", store, fixtures.join("keys"), ), ) .expect("write config"); let config = ironstorage::config::Config::load(Some(&config_path)).expect("config"); let mut secrets = FixtureSecrets; let initialized = execute_workflow( &config, WorkflowSubmission::Init(InitRequest { path: Some("nested/team".to_owned()), key_identities: vec![ "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30".to_owned(), "B37027B56FC406BD3F6A622B2AC03492B992D06F".to_owned(), ], }), &mut secrets, ) .expect("nested multi-recipient initialization"); assert!(initialized.document.is_none()); let policy = fs::read_to_string(store.join("nested/team/.gpg-id")).expect("policy"); assert_eq!(policy.lines().count(), 2); let inserted = execute_workflow( &config, WorkflowSubmission::Insert { request: InsertRequest { entry: "nested/team/account".to_owned(), input: InsertInput::Multiline, force: false, }, contents: InsertContent::multiline(b"old-password\nuser: alice\n".to_vec()), overwrite: OverwriteDecision::Decline, }, &mut secrets, ) .expect("multiline insert"); assert_eq!(inserted.entry.as_deref(), Some("nested/team/account")); assert!(inserted.document.is_some()); let ciphertext = fs::read(store.join("nested/team/account.gpg")).expect("ciphertext"); let git = ironstorage::git::GitRepository::open(&repository, identity.clone()).expect("open git"); let commits_before_failure = git.log(None).expect("log").len(); drop(git); let failure = execute_workflow( &config, WorkflowSubmission::Insert { request: InsertRequest { entry: "nested/team/account".to_owned(), input: InsertInput::EchoedLine, force: false, }, contents: InsertContent::echoed(b"must-not-win".to_vec()).expect("contents"), overwrite: OverwriteDecision::Decline, }, &mut secrets, ); assert!(failure.is_err()); assert_eq!( fs::read(store.join("nested/team/account.gpg")).expect("unchanged ciphertext"), ciphertext ); let git = ironstorage::git::GitRepository::open(&repository, identity.clone()).expect("open git"); assert_eq!(git.log(None).expect("log").len(), commits_before_failure); drop(git); let new_generated = execute_workflow( &config, WorkflowSubmission::Generate { request: GenerateRequest { entry: "nested/team/generated".to_owned(), length: Some(std::num::NonZeroUsize::new(19).expect("nonzero")), no_symbols: true, force: false, in_place: false, presentation: GeneratedPresentation::Terminal, }, overwrite: OverwriteDecision::Decline, }, &mut secrets, ) .expect("new generated entry"); assert_eq!( new_generated .document .expect("generated document") .password() .expect("password") .value() .len(), 19 ); let generated = execute_workflow( &config, WorkflowSubmission::Generate { request: GenerateRequest { entry: "nested/team/account".to_owned(), length: Some(std::num::NonZeroUsize::new(32).expect("nonzero")), no_symbols: true, force: false, in_place: true, presentation: GeneratedPresentation::Terminal, }, overwrite: OverwriteDecision::Allow, }, &mut secrets, ) .expect("in-place generation"); let document = generated.document.expect("generated document"); assert_eq!(document.password().expect("password").value().len(), 32); assert!( document .fields() .iter() .any(|field| field.value() == b"alice") ); let git = ironstorage::git::GitRepository::open(&repository, identity).expect("open git"); assert_eq!( git.log(None).expect("log").len(), commits_before_failure + 2 ); } #[test] fn decrypted_search_handles_cancel_empty_failure_and_result_navigation() { let temporary = tempfile::tempdir().expect("temporary search store"); let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); let store = temporary.path().join("store"); copy_directory(&fixtures.join("stores/basic"), &store); let config_path = temporary.path().join("config.toml"); fs::write( &config_path, format!( "vault = {:?}\ndefault_key = \"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\"\nkey_material = {:?}\n", store, fixtures.join("keys"), ), ) .expect("write config"); let config = ironstorage::config::Config::load(Some(&config_path)).expect("config"); let mut app = App::new(); app.sidebar_mut() .replace_tree(&load_tree(&config).expect("tree")); let repository = ironstorage::repository::Repository::open(config.vault()).expect("repository"); let keys = ironstorage::crypto::KeyStore::load(config.key_material()).expect("key material"); let terms = vec!["personal".to_owned()]; let find = ironstorage::read::VaultReader::new(&repository, &keys) .find(&terms) .expect("name find"); let token = app.begin_request(); assert_eq!( app.apply_result(crate::app::AsyncResult { token, payload: Ok(AsyncPayload::Found { query: "personal".to_owned(), results: find, }), }), crate::app::ResultDisposition::Applied ); assert_eq!(app.sidebar().filter_query(), "personal"); app.sidebar_mut().next_match(); assert_eq!( app.sidebar().selected().map(|selected| selected.path()), Some("email/personal") ); app.sidebar_mut().clear_filter_results(); assert!(matches!(app.dispatch(Action::Grep), AppEffect::None)); assert_eq!(app.mode(), crate::app::Mode::Dialog); app.handle_workflow_input( crossterm::event::KeyCode::Esc, crossterm::event::KeyModifiers::NONE, ); assert_eq!(app.mode(), crate::app::Mode::Browser); assert!(app.status().contains("cancelled")); let request = |pattern: &str| GrepRequest { pattern: pattern.to_owned(), ignore_case: false, invert_match: false, line_number: true, fixed_strings: true, }; let empty = execute_workflow( &config, WorkflowSubmission::Grep(request("not-present-anywhere")), &mut FixtureSecrets, ) .expect("empty grep"); assert!( empty .grep .as_ref() .is_some_and(|results| results.is_empty()) ); assert!(empty.status.contains("no matches")); let failure = execute_workflow( &config, WorkflowSubmission::Grep(request("password")), &mut UnavailableSecrets, ) .expect_err("decryption must need a secret"); assert!(!failure.contains("fixture-alice-passphrase")); let found = execute_workflow( &config, WorkflowSubmission::Grep(request("password")), &mut FixtureSecrets, ) .expect("matching grep"); let token = app.begin_request(); assert_eq!( app.apply_result(crate::app::AsyncResult { token, payload: Ok(AsyncPayload::WorkflowFinished(Ok(Box::new(found)))), }), crate::app::ResultDisposition::Applied ); let first = app .grep_view() .and_then(crate::search::GrepView::selected_entry) .expect("first result") .path() .to_string(); app.dispatch(Action::Next); let second = app .grep_view() .and_then(crate::search::GrepView::selected_entry) .expect("second result") .path() .to_string(); assert_ne!(first, second); assert!(matches!( app.dispatch(Action::Activate), AppEffect::AuthenticateEntry(ref entry) if entry == &second )); assert_eq!( app.sidebar().selected().map(|selected| selected.path()), Some(second.as_str()) ); assert!(app.grep_view().is_none()); } #[test] fn existing_tree_mutations_commit_refresh_and_preserve_failed_collisions() { let temporary = tempfile::tempdir().expect("temporary mutation store"); let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); let store = temporary.path().join("store"); copy_directory(&fixtures.join("stores/basic"), &store); let repository = ironstorage::repository::Repository::open(&store).expect("repository"); let identity = ironstorage::git::GitIdentity::ironstorage(); let git = ironstorage::git::GitRepository::init(&repository, identity.clone()) .expect("initialize git"); let initial_commits = git.log(None).expect("initial log").len(); drop(git); let config_path = temporary.path().join("config.toml"); fs::write( &config_path, format!( "vault = {:?}\ndefault_key = \"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\"\nkey_material = {:?}\n", store, fixtures.join("keys"), ), ) .expect("write config"); let config = ironstorage::config::Config::load(Some(&config_path)).expect("config"); let mut secrets = FixtureSecrets; let copied = execute_workflow( &config, WorkflowSubmission::Copy { request: CopyRequest { source: "email/personal".to_owned(), destination: "team/copied".to_owned(), force: false, }, overwrite: OverwriteDecision::Decline, }, &mut secrets, ) .expect("copy across recipient boundary"); assert_eq!(copied.selection, Some(("team/copied".to_owned(), false))); assert!(copied.tree.is_some()); assert_eq!( fixture_document_from(&store, "team/copied") .password() .expect("copied password") .value(), b"correct horse fixture" ); let collision_ciphertext = fs::read(store.join("team/service.gpg")).expect("collision ciphertext"); let git = ironstorage::git::GitRepository::open(&repository, identity.clone()).expect("open git"); let commits_before_collision = git.log(None).expect("log").len(); drop(git); let collision = execute_workflow( &config, WorkflowSubmission::Copy { request: CopyRequest { source: "email/personal".to_owned(), destination: "team/service".to_owned(), force: false, }, overwrite: OverwriteDecision::Decline, }, &mut secrets, ); assert!(collision.is_err()); assert_eq!( fs::read(store.join("team/service.gpg")).expect("unchanged collision"), collision_ciphertext ); let git = ironstorage::git::GitRepository::open(&repository, identity.clone()).expect("open git"); assert_eq!(git.log(None).expect("log").len(), commits_before_collision); drop(git); let moved = execute_workflow( &config, WorkflowSubmission::Move { request: MoveRequest { source: "team/copied".to_owned(), destination: "shared/".to_owned(), force: false, }, overwrite: OverwriteDecision::Decline, }, &mut secrets, ) .expect("move using explicit destination directory semantics"); assert_eq!(moved.selection, Some(("shared/copied".to_owned(), false))); assert!(store.join("shared/copied.gpg").is_file()); assert!(!store.join("team/copied.gpg").exists()); let copied_directory = execute_workflow( &config, WorkflowSubmission::Copy { request: CopyRequest { source: "team".to_owned(), destination: "archive".to_owned(), force: false, }, overwrite: OverwriteDecision::Decline, }, &mut secrets, ) .expect("copy folder"); assert_eq!( copied_directory.selection, Some(("archive".to_owned(), true)) ); assert!(store.join("archive/service.gpg").is_file()); execute_workflow( &config, WorkflowSubmission::Remove(RemoveRequest { entry: "archive".to_owned(), recursive: true, force: false, }), &mut secrets, ) .expect("recursive remove"); assert!(!store.join("archive").exists()); let git = ironstorage::git::GitRepository::open(&repository, identity).expect("open git"); assert_eq!(git.log(None).expect("log").len(), initial_commits + 4); } fn copy_directory(source: &Path, destination: &Path) { fs::create_dir_all(destination).expect("create destination"); for entry in fs::read_dir(source).expect("read source") { let entry = entry.expect("directory entry"); let target = destination.join(entry.file_name()); if entry.file_type().expect("file type").is_dir() { copy_directory(&entry.path(), &target); } else { fs::copy(entry.path(), target).expect("copy fixture file"); } } } }