From b503de8b2e33753444f7195b4ada78e14e3333a1 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 10 Aug 2026 07:22:55 +0000 Subject: [PATCH] Implement the in-process structured entry editor Closes #21 --- Cargo.lock | 1 + apps/tui/Cargo.toml | 3 + apps/tui/src/action.rs | 98 +++- apps/tui/src/app.rs | 504 +++++++++++++++++++- apps/tui/src/editor.rs | 482 +++++++++++++++++++ apps/tui/src/lib.rs | 154 +++++- apps/tui/src/ui.rs | 151 +++++- apps/tui/src/viewer.rs | 14 +- crates/storage/src/document.rs | 47 ++ crates/storage/src/generate.rs | 36 +- crates/storage/src/git.rs | 36 +- crates/storage/src/write.rs | 16 +- crates/storage/tests/entry_documents.rs | 27 +- crates/storage/tests/password_generation.rs | 15 + 14 files changed, 1547 insertions(+), 37 deletions(-) create mode 100644 apps/tui/src/editor.rs diff --git a/Cargo.lock b/Cargo.lock index d00be26..b5f98ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4045,6 +4045,7 @@ dependencies = [ "crossterm", "ironstorage", "ratatui", + "tempfile", ] [[package]] diff --git a/apps/tui/Cargo.toml b/apps/tui/Cargo.toml index 33dcadb..5f0682f 100644 --- a/apps/tui/Cargo.toml +++ b/apps/tui/Cargo.toml @@ -14,3 +14,6 @@ path = "src/main.rs" crossterm.workspace = true ironstorage.workspace = true ratatui.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/apps/tui/src/action.rs b/apps/tui/src/action.rs index 904f69d..83012cd 100644 --- a/apps/tui/src/action.rs +++ b/apps/tui/src/action.rs @@ -33,6 +33,16 @@ pub enum Action { ScrollDown, ScrollUp, CloseEntry, + EditEntry, + BeginInput, + SaveEditor, + AddField, + RemoveField, + MoveFieldUp, + MoveFieldDown, + Generate, + ConfirmDiscard, + KeepEditing, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -59,7 +69,15 @@ impl ActionSpec { const BROWSER_LIKE: &[Mode] = &[Mode::Browser, Mode::Viewer]; const UNLOCKED: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Editor]; -const OVERLAYS: &[Mode] = &[Mode::Help, Mode::Command, Mode::Dialog]; +const LOCKABLE: &[Mode] = &[ + Mode::Browser, + Mode::Viewer, + Mode::Editor, + Mode::Dialog, + Mode::Help, + Mode::Command, +]; +const EDITOR_AND_OVERLAYS: &[Mode] = &[Mode::Editor, Mode::Help, Mode::Command, Mode::Dialog]; #[cfg(test)] const ALL: &[Mode] = &[ Mode::Browser, @@ -111,7 +129,7 @@ pub static ACTIONS: &[ActionSpec] = &[ label: "back", command: "cancel", bindings: keys!((KeyCode::Esc, KeyModifiers::NONE, "Esc")), - modes: OVERLAYS, + modes: EDITOR_AND_OVERLAYS, }, ActionSpec { action: Action::Lock, @@ -121,7 +139,7 @@ pub static ACTIONS: &[ActionSpec] = &[ (KeyCode::Char('l'), KeyModifiers::CONTROL, "C-l"), (KeyCode::Char('z'), KeyModifiers::CONTROL, "C-z"), ), - modes: UNLOCKED, + modes: LOCKABLE, }, ActionSpec { action: Action::Refresh, @@ -245,14 +263,14 @@ pub static ACTIONS: &[ActionSpec] = &[ label: "reveal field", command: "reveal", bindings: keys!((KeyCode::Char('v'), KeyModifiers::NONE, "v")), - modes: &[Mode::Viewer], + modes: &[Mode::Viewer, Mode::Editor], }, ActionSpec { action: Action::Hide, label: "hide field", command: "hide", bindings: keys!((KeyCode::Char('V'), KeyModifiers::SHIFT, "V")), - modes: &[Mode::Viewer], + modes: &[Mode::Viewer, Mode::Editor], }, ActionSpec { action: Action::Copy, @@ -290,6 +308,76 @@ pub static ACTIONS: &[ActionSpec] = &[ bindings: keys!((KeyCode::Esc, KeyModifiers::NONE, "Esc")), modes: &[Mode::Viewer], }, + ActionSpec { + action: Action::EditEntry, + label: "edit entry", + command: "edit-entry", + bindings: keys!((KeyCode::Char('e'), KeyModifiers::NONE, "e")), + modes: &[Mode::Viewer], + }, + ActionSpec { + action: Action::BeginInput, + label: "edit field", + command: "edit-field", + bindings: keys!((KeyCode::Char('i'), KeyModifiers::NONE, "i")), + modes: &[Mode::Editor], + }, + ActionSpec { + action: Action::SaveEditor, + label: "save", + command: "save", + bindings: keys!((KeyCode::Char('s'), KeyModifiers::CONTROL, "C-s")), + modes: &[Mode::Editor], + }, + ActionSpec { + action: Action::AddField, + label: "add field", + command: "add-field", + bindings: keys!((KeyCode::Char('a'), KeyModifiers::NONE, "a")), + modes: &[Mode::Editor], + }, + ActionSpec { + action: Action::RemoveField, + label: "remove field", + command: "remove-field", + bindings: keys!((KeyCode::Char('d'), KeyModifiers::NONE, "d")), + modes: &[Mode::Editor], + }, + ActionSpec { + action: Action::MoveFieldUp, + label: "move field up", + command: "move-field-up", + bindings: keys!((KeyCode::Char('K'), KeyModifiers::SHIFT, "K")), + modes: &[Mode::Editor], + }, + ActionSpec { + action: Action::MoveFieldDown, + label: "move field down", + command: "move-field-down", + bindings: keys!((KeyCode::Char('J'), KeyModifiers::SHIFT, "J")), + modes: &[Mode::Editor], + }, + ActionSpec { + action: Action::Generate, + label: "generate field", + command: "generate-field", + bindings: keys!((KeyCode::Char('g'), KeyModifiers::NONE, "g")), + modes: &[Mode::Editor], + }, + ActionSpec { + action: Action::ConfirmDiscard, + label: "discard edits", + command: "discard-edits", + bindings: keys!((KeyCode::Char('y'), KeyModifiers::NONE, "y")), + modes: &[Mode::Dialog], + }, + ActionSpec { + action: Action::KeepEditing, + label: "keep editing", + command: "keep-editing", + bindings: keys!((KeyCode::Char('n'), KeyModifiers::NONE, "n")), + modes: &[Mode::Dialog], + }, ]; pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option { diff --git a/apps/tui/src/app.rs b/apps/tui/src/app.rs index 20f0d92..ceb86b6 100644 --- a/apps/tui/src/app.rs +++ b/apps/tui/src/app.rs @@ -5,14 +5,16 @@ use std::collections::BTreeSet; use ironstorage::{ config::Config, crypto::KeyInfo, - document::EntryDocument, + document::{DocumentError, EntryDocument, EntryFieldId}, presentation::ClipboardDisposition, read::{FindResults, TreeModel}, repository::SecretBytes, + write::WriteOutcome, }; use crate::{ action::Action, + editor::EntryEditor, sidebar::{Sidebar, SidebarIntent}, viewer::EntryViewer, }; @@ -73,6 +75,42 @@ pub enum AsyncPayload { document: Box, }, ClipboardFinished(ClipboardDisposition), + GeneratedField { + target: EntryFieldId, + password: SecretBytes, + }, + DocumentSaveFinished { + entry: String, + editor: Box, + result: Result, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EditorSaveFailureKind { + Conflict, + Unchanged, + Storage, +} + +#[derive(Debug)] +pub struct EditorSaveFailure { + kind: EditorSaveFailureKind, + message: String, +} + +impl EditorSaveFailure { + pub fn new(kind: EditorSaveFailureKind, message: String) -> Self { + Self { kind, message } + } + + pub fn kind(&self) -> EditorSaveFailureKind { + self.kind + } + + pub fn message(&self) -> &str { + &self.message + } } #[derive(Debug)] @@ -93,6 +131,12 @@ pub enum AppEffect { RefreshTree, AuthenticateEntry(String), CopyFocused(SecretBytes), + GenerateField(EntryFieldId), + SaveDocument { + config: Box, + entry: String, + editor: Box, + }, ManualLock, } @@ -107,6 +151,9 @@ pub struct App { sidebar: Sidebar, selected_entry: Option, viewer: Option, + editor: Option, + discard_confirmation: bool, + editor_generation_pending: Option, authentication_pending: Option, remaining_lease: Option, terminal_size: (u16, u16), @@ -135,6 +182,9 @@ impl App { sidebar: Sidebar::default(), selected_entry: None, viewer: None, + editor: None, + discard_confirmation: false, + editor_generation_pending: None, authentication_pending: None, remaining_lease: None, terminal_size: (0, 0), @@ -178,6 +228,14 @@ impl App { self.viewer.as_ref() } + pub fn editor(&self) -> Option<&EntryEditor> { + self.editor.as_ref() + } + + pub fn discard_confirmation(&self) -> bool { + self.discard_confirmation + } + pub fn default_key(&self) -> Option<&KeyInfo> { self.default_key.as_ref() } @@ -278,8 +336,75 @@ impl App { } }; } + Ok(AsyncPayload::GeneratedField { target, password }) => { + if !self.editor_context_active() + || self.editor_generation_pending.take() != Some(target) + { + return ResultDisposition::Stale; + } + let Some(editor) = self.editor.as_mut() else { + return ResultDisposition::Stale; + }; + match editor.apply_generated(target, password) { + Ok(()) => { + self.status = "Generated password applied to focused field".to_owned() + } + Err(error) => self.status = error.to_string(), + } + } + Ok(AsyncPayload::DocumentSaveFinished { + entry, + editor, + result, + }) => { + if !self.editor_context_active() || self.selected_entry.as_deref() != Some(&entry) { + return ResultDisposition::Stale; + } + match result { + Ok(_) => { + self.editor = None; + self.selected_entry = None; + self.suspended_mode = None; + self.discard_confirmation = false; + self.editor_generation_pending = None; + self.mode = Mode::Browser; + self.focus = PaneFocus::Sidebar; + self.status = format!("Saved {entry}"); + } + Err(failure) => match failure.kind() { + EditorSaveFailureKind::Unchanged => match (*editor).into_document() { + Ok(document) => { + self.viewer = Some(EntryViewer::new(document)); + self.suspended_mode = None; + self.discard_confirmation = false; + self.mode = Mode::Viewer; + self.focus = PaneFocus::Main; + self.status = "No changes to save".to_owned(); + } + Err(failed) => { + let (editor, error) = *failed; + self.editor = Some(editor); + self.status = error.to_string(); + } + }, + EditorSaveFailureKind::Conflict => { + self.editor = Some(*editor); + self.status = format!( + "Concurrent change detected: {}. Draft retained; Esc offers discard.", + failure.message() + ); + } + EditorSaveFailureKind::Storage => { + self.editor = Some(*editor); + self.status = + format!("Save failed: {}. Draft retained.", failure.message()); + } + }, + } + } Err(error) => { self.status = error; + self.editor_generation_pending = None; if self.mode == Mode::Viewer && self.viewer.is_none() { self.mode = Mode::Browser; self.focus = PaneFocus::Sidebar; @@ -291,6 +416,23 @@ impl App { } pub fn dispatch(&mut self, action: Action) -> AppEffect { + if self.editor_generation_pending.is_some() + && matches!( + action, + Action::FocusNext + | Action::FocusPrevious + | Action::BeginInput + | Action::SaveEditor + | Action::AddField + | Action::RemoveField + | Action::MoveFieldUp + | Action::MoveFieldDown + | Action::Generate + ) + { + self.status = "Wait for password generation to finish".to_owned(); + return AppEffect::None; + } match action { Action::Quit if matches!(self.mode, Mode::Browser | Mode::Viewer) => { self.should_quit = true; @@ -302,7 +444,13 @@ impl App { self.transition(Transition::OpenCommand); } Action::Cancel => { - self.transition(Transition::Dismiss); + if self.mode == Mode::Editor { + self.cancel_editor(); + } else if self.mode == Mode::Dialog && self.discard_confirmation { + self.keep_editing(); + } else { + self.transition(Transition::Dismiss); + } } Action::Lock => { self.transition(Transition::Lock); @@ -351,6 +499,14 @@ impl App { viewer.focus_previous(); } } + Action::FocusNext if self.mode == Mode::Editor => { + let result = self.editor.as_mut().map(EntryEditor::focus_next); + self.report_editor_result(result); + } + Action::FocusPrevious if self.mode == Mode::Editor => { + let result = self.editor.as_mut().map(EntryEditor::focus_previous); + self.report_editor_result(result); + } Action::FocusNext | Action::FocusPrevious => { self.focus = match self.focus { PaneFocus::Sidebar => PaneFocus::Main, @@ -358,16 +514,26 @@ impl App { }; } Action::Reveal => { - if self - .viewer - .as_mut() - .is_some_and(EntryViewer::reveal_focused) - { + let revealed = if self.mode == Mode::Editor { + self.editor + .as_mut() + .is_some_and(EntryEditor::reveal_focused) + } else { + self.viewer + .as_mut() + .is_some_and(EntryViewer::reveal_focused) + }; + if revealed { self.status = "Focused sensitive field revealed".to_owned(); } } Action::Hide => { - if self.viewer.as_mut().is_some_and(EntryViewer::hide_revealed) { + let hidden = if self.mode == Mode::Editor { + self.editor.as_mut().is_some_and(EntryEditor::hide_revealed) + } else { + self.viewer.as_mut().is_some_and(EntryViewer::hide_revealed) + }; + if hidden { self.status = "Sensitive field hidden".to_owned(); } } @@ -403,11 +569,236 @@ impl App { Action::CloseEntry => { self.transition(Transition::CloseEntry); } + Action::EditEntry => { + if let Some(viewer) = self.viewer.take() { + self.editor = Some(EntryEditor::new(viewer.into_document())); + self.transition(Transition::EditEntry); + self.status = "Structured editor: i edits, C-s saves, Esc cancels".to_owned(); + } + } + Action::BeginInput => { + if let Some(editor) = self.editor.as_mut() { + editor.begin_input(); + self.status = "Editing raw structured field; Esc stops input".to_owned(); + } + } + Action::SaveEditor => return self.begin_editor_save(), + Action::AddField => { + let result = self.editor.as_mut().map(EntryEditor::add_after_focused); + self.report_editor_result(result); + } + Action::RemoveField => { + let result = self.editor.as_mut().map(EntryEditor::remove_focused); + self.report_editor_result(result); + } + Action::MoveFieldUp => { + let result = self.editor.as_mut().map(EntryEditor::move_focused_up); + self.report_editor_result(result); + } + Action::MoveFieldDown => { + let result = self.editor.as_mut().map(EntryEditor::move_focused_down); + self.report_editor_result(result); + } + Action::Generate => { + if self.editor_generation_pending.is_none() + && let Some(editor) = self.editor.as_mut() + { + match editor.generation_target() { + Ok(Some(target)) => { + self.editor_generation_pending = Some(target); + self.status = "Generating password for focused field…".to_owned(); + return AppEffect::GenerateField(target); + } + Ok(None) => { + self.status = "Focused field is not a password field".to_owned(); + } + Err(error) => self.status = error.to_string(), + } + } + } + Action::ConfirmDiscard => self.discard_editor(), + Action::KeepEditing => self.keep_editing(), Action::Quit => {} } AppEffect::None } + pub fn editor_input_active(&self) -> bool { + self.mode == Mode::Editor + && self.editor_generation_pending.is_none() + && self + .editor + .as_ref() + .is_some_and(EntryEditor::is_input_active) + } + + fn editor_context_active(&self) -> bool { + self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor) + } + + pub fn handle_editor_input(&mut self, code: crossterm::event::KeyCode) -> bool { + use crossterm::event::KeyCode; + + if !self.editor_input_active() { + return false; + } + let editor = self.editor.as_mut().expect("active editor input has state"); + let result = match code { + KeyCode::Esc => { + editor.end_input(); + self.status = "Field input ended".to_owned(); + return true; + } + KeyCode::Enter => editor.split_line(), + KeyCode::Backspace => { + editor.backspace(); + return true; + } + KeyCode::Delete => { + editor.delete(); + return true; + } + KeyCode::Left => { + editor.move_cursor_left(); + return true; + } + KeyCode::Right => { + editor.move_cursor_right(); + return true; + } + KeyCode::Home => { + editor.move_cursor_home(); + return true; + } + KeyCode::End => { + editor.move_cursor_end(); + return true; + } + KeyCode::Char(character) => { + editor.insert_character(character); + return true; + } + _ => return false, + }; + if let Err(error) = result { + self.status = error.to_string(); + } + true + } + + fn report_editor_result(&mut self, result: Option>) { + if let Some(Err(error)) = result { + self.status = error.to_string(); + } + } + + fn begin_editor_save(&mut self) -> AppEffect { + if self.editor_generation_pending.is_some() { + self.status = "Wait for password generation before saving".to_owned(); + return AppEffect::None; + } + let Some(config) = self.config.clone() else { + self.status = "Cannot save before configuration is loaded".to_owned(); + return AppEffect::None; + }; + let Some(editor) = self.editor.take() else { + return AppEffect::None; + }; + let editor = match editor.prepare_save() { + Ok(editor) => editor, + Err(failure) => { + let (editor, error) = *failure; + self.editor = Some(editor); + self.status = error.to_string(); + return AppEffect::None; + } + }; + if !editor.is_dirty() { + match editor.into_document() { + Ok(document) => { + self.viewer = Some(EntryViewer::new(document)); + self.mode = Mode::Viewer; + self.status = "No changes to save".to_owned(); + } + Err(failure) => { + let (editor, error) = *failure; + self.editor = Some(editor); + self.status = error.to_string(); + } + } + return AppEffect::None; + } + let Some(entry) = self.selected_entry.clone() else { + self.editor = Some(editor); + self.status = "Cannot save an editor without an entry identity".to_owned(); + return AppEffect::None; + }; + self.status = format!("Saving {entry}…"); + AppEffect::SaveDocument { + config: Box::new(config), + entry, + editor: Box::new(editor), + } + } + + fn cancel_editor(&mut self) { + let Some(editor) = self.editor.take() else { + return; + }; + let editor = match editor.prepare_save() { + Ok(editor) => editor, + Err(failure) => { + let (editor, error) = *failure; + self.editor = Some(editor); + self.status = error.to_string(); + return; + } + }; + if editor.is_dirty() { + self.editor = Some(editor); + self.discard_confirmation = true; + self.transition(Transition::OpenDialog); + self.status = "Discard unsaved edits? y discards; n or Esc keeps editing".to_owned(); + return; + } + match editor.into_document() { + Ok(document) => { + self.viewer = Some(EntryViewer::new(document)); + self.mode = Mode::Viewer; + self.status = "Edit cancelled".to_owned(); + } + Err(failure) => { + let (editor, error) = *failure; + self.editor = Some(editor); + self.status = error.to_string(); + } + } + } + + fn discard_editor(&mut self) { + if self.mode != Mode::Dialog || !self.discard_confirmation { + return; + } + self.editor = None; + self.viewer = None; + self.selected_entry = None; + self.discard_confirmation = false; + self.suspended_mode = None; + self.editor_generation_pending = None; + self.mode = Mode::Browser; + self.focus = PaneFocus::Sidebar; + self.status = "Unsaved edits discarded".to_owned(); + } + + fn keep_editing(&mut self) { + if self.mode != Mode::Dialog || !self.discard_confirmation { + return; + } + self.discard_confirmation = false; + self.transition(Transition::Dismiss); + self.status = "Continuing edit".to_owned(); + } + pub fn authentication_granted(&mut self, entry: String) -> bool { if self.authentication_pending.as_deref() != Some(&entry) { return false; @@ -415,6 +806,7 @@ impl App { self.authentication_pending = None; self.selected_entry = Some(entry); self.viewer = None; + self.editor = None; self.status = "Authenticated; loading structured entry…".to_owned(); let transitioned = self.transition(Transition::OpenEntry); if transitioned { @@ -427,6 +819,9 @@ impl App { self.authentication_pending = None; self.selected_entry = None; self.viewer = None; + self.editor = None; + self.discard_confirmation = false; + self.editor_generation_pending = None; self.status = message; if self.mode != Mode::Browser { self.mode = Mode::Browser; @@ -443,7 +838,7 @@ impl App { } pub fn forced_relock(&mut self, reason: &'static str) { - let discarded_edit = self.mode == Mode::Editor; + let discarded_edit = self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor); self.transition(Transition::Lock); self.authentication_pending = None; self.remaining_lease = None; @@ -470,7 +865,15 @@ impl App { (Mode::Dialog | Mode::Help | Mode::Command, Transition::Dismiss) => { self.suspended_mode.take() } - (Mode::Browser | Mode::Viewer | Mode::Editor, Transition::Lock) => Some(Mode::Locked), + ( + Mode::Browser + | Mode::Viewer + | Mode::Editor + | Mode::Dialog + | Mode::Help + | Mode::Command, + Transition::Lock, + ) => Some(Mode::Locked), (Mode::Locked, Transition::Unlock) => Some(Mode::Browser), _ => None, }; @@ -482,12 +885,19 @@ impl App { if let Some(viewer) = self.viewer.as_mut() { viewer.hide_revealed(); } + if let Some(editor) = self.editor.as_mut() { + editor.hide_revealed(); + editor.end_input(); + } self.suspended_mode = Some(current); } else if destination == Mode::Browser && matches!(current, Mode::Viewer | Mode::Editor) { self.selected_entry = None; self.viewer = None; + self.editor = None; self.authentication_pending = None; self.remaining_lease = None; + self.discard_confirmation = false; + self.editor_generation_pending = None; self.focus = PaneFocus::Sidebar; } else if destination == Mode::Locked { self.suspended_mode = None; @@ -495,6 +905,9 @@ impl App { self.invalidate_requests(); self.selected_entry = None; self.viewer = None; + self.editor = None; + self.discard_confirmation = false; + self.editor_generation_pending = None; self.status = "Locked".to_owned(); } else if current == Mode::Locked { self.status = "Authentication required".to_owned(); @@ -521,6 +934,7 @@ impl App { #[cfg(test)] mod tests { use super::*; + use crate::viewer::test_support::fixture_document; #[test] fn every_legal_transition_reaches_its_destination() { @@ -651,4 +1065,74 @@ mod tests { assert_eq!(app.remaining_lease(), None); assert!(app.status().contains("unsaved edits were discarded")); } + + #[test] + fn conflict_and_storage_save_failures_restore_the_complete_editor_draft() { + for (kind, message) in [ + (EditorSaveFailureKind::Conflict, "concurrent ciphertext"), + (EditorSaveFailureKind::Storage, "encryption unavailable"), + ] { + let mut app = App::new(); + app.open_test_document("email/personal", fixture_document("email/personal")); + app.dispatch(Action::EditEntry); + app.editor + .as_mut() + .expect("editor") + .add_after_focused() + .expect("dirty document"); + let editor = Box::new(app.editor.take().expect("move editor to worker")); + let token = app.begin_request(); + assert_eq!( + app.apply_result(AsyncResult { + token, + payload: Ok(AsyncPayload::DocumentSaveFinished { + entry: "email/personal".to_owned(), + editor, + result: Err(EditorSaveFailure::new(kind, message.to_owned())), + }), + }), + ResultDisposition::Applied + ); + assert_eq!(app.mode(), Mode::Editor); + assert!(app.editor().is_some_and(EntryEditor::is_dirty)); + assert!(app.status().contains(message)); + } + } + + #[test] + fn editor_results_complete_safely_while_help_temporarily_has_focus() { + let mut app = App::new(); + app.open_test_document("email/personal", fixture_document("email/personal")); + app.dispatch(Action::EditEntry); + let target = app + .editor + .as_mut() + .expect("editor") + .generation_target() + .expect("target") + .expect("password target"); + app.editor_generation_pending = Some(target); + let token = app.begin_request(); + app.dispatch(Action::Help); + assert_eq!(app.mode(), Mode::Help); + assert_eq!( + app.apply_result(AsyncResult { + token, + payload: Ok(AsyncPayload::GeneratedField { + target, + password: SecretBytes::new(b"generated-under-help".to_vec()), + }), + }), + ResultDisposition::Applied + ); + app.dispatch(Action::Cancel); + assert_eq!(app.mode(), Mode::Editor); + assert_eq!( + app.editor() + .and_then(EntryEditor::focused_field) + .expect("password") + .value(), + b"generated-under-help" + ); + } } diff --git a/apps/tui/src/editor.rs b/apps/tui/src/editor.rs new file mode 100644 index 0000000..b368efb --- /dev/null +++ b/apps/tui/src/editor.rs @@ -0,0 +1,482 @@ +//! In-process structured document editing state. + +use std::fmt; + +use ironstorage::{ + document::{ + DocumentError, EntryDocument, EntryField, EntryFieldDraft, EntryFieldId, EntryFieldKind, + EntrySensitivity, + }, + repository::SecretBytes, +}; + +/// Presentation state for editing one storage-owned document. +/// +/// The focused buffer contains one complete pass entry line. Committing that +/// buffer delegates classification back to `EntryDocument`, so names, duplicate +/// fields, notes, and OTP URI recognition remain storage behavior. +pub struct EntryEditor { + document: EntryDocument, + focused: usize, + buffer: SecretBytes, + cursor: usize, + input_active: bool, + revealed: bool, + dirty: bool, +} + +impl EntryEditor { + pub fn new(document: EntryDocument) -> Self { + let buffer = document.fields().first().map_or_else( + || SecretBytes::new(Vec::new()), + |field| SecretBytes::new(field.contents().expose().to_vec()), + ); + let cursor = buffer.expose().len(); + Self { + document, + focused: 0, + buffer, + cursor, + input_active: false, + revealed: false, + dirty: false, + } + } + + pub fn document(&self) -> &EntryDocument { + &self.document + } + + pub fn focused_index(&self) -> Option { + (!self.document.fields().is_empty()).then_some(self.focused) + } + + pub fn focused_field(&self) -> Option<&EntryField> { + self.document.fields().get(self.focused) + } + + pub fn focused_contents(&self, id: EntryFieldId) -> Option<&[u8]> { + self.focused_field() + .filter(|field| field.id() == id) + .map(|_| self.buffer.expose()) + } + + pub fn cursor(&self) -> usize { + self.cursor + } + + pub fn is_input_active(&self) -> bool { + self.input_active + } + + pub fn begin_input(&mut self) { + if self.focused_field().is_some() { + self.input_active = true; + } + } + + pub fn end_input(&mut self) { + self.input_active = false; + self.revealed = false; + } + + pub fn is_revealed(&self, id: EntryFieldId) -> bool { + self.revealed && self.focused_field().is_some_and(|field| field.id() == id) + } + + pub fn reveal_focused(&mut self) -> bool { + if self + .focused_field() + .is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive) + { + self.revealed = true; + true + } else { + false + } + } + + pub fn hide_revealed(&mut self) -> bool { + std::mem::take(&mut self.revealed) + } + + pub fn is_dirty(&self) -> bool { + self.dirty + } + + pub fn focus_next(&mut self) -> Result<(), DocumentError> { + self.flush()?; + self.hide_revealed(); + if !self.document.fields().is_empty() { + self.focused = (self.focused + 1) % self.document.fields().len(); + self.load_focused(); + } + Ok(()) + } + + pub fn focus_previous(&mut self) -> Result<(), DocumentError> { + self.flush()?; + self.hide_revealed(); + if !self.document.fields().is_empty() { + self.focused = self + .focused + .checked_sub(1) + .unwrap_or(self.document.fields().len() - 1); + self.load_focused(); + } + Ok(()) + } + + pub fn add_after_focused(&mut self) -> Result<(), DocumentError> { + self.flush()?; + let index = if self.document.fields().is_empty() { + 0 + } else { + self.focused + 1 + }; + self.document.add(index, EntryFieldDraft::blank())?; + self.focused = index; + self.dirty = true; + self.load_focused(); + self.input_active = true; + Ok(()) + } + + pub fn remove_focused(&mut self) -> Result<(), DocumentError> { + self.flush()?; + let Some(field) = self.focused_field() else { + return Ok(()); + }; + self.document.remove(field.id())?; + self.focused = self + .focused + .min(self.document.fields().len().saturating_sub(1)); + self.dirty = true; + self.load_focused(); + Ok(()) + } + + pub fn move_focused_up(&mut self) -> Result<(), DocumentError> { + self.flush()?; + if self.focused == 0 { + return Ok(()); + } + let id = self + .focused_field() + .expect("nonzero focus has a field") + .id(); + self.focused -= 1; + self.document.reorder(id, self.focused)?; + self.dirty = true; + self.load_focused(); + Ok(()) + } + + pub fn move_focused_down(&mut self) -> Result<(), DocumentError> { + self.flush()?; + if self.focused + 1 >= self.document.fields().len() { + return Ok(()); + } + let id = self.focused_field().expect("focus has a field").id(); + self.focused += 1; + self.document.reorder(id, self.focused)?; + self.dirty = true; + self.load_focused(); + Ok(()) + } + + pub fn generation_target(&mut self) -> Result, DocumentError> { + self.flush()?; + Ok(self.focused_field().and_then(|field| { + (field.metadata().sensitivity() == EntrySensitivity::Sensitive + && field.metadata().kind() != EntryFieldKind::OtpUri) + .then_some(field.id()) + })) + } + + pub fn apply_generated( + &mut self, + target: EntryFieldId, + password: SecretBytes, + ) -> Result<(), DocumentError> { + self.flush()?; + self.document + .replace_field_value(target, password.expose().to_vec())?; + self.dirty = true; + if self + .focused_field() + .is_some_and(|field| field.id() == target) + { + self.load_focused(); + } + self.revealed = false; + Ok(()) + } + + pub fn prepare_save(mut self) -> Result> { + match self.flush() { + Ok(()) => Ok(self), + Err(error) => Err(Box::new((self, error))), + } + } + + pub fn into_document(mut self) -> Result> { + match self.flush() { + Ok(()) => Ok(self.document), + Err(error) => Err(Box::new((self, error))), + } + } + + pub fn insert_character(&mut self, character: char) { + if !self.input_active || self.focused_field().is_none() || character.is_control() { + return; + } + let encoded = character.to_string(); + let mut replacement = Vec::with_capacity(self.buffer.expose().len() + encoded.len()); + replacement.extend_from_slice(&self.buffer.expose()[..self.cursor]); + replacement.extend_from_slice(encoded.as_bytes()); + replacement.extend_from_slice(&self.buffer.expose()[self.cursor..]); + self.cursor += encoded.len(); + self.replace_buffer(replacement); + } + + pub fn backspace(&mut self) { + if !self.input_active || self.cursor == 0 { + return; + } + let start = previous_character_boundary(self.buffer.expose(), self.cursor); + let mut replacement = + Vec::with_capacity(self.buffer.expose().len() - (self.cursor - start)); + replacement.extend_from_slice(&self.buffer.expose()[..start]); + replacement.extend_from_slice(&self.buffer.expose()[self.cursor..]); + self.cursor = start; + self.replace_buffer(replacement); + } + + pub fn delete(&mut self) { + if !self.input_active || self.cursor >= self.buffer.expose().len() { + return; + } + let end = next_character_boundary(self.buffer.expose(), self.cursor); + let mut replacement = Vec::with_capacity(self.buffer.expose().len() - (end - self.cursor)); + replacement.extend_from_slice(&self.buffer.expose()[..self.cursor]); + replacement.extend_from_slice(&self.buffer.expose()[end..]); + self.replace_buffer(replacement); + } + + pub fn move_cursor_left(&mut self) { + self.cursor = previous_character_boundary(self.buffer.expose(), self.cursor); + } + + pub fn move_cursor_right(&mut self) { + self.cursor = next_character_boundary(self.buffer.expose(), self.cursor); + } + + pub fn move_cursor_home(&mut self) { + self.cursor = 0; + } + + pub fn move_cursor_end(&mut self) { + self.cursor = self.buffer.expose().len(); + } + + pub fn split_line(&mut self) -> Result<(), DocumentError> { + if !self.input_active { + return Ok(()); + } + let Some(field) = self.focused_field() else { + return Ok(()); + }; + let id = field.id(); + let left = self.buffer.expose()[..self.cursor].to_vec(); + let right = self.buffer.expose()[self.cursor..].to_vec(); + self.document.update(id, EntryFieldDraft::line(left)?)?; + self.document + .add(self.focused + 1, EntryFieldDraft::line(right)?)?; + self.focused += 1; + self.dirty = true; + self.load_focused(); + Ok(()) + } + + fn flush(&mut self) -> Result<(), DocumentError> { + let Some(field) = self.focused_field() else { + return Ok(()); + }; + if field.contents().expose() == self.buffer.expose() { + return Ok(()); + } + let id = field.id(); + self.document + .update(id, EntryFieldDraft::line(self.buffer.expose().to_vec())?)?; + self.dirty = true; + Ok(()) + } + + fn load_focused(&mut self) { + self.buffer = self.focused_field().map_or_else( + || SecretBytes::new(Vec::new()), + |field| SecretBytes::new(field.contents().expose().to_vec()), + ); + self.cursor = self.buffer.expose().len(); + self.revealed = false; + if self.focused_field().is_none() { + self.input_active = false; + } + } + + fn replace_buffer(&mut self, replacement: Vec) { + self.buffer = SecretBytes::new(replacement); + let Some(id) = self.focused_field().map(EntryField::id) else { + return; + }; + let draft = EntryFieldDraft::line(self.buffer.expose().to_vec()) + .expect("the single-line editor never inserts line endings into its buffer"); + self.document + .update(id, draft) + .expect("the focused field identifier belongs to the document"); + self.dirty = true; + } +} + +impl fmt::Debug for EntryEditor { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EntryEditor") + .field("document", &self.document) + .field("focused", &self.focused) + .field("buffer", &"[REDACTED]") + .field("cursor", &self.cursor) + .field("input_active", &self.input_active) + .field("revealed", &self.revealed) + .field("dirty", &self.dirty) + .finish() + } +} + +fn previous_character_boundary(value: &[u8], cursor: usize) -> usize { + let mut boundary = cursor.saturating_sub(1); + while boundary > 0 && value[boundary].is_ascii_continuation() { + boundary -= 1; + } + boundary +} + +fn next_character_boundary(value: &[u8], cursor: usize) -> usize { + let mut boundary = cursor.saturating_add(1).min(value.len()); + while boundary < value.len() && value[boundary].is_ascii_continuation() { + boundary += 1; + } + boundary +} + +trait Utf8Continuation { + fn is_ascii_continuation(&self) -> bool; +} + +impl Utf8Continuation for u8 { + fn is_ascii_continuation(&self) -> bool { + self & 0b1100_0000 == 0b1000_0000 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::viewer::test_support::fixture_document; + + #[test] + fn unicode_input_focus_and_masking_are_predictable() { + let mut editor = EntryEditor::new(fixture_document("unicode/咖啡")); + assert!(editor.reveal_focused()); + editor.begin_input(); + editor.move_cursor_end(); + editor.insert_character('界'); + assert!(editor.is_revealed(editor.focused_field().expect("field").id())); + editor.focus_next().expect("focus next"); + assert!(!editor.revealed); + editor.focus_previous().expect("focus previous"); + assert!( + editor + .focused_field() + .expect("password") + .contents() + .expose() + .ends_with("界".as_bytes()) + ); + } + + #[test] + fn add_split_remove_and_reorder_keep_stable_focus() { + let mut editor = EntryEditor::new(fixture_document("email/personal")); + let initial = editor.document().fields().len(); + editor.add_after_focused().expect("add"); + assert_eq!(editor.focused_index(), Some(1)); + editor.insert_character('a'); + editor.insert_character('b'); + editor.move_cursor_left(); + editor.split_line().expect("split"); + assert_eq!(editor.document().fields().len(), initial + 2); + editor.move_focused_up().expect("move up"); + assert_eq!(editor.focused_index(), Some(1)); + editor.remove_focused().expect("remove"); + assert_eq!(editor.document().fields().len(), initial + 1); + } + + #[test] + fn generated_values_preserve_structured_names_and_empty_documents_are_editable() { + let mut editor = EntryEditor::new(fixture_document("email/personal")); + editor.focus_next().expect("username"); + assert_eq!(editor.generation_target().expect("target"), None); + editor.focus_previous().expect("password"); + let target = editor + .generation_target() + .expect("target") + .expect("sensitive target"); + editor + .apply_generated(target, SecretBytes::new(b"generated".to_vec())) + .expect("apply generated"); + assert_eq!( + editor.focused_field().expect("password").value(), + b"generated" + ); + + let mut empty = EntryEditor::new(fixture_document("new/empty-document")); + assert_eq!(empty.focused_index(), None); + empty.add_after_focused().expect("add first field"); + assert_eq!(empty.focused_index(), Some(0)); + + let mut otp = EntryEditor::new(fixture_document("otp/totp")); + otp.focus_next().expect("OTP URI"); + assert_eq!(otp.generation_target().expect("OTP target"), None); + } + + #[test] + fn duplicate_labels_and_arbitrary_reordering_round_trip_without_schema_assumptions() { + let mut document = fixture_document("new/duplicate-document"); + document + .add( + 0, + EntryFieldDraft::line(b"password".to_vec()).expect("password"), + ) + .expect("add password"); + document + .add( + 1, + EntryFieldDraft::field("custom", b"one".to_vec()).expect("field"), + ) + .expect("add first duplicate"); + document + .add( + 2, + EntryFieldDraft::field("custom", b"two".to_vec()).expect("field"), + ) + .expect("add second duplicate"); + let mut editor = EntryEditor::new(document); + editor.focus_next().expect("first duplicate"); + editor.focus_next().expect("second duplicate"); + editor.move_focused_up().expect("reorder duplicate"); + let serialized = editor.into_document().expect("valid editor").serialize(); + assert_eq!(serialized.expose(), b"password\ncustom: two\ncustom: one"); + } +} diff --git a/apps/tui/src/lib.rs b/apps/tui/src/lib.rs index 2aef2d8..6952454 100644 --- a/apps/tui/src/lib.rs +++ b/apps/tui/src/lib.rs @@ -5,6 +5,7 @@ pub mod action; pub mod app; +pub mod editor; pub mod runtime; pub mod sidebar; pub mod terminal; @@ -22,7 +23,7 @@ use ratatui::DefaultTerminal; use crate::{ action::resolve_key, - app::{App, AppEffect, AsyncPayload, StartupData}, + app::{App, AppEffect, AsyncPayload, EditorSaveFailure, EditorSaveFailureKind, StartupData}, runtime::{AsyncExecutor, AuthenticationCoordinator, AuthenticationEvent}, }; @@ -98,6 +99,14 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { { apply_authentication_event(&mut app, coordinator, &executor, event); } + if !key.modifiers.intersects( + crossterm::event::KeyModifiers::CONTROL + | crossterm::event::KeyModifiers::ALT + | crossterm::event::KeyModifiers::SUPER, + ) && app.handle_editor_input(key.code) + { + continue; + } if app.sidebar().is_editing_filter() { if handle_filter_key(&mut app, key.code) { submit_filter(&mut app, &executor); @@ -148,6 +157,27 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { }); } } + 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 => { if let Some(coordinator) = authentication.as_mut() && let Err(error) = coordinator.lock() @@ -240,6 +270,61 @@ fn load_document( .map_err(|error| error.to_string()) } +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())?; @@ -288,3 +373,70 @@ fn handle_filter_key(app: &mut App, code: crossterm::event::KeyCode) -> bool { _ => false, } } + +#[cfg(test)] +mod tests { + use std::{fs, path::Path}; + + use super::*; + use crate::{editor::EntryEditor, viewer::test_support::fixture_document_from}; + + #[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); + } + + 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"); + } + } + } +} diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index 10e5e0b..5f95b40 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -11,6 +11,7 @@ use ratatui::{ use crate::{ action::available_actions, app::{App, Mode, PaneFocus}, + editor::EntryEditor, viewer::EntryViewer, }; @@ -125,16 +126,24 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { .wrap(Wrap { trim: false }), panes[0], ); - let main = if app.mode() == Mode::Viewer { - app.viewer().map_or_else( + let main = match app.mode() { + Mode::Viewer => app.viewer().map_or_else( || Paragraph::new(main_text(app)), |viewer| { Paragraph::new(viewer_lines(viewer)) .scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0)) }, - ) - } else { - Paragraph::new(main_text(app)) + ), + Mode::Editor => app.editor().map_or_else( + || Paragraph::new(main_text(app)), + |editor| { + Paragraph::new(editor_lines(editor)).scroll(( + u16::try_from(editor.focused_index().unwrap_or_default()).unwrap_or(u16::MAX), + 0, + )) + }, + ), + _ => Paragraph::new(main_text(app)), }; frame.render_widget( main.block(pane_block( @@ -212,7 +221,10 @@ fn main_text(app: &App) -> String { || "Structured entry viewer".to_owned(), |path| format!("Opening {path}…"), ), - Mode::Editor => "Structured entry editor".to_owned(), + Mode::Editor => "Saving structured entry…".to_owned(), + Mode::Dialog if app.discard_confirmation() => { + "Discard all unsaved edits? Press y to discard, n or Esc to keep editing.".to_owned() + } Mode::Dialog => "Complete or cancel the active dialog.".to_owned(), Mode::Command => "Enter a command on the bottom line.".to_owned(), Mode::Help | Mode::Locked => String::new(), @@ -289,6 +301,78 @@ fn viewer_lines(viewer: &EntryViewer) -> Vec> { .collect() } +fn editor_lines(editor: &EntryEditor) -> Vec> { + let focused = editor.focused_index(); + if editor.document().fields().is_empty() { + return vec![Line::from( + "This entry is empty. Press a to add its first field.", + )]; + } + + editor + .document() + .fields() + .iter() + .enumerate() + .map(|(index, field)| { + let selected = focused == Some(index); + let contents = editor + .focused_contents(field.id()) + .unwrap_or_else(|| field.contents().expose()); + let label = field.metadata().name().map_or_else( + || format!("{:?}", field.metadata().kind()), + |name| format!("{:?} ({name})", field.metadata().kind()), + ); + let masked = field.metadata().sensitivity() + == ironstorage::document::EntrySensitivity::Sensitive + && !editor.is_revealed(field.id()); + let mut spans = vec![Span::styled( + format!("#{:02} {label}: ", index + 1), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )]; + if masked { + spans.push(Span::styled( + "••••••••", + Style::default().fg(Color::DarkGray), + )); + if selected && editor.is_input_active() { + spans.push(Span::styled( + " [hidden input]", + Style::default().fg(Color::Yellow), + )); + } + } else if let Ok(value) = std::str::from_utf8(contents) { + if selected && editor.is_input_active() && value.is_char_boundary(editor.cursor()) { + let (before, after) = value.split_at(editor.cursor()); + spans.push(Span::raw(before)); + spans.push(Span::styled("▏", Style::default().fg(Color::Yellow))); + spans.push(Span::raw(after)); + } else if value.is_empty() { + spans.push(Span::styled( + "(empty)", + Style::default().fg(Color::DarkGray), + )); + } else { + spans.push(Span::raw(value)); + } + } else { + spans.push(Span::styled( + "[non-UTF-8 field; editing will preserve bytes]", + Style::default().fg(Color::Yellow), + )); + } + let line = Line::from(spans); + if selected { + line.style(Style::default().bg(Color::Blue).fg(Color::White)) + } else { + line + } + }) + .collect() +} + fn mode_title(mode: Mode) -> &'static str { match mode { Mode::Browser => "Browser", @@ -330,6 +414,10 @@ fn prompt_line(app: &App) -> Paragraph<'static> { match app.mode() { Mode::Command => Paragraph::new(":").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") + .style(Style::default().fg(Color::Yellow)) + } _ if app.sidebar().is_editing_filter() => { Paragraph::new(format!("/{}", app.sidebar().filter_query())) .style(Style::default().fg(Color::Yellow)) @@ -400,7 +488,7 @@ mod tests { fn help_is_generated_from_the_action_registry() { let mut app = App::new(); assert!(app.transition(Transition::OpenHelp)); - let output = render(100, 20, &app); + let output = render(140, 35, &app); for spec in crate::action::ACTIONS { assert!(output.contains(spec.label)); assert!(output.contains(spec.command)); @@ -539,4 +627,53 @@ mod tests { assert_eq!(app.sidebar().selected(), selected.as_ref()); assert!(app.viewer().is_none()); } + + #[test] + fn editor_keeps_sensitive_input_masked_until_explicit_reveal() { + let mut app = App::new(); + app.open_test_document("email/personal", fixture_document("email/personal")); + app.dispatch(crate::action::Action::EditEntry); + app.dispatch(crate::action::Action::BeginInput); + app.handle_editor_input(crossterm::event::KeyCode::Char('x')); + + for width in [60, 100, 140] { + let hidden = render(width, 20, &app); + assert!(hidden.contains("hidden input")); + assert!(!hidden.contains("correct horse fixturex")); + } + + app.handle_editor_input(crossterm::event::KeyCode::Esc); + app.dispatch(crate::action::Action::Reveal); + let revealed = render(100, 20, &app); + assert!(revealed.contains("correct horse fixturex")); + assert!(!format!("{app:?}").contains("correct horse fixturex")); + } + + #[test] + fn dirty_editor_requires_explicit_discard_and_relock_discards_from_dialog() { + let mut app = App::new(); + app.open_test_document("email/personal", fixture_document("email/personal")); + app.dispatch(crate::action::Action::EditEntry); + app.dispatch(crate::action::Action::AddField); + app.dispatch(crate::action::Action::Cancel); + assert_eq!(app.mode(), Mode::Dialog); + assert!(app.discard_confirmation()); + assert!(render(100, 20, &app).contains("Discard all unsaved edits")); + + app.dispatch(crate::action::Action::KeepEditing); + assert_eq!(app.mode(), Mode::Editor); + app.dispatch(crate::action::Action::Cancel); + app.dispatch(crate::action::Action::ConfirmDiscard); + assert_eq!(app.mode(), Mode::Browser); + assert!(app.editor().is_none()); + + app.open_test_document("email/personal", fixture_document("email/personal")); + app.dispatch(crate::action::Action::EditEntry); + app.dispatch(crate::action::Action::AddField); + app.dispatch(crate::action::Action::Cancel); + app.forced_relock("test expiry"); + assert_eq!(app.mode(), Mode::Locked); + assert!(app.editor().is_none()); + assert!(app.status().contains("unsaved edits were discarded")); + } } diff --git a/apps/tui/src/viewer.rs b/apps/tui/src/viewer.rs index 338981c..d940f68 100644 --- a/apps/tui/src/viewer.rs +++ b/apps/tui/src/viewer.rs @@ -32,6 +32,10 @@ impl EntryViewer { &self.document } + pub fn into_document(self) -> EntryDocument { + self.document + } + pub fn focused_index(&self) -> Option { (!self.document.fields().is_empty()).then_some(self.focused) } @@ -120,7 +124,7 @@ impl fmt::Debug for EntryViewer { #[cfg(test)] pub(crate) mod test_support { - use std::path::PathBuf; + use std::path::{Path, PathBuf}; use ironstorage::{ crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, @@ -144,7 +148,13 @@ pub(crate) mod test_support { pub(crate) fn fixture_document(entry: &str) -> EntryDocument { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); - let repository = Repository::open(root.join("stores/basic")).expect("fixture repository"); + fixture_document_from(&root.join("stores/basic"), entry) + } + + pub(crate) fn fixture_document_from(store: &Path, entry: &str) -> EntryDocument { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../crates/storage/tests/fixtures/compatibility"); + let repository = Repository::open(store).expect("fixture repository"); let keys = KeyStore::load(root.join("keys")).expect("fixture keys"); EntryDocumentService::new(&repository, &keys) .open(entry, &mut FixtureSecrets) diff --git a/crates/storage/src/document.rs b/crates/storage/src/document.rs index 2585f45..b5a05d8 100644 --- a/crates/storage/src/document.rs +++ b/crates/storage/src/document.rs @@ -262,6 +262,33 @@ impl EntryDocument { .ok_or(DocumentError::UnknownField { id }) } + /// Replace only a structured field's value while preserving its storage- + /// supplied name and syntax. + pub fn replace_field_value( + &mut self, + id: EntryFieldId, + value: Vec, + ) -> Result<(), DocumentError> { + let field = self.field(id).ok_or(DocumentError::UnknownField { id })?; + let draft = match field.metadata().kind() { + EntryFieldKind::OtpUri => EntryFieldDraft::otp_uri(value)?, + EntryFieldKind::Password | EntryFieldKind::Note | EntryFieldKind::Blank => { + EntryFieldDraft::line(value)? + } + EntryFieldKind::Username + | EntryFieldKind::Email + | EntryFieldKind::Url + | EntryFieldKind::Field => EntryFieldDraft::field( + field + .metadata() + .name() + .ok_or(DocumentError::InvalidFieldName)?, + value, + )?, + }; + self.update(id, draft) + } + pub fn conflict_token(&self) -> DocumentConflictToken { self.conflict_token } @@ -424,6 +451,26 @@ impl<'a> EntryDocumentService<'a> { committer, )?) } + + /// Save without consuming the document, allowing an in-process editor to + /// present a conflict or encryption failure and retain the user's draft. + pub fn save_recoverable( + &self, + document: &EntryDocument, + signing: Option<&SigningPolicy>, + committer: &mut impl EntryCommitter, + ) -> Result { + let replacement = document.serialize(); + Ok( + VaultWriter::new(self.repository, self.keys).finish_edit_recoverable( + &document.session, + replacement, + "IronStorage structured editor", + signing, + committer, + )?, + ) + } } #[derive(Debug)] diff --git a/crates/storage/src/generate.rs b/crates/storage/src/generate.rs index 53f00df..5311b1e 100644 --- a/crates/storage/src/generate.rs +++ b/crates/storage/src/generate.rs @@ -46,6 +46,31 @@ impl GeneratorConfig { pub fn character_set(&self) -> &[char] { &self.character_set } + + /// Generate a zeroizing password for an in-process structured editor + /// without mutating a repository. + pub fn generate_secret( + &self, + length: Option, + no_symbols: bool, + ) -> Result { + self.generate_secret_with_rng(length, no_symbols, &mut OsRng) + } + + fn generate_secret_with_rng( + &self, + length: Option, + no_symbols: bool, + rng: &mut R, + ) -> Result { + let length = validate_length(length.unwrap_or(self.default_length).get())?; + let characters = if no_symbols { + &self.alphanumeric_set + } else { + &self.character_set + }; + generate_password(rng, length, characters) + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -126,14 +151,9 @@ impl<'a> PasswordGenerator<'a> { if request.force && request.in_place { return Err(GenerateError::IncompatibleFlags); } - let length = request.length.unwrap_or(self.config.default_length); - let length = validate_length(length.get())?; - let characters = if request.no_symbols { - &self.config.alphanumeric_set - } else { - &self.config.character_set - }; - let password = generate_password(rng, length, characters)?; + let password = + self.config + .generate_secret_with_rng(request.length, request.no_symbols, rng)?; let path = EntryPath::parse(&request.entry)?; let contents = if request.in_place { let ciphertext = self.repository.read_entry(&path)?; diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index a297cd6..f649a23 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -25,7 +25,7 @@ use crate::{ mutation::{TreeCommit, TreeCommitError, TreeCommitter}, recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter}, repository::{EncryptedEntry, Repository, SecretBytes}, - write::{EntryCommit, EntryCommitError, EntryCommitter}, + write::{EntryCommit, EntryCommitError, EntryCommitter, NoGitEntryCommitter}, }; const DEFAULT_BRANCH: &str = "main"; @@ -38,6 +38,11 @@ pub struct GitIdentity { } impl GitIdentity { + pub fn ironstorage() -> Self { + Self::new("IronStorage", "ironstorage@localhost") + .expect("the built-in Git identity is valid") + } + pub fn new(name: impl Into, email: impl Into) -> Result { let name = name.into(); let email = email.into(); @@ -257,6 +262,35 @@ pub struct GitRepository { identity: GitIdentity, } +/// Storage-owned selection of pass-compatible automatic Git commits. +pub enum AutomaticEntryCommitter { + Git(Box), + None(NoGitEntryCommitter), +} + +impl AutomaticEntryCommitter { + pub fn for_entry( + repository: &Repository, + entry: &str, + identity: GitIdentity, + ) -> Result { + match GitRepository::open_innermost(repository, Path::new(entry), identity) { + Ok(git) => Ok(Self::Git(Box::new(git))), + Err(GitError::NotRepository) => Ok(Self::None(NoGitEntryCommitter)), + Err(error) => Err(error), + } + } +} + +impl EntryCommitter for AutomaticEntryCommitter { + fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> { + match self { + Self::Git(git) => EntryCommitter::commit(git.as_mut(), change), + Self::None(committer) => committer.commit(change), + } + } +} + pub struct GitCredential { username: String, password: crate::repository::SecretBytes, diff --git a/crates/storage/src/write.rs b/crates/storage/src/write.rs index c366a89..bb973c0 100644 --- a/crates/storage/src/write.rs +++ b/crates/storage/src/write.rs @@ -337,6 +337,20 @@ impl<'a> VaultWriter<'a> { editor_name: &str, signing: Option<&SigningPolicy>, committer: &mut impl EntryCommitter, + ) -> Result { + self.finish_edit_recoverable(&session, replacement, editor_name, signing, committer) + } + + /// Finish an edit while retaining the session when validation, encryption, + /// conflict detection, or commit orchestration fails. + #[allow(clippy::too_many_arguments)] + pub fn finish_edit_recoverable( + &self, + session: &EditSession, + replacement: SecretBytes, + editor_name: &str, + signing: Option<&SigningPolicy>, + committer: &mut impl EntryCommitter, ) -> Result { if replacement.expose() == session.plaintext.expose() { return Err(WriteError::Unchanged); @@ -375,7 +389,7 @@ impl<'a> VaultWriter<'a> { return Err(WriteError::Commit(error)); } Ok(WriteOutcome { - path: session.path, + path: session.path.clone(), action: EntryAction::Edit, }) } diff --git a/crates/storage/tests/entry_documents.rs b/crates/storage/tests/entry_documents.rs index 4d363ac..46442fc 100644 --- a/crates/storage/tests/entry_documents.rs +++ b/crates/storage/tests/entry_documents.rs @@ -238,11 +238,12 @@ fn atomic_save_creates_updates_and_rejects_stale_documents() -> TestResult { let stale_password = stale.password().expect("password").id(); stale.update(stale_password, EntryFieldDraft::line(b"stale".to_vec())?)?; assert!(matches!( - service.save(stale, None, &mut committer), + service.save_recoverable(&stale, None, &mut committer), Err(DocumentError::Write( WriteError::ConcurrentModification { .. } )) )); + assert_eq!(stale.serialize().expose(), b"stale\nusername: alice"); assert_eq!( repository.read_entry(&EntryPath::parse("documents/new")?)?, winner @@ -253,9 +254,31 @@ fn atomic_save_creates_updates_and_rejects_stale_documents() -> TestResult { rollback.update(password, EntryFieldDraft::line(b"must roll back".to_vec())?)?; committer.fail = true; assert!(matches!( - service.save(rollback, None, &mut committer), + service.save_recoverable(&rollback, None, &mut committer), Err(DocumentError::Write(WriteError::Commit(_))) )); + assert!( + rollback + .serialize() + .expose() + .starts_with(b"must roll back\n") + ); + assert_eq!( + repository.read_entry(&EntryPath::parse("documents/new")?)?, + winner + ); + committer.fail = false; + std::fs::write( + store.path().join(".gpg-id"), + b"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\n", + )?; + let retained = rollback.serialize(); + assert!( + service + .save_recoverable(&rollback, None, &mut committer) + .is_err() + ); + assert_eq!(rollback.serialize().expose(), retained.expose()); assert_eq!( repository.read_entry(&EntryPath::parse("documents/new")?)?, winner diff --git a/crates/storage/tests/password_generation.rs b/crates/storage/tests/password_generation.rs index 3535042..9ce2f67 100644 --- a/crates/storage/tests/password_generation.rs +++ b/crates/storage/tests/password_generation.rs @@ -131,6 +131,21 @@ fn deterministic_generation_uses_only_requested_characters_and_length() -> TestR Ok(()) } +#[test] +fn in_process_generation_returns_a_zeroizing_unstored_secret() -> TestResult { + let config = GeneratorConfig::new(12, "abc123")?; + let generated = config.generate_secret(NonZeroUsize::new(48), false)?; + assert_eq!(generated.expose().len(), 48); + assert!( + generated + .expose() + .iter() + .all(|byte| b"abc123".contains(byte)) + ); + assert!(!format!("{generated:?}").contains(std::str::from_utf8(generated.expose())?)); + Ok(()) +} + #[test] fn default_no_symbols_and_presentation_actions_are_typed() -> TestResult { let fixture = FixtureSet::load()?;