Implement the in-process structured entry editor

Closes #21
This commit is contained in:
Hermes Agent
2026-08-10 07:22:55 +00:00
parent cf7a6216ac
commit b503de8b2e
14 changed files with 1547 additions and 37 deletions

1
Cargo.lock generated
View File

@@ -4045,6 +4045,7 @@ dependencies = [
"crossterm",
"ironstorage",
"ratatui",
"tempfile",
]
[[package]]

View File

@@ -14,3 +14,6 @@ path = "src/main.rs"
crossterm.workspace = true
ironstorage.workspace = true
ratatui.workspace = true
[dev-dependencies]
tempfile = "3"

View File

@@ -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<Action> {

View File

@@ -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<EntryDocument>,
},
ClipboardFinished(ClipboardDisposition),
GeneratedField {
target: EntryFieldId,
password: SecretBytes,
},
DocumentSaveFinished {
entry: String,
editor: Box<EntryEditor>,
result: Result<WriteOutcome, EditorSaveFailure>,
},
}
#[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<Config>,
entry: String,
editor: Box<EntryEditor>,
},
ManualLock,
}
@@ -107,6 +151,9 @@ pub struct App {
sidebar: Sidebar,
selected_entry: Option<String>,
viewer: Option<EntryViewer>,
editor: Option<EntryEditor>,
discard_confirmation: bool,
editor_generation_pending: Option<EntryFieldId>,
authentication_pending: Option<String>,
remaining_lease: Option<std::time::Duration>,
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,8 +444,14 @@ impl App {
self.transition(Transition::OpenCommand);
}
Action::Cancel => {
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);
return AppEffect::ManualLock;
@@ -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
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<Result<(), DocumentError>>) {
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"
);
}
}

482
apps/tui/src/editor.rs Normal file
View File

@@ -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<usize> {
(!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<Option<EntryFieldId>, 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<Self, Box<(Self, DocumentError)>> {
match self.flush() {
Ok(()) => Ok(self),
Err(error) => Err(Box::new((self, error))),
}
}
pub fn into_document(mut self) -> Result<EntryDocument, Box<(Self, DocumentError)>> {
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<u8>) {
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");
}
}

View File

@@ -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<editor::EntryEditor>,
) -> 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<ironstorage::write::WriteOutcome, EditorSaveFailure> {
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<ironstorage::read::TreeModel, String> {
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");
}
}
}
}

View File

@@ -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<Line<'_>> {
.collect()
}
fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
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"));
}
}

View File

@@ -32,6 +32,10 @@ impl EntryViewer {
&self.document
}
pub fn into_document(self) -> EntryDocument {
self.document
}
pub fn focused_index(&self) -> Option<usize> {
(!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)

View File

@@ -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<u8>,
) -> 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<WriteOutcome, DocumentError> {
let replacement = document.serialize();
Ok(
VaultWriter::new(self.repository, self.keys).finish_edit_recoverable(
&document.session,
replacement,
"IronStorage structured editor",
signing,
committer,
)?,
)
}
}
#[derive(Debug)]

View File

@@ -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<NonZeroUsize>,
no_symbols: bool,
) -> Result<SecretBytes, GenerateError> {
self.generate_secret_with_rng(length, no_symbols, &mut OsRng)
}
fn generate_secret_with_rng<R: RngCore + CryptoRng>(
&self,
length: Option<NonZeroUsize>,
no_symbols: bool,
rng: &mut R,
) -> Result<SecretBytes, GenerateError> {
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)?;

View File

@@ -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<String>, email: impl Into<String>) -> Result<Self, GitError> {
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<GitRepository>),
None(NoGitEntryCommitter),
}
impl AutomaticEntryCommitter {
pub fn for_entry(
repository: &Repository,
entry: &str,
identity: GitIdentity,
) -> Result<Self, GitError> {
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,

View File

@@ -337,6 +337,20 @@ impl<'a> VaultWriter<'a> {
editor_name: &str,
signing: Option<&SigningPolicy>,
committer: &mut impl EntryCommitter,
) -> Result<WriteOutcome, WriteError> {
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<WriteOutcome, WriteError> {
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,
})
}

View File

@@ -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

View File

@@ -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()?;