From 6ea184cbc1bb7b4e7a12adfbcd0e136080e7d580 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 10 Aug 2026 09:22:55 +0000 Subject: [PATCH] Implement TUI init insert and generate workflows --- Cargo.lock | 1 + apps/tui/Cargo.toml | 1 + apps/tui/src/app.rs | 200 ++++++++++++- apps/tui/src/lib.rs | 349 +++++++++++++++++++++-- apps/tui/src/runtime.rs | 44 ++- apps/tui/src/ui.rs | 50 ++++ apps/tui/src/workflow.rs | 570 ++++++++++++++++++++++++++++++++++++++ crates/storage/src/git.rs | 30 ++ 8 files changed, 1213 insertions(+), 32 deletions(-) create mode 100644 apps/tui/src/workflow.rs diff --git a/Cargo.lock b/Cargo.lock index b5f98ef..8962024 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4046,6 +4046,7 @@ dependencies = [ "ironstorage", "ratatui", "tempfile", + "zeroize", ] [[package]] diff --git a/apps/tui/Cargo.toml b/apps/tui/Cargo.toml index 5f0682f..d458d7e 100644 --- a/apps/tui/Cargo.toml +++ b/apps/tui/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" crossterm.workspace = true ironstorage.workspace = true ratatui.workspace = true +zeroize.workspace = true [dev-dependencies] tempfile = "3" diff --git a/apps/tui/src/app.rs b/apps/tui/src/app.rs index 1e4e946..31e8191 100644 --- a/apps/tui/src/app.rs +++ b/apps/tui/src/app.rs @@ -21,6 +21,7 @@ use crate::{ editor::EntryEditor, sidebar::{Sidebar, SidebarIntent}, viewer::EntryViewer, + workflow::{WorkflowForm, WorkflowInput, WorkflowSubmission}, }; #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] @@ -64,6 +65,15 @@ pub struct StartupData { pub config: Config, pub tree: TreeModel, pub key: KeyInfo, + pub keys: Vec, +} + +#[derive(Debug)] +pub struct WorkflowSuccess { + pub tree: Option, + pub entry: Option, + pub document: Option>, + pub status: String, } #[derive(Debug)] @@ -88,6 +98,7 @@ pub enum AsyncPayload { editor: Box, result: Result, }, + WorkflowFinished(Result, String>), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -141,6 +152,7 @@ pub enum AppEffect { entry: String, editor: Box, }, + AuthenticateWorkflow(Box), OpenWorkflow(WorkflowAction), RunCommand(CommandRequest), ManualLock, @@ -160,6 +172,7 @@ pub struct App { status: String, config: Option, default_key: Option, + available_keys: Vec, sidebar: Sidebar, selected_entry: Option, viewer: Option, @@ -172,6 +185,8 @@ pub struct App { command_open_target: Option, editor_generation_pending: Option, authentication_pending: Option, + workflow: Option, + workflow_pending: bool, remaining_lease: Option, terminal_size: (u16, u16), ticks: u64, @@ -196,6 +211,7 @@ impl App { status: "Starting…".to_owned(), config: None, default_key: None, + available_keys: Vec::new(), sidebar: Sidebar::default(), selected_entry: None, viewer: None, @@ -208,6 +224,8 @@ impl App { command_open_target: None, editor_generation_pending: None, authentication_pending: None, + workflow: None, + workflow_pending: false, remaining_lease: None, terminal_size: (0, 0), ticks: 0, @@ -291,7 +309,11 @@ impl App { } pub fn authentication_pending(&self) -> bool { - self.authentication_pending.is_some() + self.authentication_pending.is_some() || self.workflow_pending + } + + pub fn workflow(&self) -> Option<&WorkflowForm> { + self.workflow.as_ref() } pub fn remaining_lease(&self) -> Option { @@ -354,6 +376,7 @@ impl App { self.status = format!("Vault: {}", startup.config.vault().display()); self.sidebar.replace_tree(&startup.tree); self.default_key = Some(startup.key); + self.available_keys = startup.keys; self.config = Some(startup.config); } Ok(AsyncPayload::Refreshed(tree)) => { @@ -459,6 +482,35 @@ impl App { }, } } + Ok(AsyncPayload::WorkflowFinished(result)) => { + self.workflow_pending = false; + match result { + Ok(success) => { + if let Some(tree) = success.tree.as_ref() { + self.sidebar.replace_tree(tree); + } + self.workflow = None; + self.suspended_mode = None; + if let (Some(entry), Some(document)) = (success.entry, success.document) { + self.selected_entry = Some(entry); + self.viewer = Some(EntryViewer::new(*document)); + self.editor = None; + self.mode = Mode::Viewer; + self.focus = PaneFocus::Main; + } else { + self.selected_entry = None; + self.viewer = None; + self.editor = None; + self.mode = Mode::Browser; + self.focus = PaneFocus::Sidebar; + } + self.status = success.status; + } + Err(error) => { + self.status = format!("Workflow failed: {error}. Form retained."); + } + } + } Err(error) => { self.status = error; self.editor_generation_pending = None; @@ -510,6 +562,8 @@ impl App { self.keep_editing(); } else if self.mode == Mode::Dialog && self.command_confirmation.is_some() { self.cancel_command_confirmation(); + } else if self.mode == Mode::Dialog && self.workflow.is_some() { + self.cancel_workflow(); } else { if self.mode == Mode::Command { self.command_line.clear(); @@ -706,6 +760,15 @@ impl App { | Action::GitPull | Action::GitPush => { let workflow = action.workflow().expect("matched workflow action"); + if matches!( + workflow, + WorkflowAction::Initialize + | WorkflowAction::InsertEntry + | WorkflowAction::GenerateEntry + ) { + self.open_workflow(workflow, None); + return AppEffect::None; + } self.status = format!("Selected {} workflow", workflow_label(workflow)); return AppEffect::OpenWorkflow(workflow); } @@ -723,6 +786,42 @@ impl App { .is_some_and(EntryEditor::is_input_active) } + pub fn handle_workflow_input( + &mut self, + code: crossterm::event::KeyCode, + modifiers: crossterm::event::KeyModifiers, + ) -> Option { + if self.mode != Mode::Dialog || self.workflow.is_none() { + return None; + } + if self.workflow_pending { + if code == crossterm::event::KeyCode::Esc { + self.status = "Wait for the active workflow to finish".to_owned(); + } + return Some(AppEffect::None); + } + let input = self.workflow.as_mut()?.handle_key(code, modifiers); + match input { + WorkflowInput::Consumed => Some(AppEffect::None), + WorkflowInput::Cancel => { + self.cancel_workflow(); + Some(AppEffect::None) + } + WorkflowInput::Lock => Some(self.dispatch(Action::Lock)), + WorkflowInput::Submit => match self.workflow.as_ref()?.submission() { + Ok(submission) => { + self.workflow_pending = true; + self.status = "Authenticating before applying the workflow…".to_owned(); + Some(AppEffect::AuthenticateWorkflow(Box::new(submission))) + } + Err(error) => { + self.status = format!("Validation: {error}"); + Some(AppEffect::None) + } + }, + } + } + pub fn handle_command_input( &mut self, code: crossterm::event::KeyCode, @@ -893,6 +992,19 @@ impl App { self.status = message; AppEffect::None } + CommandInvocation::Storage(request @ CommandRequest::Init(_)) + | CommandInvocation::Storage(request @ CommandRequest::Insert(_)) + | CommandInvocation::Storage(request @ CommandRequest::Generate(_)) => { + self.transition(Transition::Dismiss); + let workflow = match &request { + CommandRequest::Init(_) => WorkflowAction::Initialize, + CommandRequest::Insert(_) => WorkflowAction::InsertEntry, + CommandRequest::Generate(_) => WorkflowAction::GenerateEntry, + _ => unreachable!(), + }; + self.open_workflow(workflow, Some(request)); + AppEffect::None + } CommandInvocation::Storage(request) => { let operation = operation_name(&request); self.transition(Transition::Dismiss); @@ -929,6 +1041,53 @@ impl App { } } + fn open_workflow(&mut self, workflow: WorkflowAction, request: Option) { + let form = match (workflow, request) { + (WorkflowAction::Initialize, Some(CommandRequest::Init(request))) => { + WorkflowForm::init( + &self.available_keys, + self.default_key.as_ref(), + Some(request), + ) + } + (WorkflowAction::Initialize, None) => { + WorkflowForm::init(&self.available_keys, self.default_key.as_ref(), None) + } + (WorkflowAction::InsertEntry, Some(CommandRequest::Insert(request))) => { + WorkflowForm::insert(Some(request)) + } + (WorkflowAction::InsertEntry, None) => WorkflowForm::insert(None), + (WorkflowAction::GenerateEntry, Some(CommandRequest::Generate(request))) => { + WorkflowForm::generate(Some(request)) + } + (WorkflowAction::GenerateEntry, None) => WorkflowForm::generate(None), + _ => return, + }; + self.workflow = Some(form); + self.workflow_pending = false; + self.transition(Transition::OpenDialog); + self.status = "Tab moves, Space toggles, Ctrl-S submits, Esc cancels".to_owned(); + } + + fn cancel_workflow(&mut self) { + if self.workflow_pending { + self.status = "Wait for the active workflow to finish".to_owned(); + return; + } + self.workflow = None; + self.transition(Transition::Dismiss); + self.status = "Workflow cancelled; password store unchanged".to_owned(); + } + + pub fn workflow_authentication_granted(&mut self) -> bool { + self.mode == Mode::Dialog && self.workflow.is_some() && self.workflow_pending + } + + pub fn workflow_authentication_failed(&mut self, message: String) { + self.workflow_pending = false; + self.status = format!("Authentication failed: {message}. Form retained."); + } + fn confirm_command(&mut self) -> Option { if self.mode != Mode::Dialog { return None; @@ -1235,6 +1394,8 @@ impl App { self.command_help = None; self.command_open_target = None; self.editor_generation_pending = None; + self.workflow = None; + self.workflow_pending = false; self.status = "Locked".to_owned(); } else if current == Mode::Locked { self.status = "Authentication required".to_owned(); @@ -1498,11 +1659,10 @@ mod tests { #[test] fn direct_workflow_keys_dispatch_typed_ui_effects_without_domain_work() { let mut app = App::new(); - assert!(matches!( - app.dispatch(Action::InsertEntry), - AppEffect::OpenWorkflow(WorkflowAction::InsertEntry) - )); - assert!(app.status().contains("entry insertion")); + assert!(matches!(app.dispatch(Action::InsertEntry), AppEffect::None)); + assert_eq!(app.mode(), Mode::Dialog); + assert!(app.workflow().is_some()); + app.dispatch(Action::Cancel); app.open_test_document("email/personal", fixture_document("email/personal")); assert!(matches!( app.dispatch(Action::RemoveEntry), @@ -1511,6 +1671,34 @@ mod tests { assert!(app.status().contains("entry removal")); } + #[test] + fn colon_write_commands_open_the_same_secure_forms_with_typed_options() { + let mut app = App::new(); + assert!(matches!( + enter_command(&mut app, "insert --multiline --force nested/note"), + AppEffect::None + )); + assert_eq!(app.mode(), Mode::Dialog); + let rows = app.workflow().expect("insert form").rows().join("\n"); + assert!(rows.contains("nested/note")); + assert!(rows.contains("multiline")); + assert!(rows.contains("Overwrite: yes")); + app.handle_workflow_input( + crossterm::event::KeyCode::Esc, + crossterm::event::KeyModifiers::NONE, + ); + assert_eq!(app.mode(), Mode::Browser); + + assert!(matches!( + enter_command(&mut app, "generate --no-symbols --in-place nested/note 31"), + AppEffect::None + )); + let rows = app.workflow().expect("generate form").rows().join("\n"); + assert!(rows.contains("Length: 31")); + assert!(rows.contains("Symbols: no")); + assert!(rows.contains("In place: yes")); + } + #[test] fn colon_show_and_edit_route_to_authenticated_tui_panes() { let mut app = App::new(); diff --git a/apps/tui/src/lib.rs b/apps/tui/src/lib.rs index 3391a8e..940c06c 100644 --- a/apps/tui/src/lib.rs +++ b/apps/tui/src/lib.rs @@ -12,6 +12,7 @@ pub mod sidebar; pub mod terminal; pub mod ui; pub mod viewer; +pub mod workflow; use std::{ io, @@ -24,8 +25,14 @@ use ratatui::DefaultTerminal; use crate::{ action::{KeyResolution, KeyResolver}, - app::{App, AppEffect, AsyncPayload, EditorSaveFailure, EditorSaveFailureKind, StartupData}, - runtime::{AsyncExecutor, AuthenticationCoordinator, AuthenticationEvent}, + app::{ + App, AppEffect, AsyncPayload, EditorSaveFailure, EditorSaveFailureKind, StartupData, + WorkflowSuccess, + }, + runtime::{ + AsyncExecutor, AuthenticationCoordinator, AuthenticationEvent, AuthenticationTarget, + }, + workflow::WorkflowSubmission, }; const TICK_INTERVAL: Duration = Duration::from_millis(250); @@ -112,6 +119,17 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { ); 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 clipboard_cancellations, + ); + continue; + } if !key.modifiers.intersects( crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT @@ -178,13 +196,22 @@ fn apply_app_effect( } AppEffect::AuthenticateEntry(entry) => { if let Some(coordinator) = authentication.as_mut() { - coordinator.request(entry); + 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::CopyFocused(value) => { if let Some(config) = app.config().cloned() { let (cancel, cancellation) = mpsc::channel(); @@ -257,10 +284,16 @@ fn load_startup() -> Result { .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 }) + Ok(StartupData { + config, + tree, + key, + keys: all_keys, + }) } fn apply_authentication_event( @@ -270,27 +303,51 @@ fn apply_authentication_event( event: AuthenticationEvent, ) { match event { - AuthenticationEvent::Granted(entry) => { - if !app.authentication_granted(entry.clone()) { + 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(handle)) = (app.config().cloned(), coordinator.handle()) else { - app.authentication_failed( + 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_latest_request(); + let token = app.begin_request(); executor.submit(token, move || { - load_document(&config, &entry, handle).map(|document| { - AsyncPayload::DocumentLoaded { - entry, - document: Box::new(document), - } - }) + Ok(AsyncPayload::WorkflowFinished( + execute_workflow(&config, *submission, &mut handle).map(Box::new), + )) }); } - AuthenticationEvent::Failed(error) => app.authentication_failed(error), + AuthenticationEvent::Failed { workflow, message } => { + if workflow { + app.workflow_authentication_failed(message); + } else { + app.authentication_failed(message); + } + } AuthenticationEvent::Expired => app.forced_relock("authentication lease expired"), } } @@ -309,6 +366,97 @@ fn load_document( .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, GitIdentity}, + 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 (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())?; + drop(outcome); + ( + Some(entry.clone()), + format!("Generated password for {entry}"), + ) + } + }; + let tree = ironstorage::read::VaultReader::new(&repository, &keys) + .list(&ironstorage::repository::DirectoryPath::root()) + .map_err(|error| { + status.push_str(&format!("; sidebar refresh failed: {error}")); + }) + .ok(); + 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, + entry, + document, + status, + }) +} + fn save_document( config: &ironstorage::config::Config, entry: String, @@ -417,6 +565,15 @@ fn handle_filter_key(app: &mut App, code: crossterm::event::KeyCode) -> bool { mod tests { use std::{fs, path::Path}; + use ironstorage::{ + command::{ + GenerateRequest, GeneratedPresentation, InitRequest, InsertInput, InsertRequest, + }, + crypto::{KeyInfo, SecretProvider, SecretProviderError}, + repository::SecretBytes, + write::{InsertContent, OverwriteDecision}, + }; + use super::*; use crate::{editor::EntryEditor, viewer::test_support::fixture_document_from}; @@ -473,6 +630,166 @@ mod tests { 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), + } + } + } + + #[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 + ); + } + 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") { diff --git a/apps/tui/src/runtime.rs b/apps/tui/src/runtime.rs index 4eeb2a6..770b403 100644 --- a/apps/tui/src/runtime.rs +++ b/apps/tui/src/runtime.rs @@ -16,6 +16,7 @@ use ironstorage::{ }; use crate::app::{AsyncPayload, AsyncResult, RequestToken}; +use crate::workflow::WorkflowSubmission; pub struct AsyncExecutor { sender: Sender, @@ -23,16 +24,22 @@ pub struct AsyncExecutor { tasks: Mutex>>, } -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug)] pub enum AuthenticationEvent { - Granted(String), - Failed(String), + Granted(AuthenticationTarget), + Failed { workflow: bool, message: String }, Expired, } +#[derive(Debug)] +pub enum AuthenticationTarget { + Entry(String), + Workflow(Box), +} + struct AuthenticationCompletion { generation: u64, - entry: String, + target: AuthenticationTarget, result: Result, } @@ -64,7 +71,15 @@ impl AuthenticationCoordinator { }) } - pub fn request(&mut self, entry: String) { + pub fn request_entry(&mut self, entry: String) { + self.request(AuthenticationTarget::Entry(entry)); + } + + pub fn request_workflow(&mut self, submission: Box) { + self.request(AuthenticationTarget::Workflow(submission)); + } + + fn request(&mut self, target: AuthenticationTarget) { self.generation = self.generation.wrapping_add(1); let generation = self.generation; let session = self.session.clone(); @@ -76,7 +91,7 @@ impl AuthenticationCoordinator { .map_err(|error| error.to_string()); let _ignored = sender.send(AuthenticationCompletion { generation, - entry, + target, result, }); }); @@ -91,11 +106,14 @@ impl AuthenticationCoordinator { match completion.result { Ok(handle) => { self.handle = Some(handle); - Some(AuthenticationEvent::Granted(completion.entry)) + Some(AuthenticationEvent::Granted(completion.target)) } Err(error) => { self.handle = None; - Some(AuthenticationEvent::Failed(error)) + Some(AuthenticationEvent::Failed { + workflow: matches!(completion.target, AuthenticationTarget::Workflow(_)), + message: error, + }) } } } @@ -104,7 +122,10 @@ impl AuthenticationCoordinator { let handle = self.handle.as_ref()?; if let Err(error) = handle.touch_user_activity() { self.handle = None; - return Some(AuthenticationEvent::Failed(error.to_string())); + return Some(AuthenticationEvent::Failed { + workflow: false, + message: error.to_string(), + }); } None } @@ -118,7 +139,10 @@ impl AuthenticationCoordinator { Ok(false) => None, Err(error) => { self.handle = None; - Some(AuthenticationEvent::Failed(error.to_string())) + Some(AuthenticationEvent::Failed { + workflow: false, + message: error.to_string(), + }) } } } diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index 63abbdf..dadb209 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -118,6 +118,18 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { return; } + if app.mode() == Mode::Dialog + && let Some(workflow) = app.workflow() + { + frame.render_widget( + Paragraph::new(workflow.rows().join("\n")) + .block(Block::bordered().title(workflow.title())) + .wrap(Wrap { trim: false }), + area, + ); + return; + } + let class = layout_class(frame.area()); let (direction, constraints) = match class { LayoutClass::Narrow => ( @@ -422,6 +434,9 @@ fn status_line(app: &App) -> Paragraph<'_> { } fn context_line(app: &App) -> Paragraph<'static> { + if app.mode() == Mode::Dialog && app.workflow().is_some() { + return Paragraph::new("Tab/Shift-Tab focus Space toggle Ctrl-S submit Esc cancel"); + } let text = context_actions(app.mode()) .map(|spec| format!("{} {}", spec.bindings[0].display, spec.label)) .collect::>() @@ -436,6 +451,9 @@ fn prompt_line(app: &App) -> Paragraph<'static> { Mode::Dialog if app.command_confirmation_message().is_some() => { Paragraph::new("confirm> y / n / Esc").style(Style::default().fg(Color::Yellow)) } + Mode::Dialog if app.workflow().is_some() => { + Paragraph::new("form> ").style(Style::default().fg(Color::Yellow)) + } Mode::Dialog => Paragraph::new("dialog> ").style(Style::default().fg(Color::Yellow)), Mode::Editor if app.editor().is_some_and(EntryEditor::is_input_active) => { Paragraph::new("-- INSERT -- Esc stops input; Tab changes field; C-s saves") @@ -575,6 +593,38 @@ mod tests { assert!(confirmation.contains("confirm> y / n / Esc")); } + #[test] + fn workflow_dialog_is_keyboard_discoverable_and_masks_inserted_secrets() { + let mut app = App::new(); + app.dispatch(crate::action::Action::InsertEntry); + for character in "nested/account".chars() { + app.handle_workflow_input( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::NONE, + ); + } + app.handle_workflow_input( + crossterm::event::KeyCode::Tab, + crossterm::event::KeyModifiers::NONE, + ); + app.handle_workflow_input( + crossterm::event::KeyCode::Tab, + crossterm::event::KeyModifiers::NONE, + ); + for character in "never-render-this".chars() { + app.handle_workflow_input( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::NONE, + ); + } + let output = render(100, 24, &app); + assert!(output.contains("Insert entry")); + assert!(output.contains("nested/account")); + assert!(output.contains("Ctrl-S submit")); + assert!(output.contains("••••••••")); + assert!(!output.contains("never-render-this")); + } + #[test] fn hierarchy_selection_and_storage_indicators_have_stable_rendering() { let mut app = App::new(); diff --git a/apps/tui/src/workflow.rs b/apps/tui/src/workflow.rs new file mode 100644 index 0000000..9cb1ee4 --- /dev/null +++ b/apps/tui/src/workflow.rs @@ -0,0 +1,570 @@ +//! Keyboard-only presentation state for storage-owned write workflows. + +use std::{fmt, num::NonZeroUsize}; + +use crossterm::event::{KeyCode, KeyModifiers}; +use ironstorage::{ + command::{GenerateRequest, GeneratedPresentation, InitRequest, InsertInput, InsertRequest}, + crypto::KeyInfo, + write::{InsertContent, OverwriteDecision}, +}; +use zeroize::Zeroize; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InsertMode { + Hidden, + Echoed, + Multiline, +} + +impl InsertMode { + fn next(self) -> Self { + match self { + Self::Hidden => Self::Echoed, + Self::Echoed => Self::Multiline, + Self::Multiline => Self::Hidden, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Hidden => "single line (confirmed)", + Self::Echoed => "single line", + Self::Multiline => "multiline", + } + } +} + +#[derive(Default)] +struct SecretText { + characters: Vec, +} + +impl SecretText { + fn push(&mut self, character: char) { + self.characters.push(character); + } + + fn pop(&mut self) { + self.characters.pop(); + } + + fn bytes(&self) -> Vec { + self.characters.iter().collect::().into_bytes() + } + + fn is_empty(&self) -> bool { + self.characters.is_empty() + } +} + +impl Drop for SecretText { + fn drop(&mut self) { + self.characters.zeroize(); + } +} + +impl fmt::Debug for SecretText { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SecretText([REDACTED])") + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct RecipientChoice { + identity: String, + label: String, + selected: bool, +} + +#[derive(Debug)] +pub struct InitForm { + path: String, + recipients: Vec, + focus: usize, +} + +#[derive(Debug)] +pub struct InsertForm { + entry: String, + mode: InsertMode, + secret: SecretText, + confirmation: SecretText, + overwrite: bool, + focus: usize, +} + +#[derive(Debug)] +pub struct GenerateForm { + entry: String, + length: String, + no_symbols: bool, + overwrite: bool, + in_place: bool, + focus: usize, +} + +#[derive(Debug)] +pub enum WorkflowForm { + Init(InitForm), + Insert(InsertForm), + Generate(GenerateForm), +} + +#[derive(Debug)] +pub enum WorkflowSubmission { + Init(InitRequest), + Insert { + request: InsertRequest, + contents: InsertContent, + overwrite: OverwriteDecision, + }, + Generate { + request: GenerateRequest, + overwrite: OverwriteDecision, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum WorkflowInput { + Consumed, + Cancel, + Lock, + Submit, +} + +impl WorkflowForm { + pub fn init(keys: &[KeyInfo], default: Option<&KeyInfo>, request: Option) -> Self { + let request = request.unwrap_or_else(|| InitRequest { + path: None, + key_identities: default + .map(|key| vec![key.fingerprint().to_string()]) + .unwrap_or_default(), + }); + let mut recipients = keys + .iter() + .filter(|key| key.can_encrypt()) + .map(|key| { + let identity = key.fingerprint().to_string(); + let user = key + .user_ids() + .first() + .map_or("unknown identity", String::as_str); + RecipientChoice { + selected: request.key_identities.iter().any(|item| item == &identity), + label: format!("{} — {user}", key.key_id()), + identity, + } + }) + .collect::>(); + for identity in &request.key_identities { + if !recipients.iter().any(|choice| &choice.identity == identity) { + recipients.push(RecipientChoice { + identity: identity.clone(), + label: format!("requested identity {identity}"), + selected: true, + }); + } + } + Self::Init(InitForm { + path: request.path.unwrap_or_default(), + recipients, + focus: 0, + }) + } + + pub fn insert(request: Option) -> Self { + let request = request.unwrap_or(InsertRequest { + entry: String::new(), + input: InsertInput::HiddenConfirmed, + force: false, + }); + let mode = match request.input { + InsertInput::HiddenConfirmed => InsertMode::Hidden, + InsertInput::EchoedLine => InsertMode::Echoed, + InsertInput::Multiline => InsertMode::Multiline, + }; + Self::Insert(InsertForm { + entry: request.entry, + mode, + secret: SecretText::default(), + confirmation: SecretText::default(), + overwrite: request.force, + focus: 0, + }) + } + + pub fn generate(request: Option) -> Self { + let request = request.unwrap_or(GenerateRequest { + entry: String::new(), + length: None, + no_symbols: false, + force: false, + in_place: false, + presentation: GeneratedPresentation::Terminal, + }); + Self::Generate(GenerateForm { + entry: request.entry, + length: request + .length + .map_or_else(String::new, |length| length.to_string()), + no_symbols: request.no_symbols, + overwrite: request.force, + in_place: request.in_place, + focus: 0, + }) + } + + pub fn title(&self) -> &'static str { + match self { + Self::Init(_) => "Initialize recipients", + Self::Insert(_) => "Insert entry", + Self::Generate(_) => "Generate password", + } + } + + pub fn rows(&self) -> Vec { + match self { + Self::Init(form) => { + let mut rows = vec![row( + form.focus == 0, + "Path", + if form.path.is_empty() { + "/" + } else { + &form.path + }, + )]; + rows.push("Recipients (Space toggles):".to_owned()); + rows.extend(form.recipients.iter().enumerate().map(|(index, choice)| { + row( + form.focus == index + 1, + if choice.selected { "[x]" } else { "[ ]" }, + &choice.label, + ) + })); + rows + } + Self::Insert(form) => vec![ + row(form.focus == 0, "Entry", &form.entry), + row(form.focus == 1, "Input", form.mode.label()), + row( + form.focus == 2, + "Secret", + if form.secret.is_empty() { + "(empty)" + } else { + "••••••••" + }, + ), + row( + form.focus == 3, + "Confirm", + if form.mode != InsertMode::Hidden { + "(not required)" + } else if form.confirmation.is_empty() { + "(empty)" + } else { + "••••••••" + }, + ), + row(form.focus == 4, "Overwrite", yes_no(form.overwrite)), + ], + Self::Generate(form) => vec![ + row(form.focus == 0, "Entry", &form.entry), + row( + form.focus == 1, + "Length", + if form.length.is_empty() { + "pass default" + } else { + &form.length + }, + ), + row(form.focus == 2, "Symbols", yes_no(!form.no_symbols)), + row(form.focus == 3, "Overwrite", yes_no(form.overwrite)), + row(form.focus == 4, "In place", yes_no(form.in_place)), + ], + } + } + + pub fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> WorkflowInput { + if modifiers.contains(KeyModifiers::CONTROL) { + return match code { + KeyCode::Char('s') => WorkflowInput::Submit, + KeyCode::Char('l' | 'z') => WorkflowInput::Lock, + _ => WorkflowInput::Consumed, + }; + } + match code { + KeyCode::Esc => return WorkflowInput::Cancel, + KeyCode::Tab | KeyCode::Down => self.move_focus(true), + KeyCode::BackTab | KeyCode::Up => self.move_focus(false), + KeyCode::Char(' ') => self.space(), + KeyCode::Enter => self.enter(), + KeyCode::Backspace => self.backspace(), + KeyCode::Char(character) if !character.is_control() => self.character(character), + _ => {} + } + WorkflowInput::Consumed + } + + pub fn submission(&self) -> Result { + match self { + Self::Init(form) => { + let key_identities = form + .recipients + .iter() + .filter(|choice| choice.selected) + .map(|choice| choice.identity.clone()) + .collect::>(); + if key_identities.is_empty() { + return Err("Select at least one encryption recipient".to_owned()); + } + Ok(WorkflowSubmission::Init(InitRequest { + path: (!form.path.trim().is_empty()).then(|| form.path.trim().to_owned()), + key_identities, + })) + } + Self::Insert(form) => { + if form.entry.trim().is_empty() { + return Err("Entry path is required".to_owned()); + } + let (input, contents) = match form.mode { + InsertMode::Hidden => ( + InsertInput::HiddenConfirmed, + InsertContent::hidden(form.secret.bytes(), form.confirmation.bytes()), + ), + InsertMode::Echoed => ( + InsertInput::EchoedLine, + InsertContent::echoed(form.secret.bytes()), + ), + InsertMode::Multiline => ( + InsertInput::Multiline, + Ok(InsertContent::multiline(form.secret.bytes())), + ), + }; + Ok(WorkflowSubmission::Insert { + request: InsertRequest { + entry: form.entry.trim().to_owned(), + input, + force: form.overwrite, + }, + contents: contents.map_err(|error| error.to_string())?, + overwrite: if form.overwrite { + OverwriteDecision::Allow + } else { + OverwriteDecision::Decline + }, + }) + } + Self::Generate(form) => { + if form.entry.trim().is_empty() { + return Err("Entry path is required".to_owned()); + } + let length = if form.length.is_empty() { + None + } else { + Some( + form.length + .parse::() + .map_err(|_| "Length must be a positive integer".to_owned())?, + ) + }; + Ok(WorkflowSubmission::Generate { + request: GenerateRequest { + entry: form.entry.trim().to_owned(), + length, + no_symbols: form.no_symbols, + force: form.overwrite, + in_place: form.in_place, + presentation: GeneratedPresentation::Terminal, + }, + overwrite: if form.overwrite || form.in_place { + OverwriteDecision::Allow + } else { + OverwriteDecision::Decline + }, + }) + } + } + } + + fn focus_count(&self) -> usize { + match self { + Self::Init(form) => form.recipients.len() + 1, + Self::Insert(_) | Self::Generate(_) => 5, + } + } + + fn move_focus(&mut self, forward: bool) { + let count = self.focus_count(); + let focus = match self { + Self::Init(form) => &mut form.focus, + Self::Insert(form) => &mut form.focus, + Self::Generate(form) => &mut form.focus, + }; + *focus = if forward { + (*focus + 1) % count + } else { + (*focus + count - 1) % count + }; + } + + fn space(&mut self) { + match self { + Self::Init(form) if form.focus > 0 => form.recipients[form.focus - 1].selected ^= true, + Self::Insert(form) if form.focus == 1 => form.mode = form.mode.next(), + Self::Insert(form) if form.focus == 4 => form.overwrite ^= true, + Self::Generate(form) if form.focus == 2 => form.no_symbols ^= true, + Self::Generate(form) if form.focus == 3 => { + form.overwrite ^= true; + if form.overwrite { + form.in_place = false; + } + } + Self::Generate(form) if form.focus == 4 => { + form.in_place ^= true; + if form.in_place { + form.overwrite = false; + } + } + _ => self.character(' '), + } + } + + fn enter(&mut self) { + match self { + Self::Insert(form) if form.focus == 2 && form.mode == InsertMode::Multiline => { + form.secret.push('\n') + } + _ => self.space(), + } + } + + fn backspace(&mut self) { + match self { + Self::Init(form) if form.focus == 0 => { + form.path.pop(); + } + Self::Insert(form) if form.focus == 0 => { + form.entry.pop(); + } + Self::Insert(form) if form.focus == 2 => form.secret.pop(), + Self::Insert(form) if form.focus == 3 && form.mode == InsertMode::Hidden => { + form.confirmation.pop() + } + Self::Generate(form) if form.focus == 0 => { + form.entry.pop(); + } + Self::Generate(form) if form.focus == 1 => { + form.length.pop(); + } + _ => {} + } + } + + fn character(&mut self, character: char) { + match self { + Self::Init(form) if form.focus == 0 => form.path.push(character), + Self::Insert(form) if form.focus == 0 => form.entry.push(character), + Self::Insert(form) + if form.focus == 2 && (form.mode == InsertMode::Multiline || character != '\n') => + { + form.secret.push(character) + } + Self::Insert(form) + if form.focus == 3 && form.mode == InsertMode::Hidden && character != '\n' => + { + form.confirmation.push(character) + } + Self::Generate(form) if form.focus == 0 => form.entry.push(character), + Self::Generate(form) if form.focus == 1 && character.is_ascii_digit() => { + form.length.push(character) + } + _ => {} + } + } +} + +fn row(focused: bool, label: &str, value: &str) -> String { + format!("{} {label}: {value}", if focused { ">" } else { " " }) +} + +fn yes_no(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn type_text(form: &mut WorkflowForm, text: &str) { + for character in text.chars() { + form.handle_key(KeyCode::Char(character), KeyModifiers::NONE); + } + } + + #[test] + fn hidden_entry_is_confirmed_and_never_rendered_or_debugged() { + let mut form = WorkflowForm::insert(None); + type_text(&mut form, "nested/account"); + form.handle_key(KeyCode::Tab, KeyModifiers::NONE); + form.handle_key(KeyCode::Tab, KeyModifiers::NONE); + type_text(&mut form, "do-not-display"); + form.handle_key(KeyCode::Tab, KeyModifiers::NONE); + type_text(&mut form, "do-not-display"); + let rendered = form.rows().join("\n"); + let debug = format!("{form:?}"); + assert!(!rendered.contains("do-not-display")); + assert!(!debug.contains("do-not-display")); + assert!(matches!( + form.submission(), + Ok(WorkflowSubmission::Insert { .. }) + )); + } + + #[test] + fn confirmation_and_generation_validation_are_storage_compatible() { + let mut insert = WorkflowForm::insert(None); + type_text(&mut insert, "entry"); + insert.handle_key(KeyCode::Tab, KeyModifiers::NONE); + insert.handle_key(KeyCode::Tab, KeyModifiers::NONE); + type_text(&mut insert, "first"); + insert.handle_key(KeyCode::Tab, KeyModifiers::NONE); + type_text(&mut insert, "second"); + assert!(insert.submission().unwrap_err().contains("match")); + + let mut generate = WorkflowForm::generate(None); + type_text(&mut generate, "entry"); + generate.handle_key(KeyCode::Tab, KeyModifiers::NONE); + assert!(generate.submission().is_ok()); + type_text(&mut generate, "0"); + assert!(generate.submission().unwrap_err().contains("positive")); + } + + #[test] + fn multiline_input_and_generate_options_are_preserved() { + let mut insert = WorkflowForm::insert(Some(InsertRequest { + entry: "notes/item".into(), + input: InsertInput::Multiline, + force: true, + })); + insert.handle_key(KeyCode::Tab, KeyModifiers::NONE); + insert.handle_key(KeyCode::Tab, KeyModifiers::NONE); + type_text(&mut insert, "first"); + insert.handle_key(KeyCode::Enter, KeyModifiers::NONE); + type_text(&mut insert, "second"); + match insert.submission().expect("submission") { + WorkflowSubmission::Insert { + contents, + overwrite, + .. + } => { + assert_eq!(contents.expose(), b"first\nsecond"); + assert_eq!(overwrite, OverwriteDecision::Allow); + } + _ => panic!("wrong submission"), + } + } +} diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index f649a23..0fa3612 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -268,6 +268,36 @@ pub enum AutomaticEntryCommitter { None(NoGitEntryCommitter), } +/// Storage-owned selection of pass-compatible automatic Git commits for a +/// recipient policy change. +pub enum AutomaticPolicyCommitter { + Git(Box), + None(crate::recipient::NoGitCommitter), +} + +impl AutomaticPolicyCommitter { + pub fn for_directory( + repository: &Repository, + directory: &str, + identity: GitIdentity, + ) -> Result { + match GitRepository::open_innermost(repository, Path::new(directory), identity) { + Ok(git) => Ok(Self::Git(Box::new(git))), + Err(GitError::NotRepository) => Ok(Self::None(crate::recipient::NoGitCommitter)), + Err(error) => Err(error), + } + } +} + +impl PolicyCommitter for AutomaticPolicyCommitter { + fn commit(&mut self, change: &PolicyCommit) -> Result<(), PolicyCommitError> { + match self { + Self::Git(git) => PolicyCommitter::commit(git.as_mut(), change), + Self::None(committer) => committer.commit(change), + } + } +} + impl AutomaticEntryCommitter { pub fn for_entry( repository: &Repository,