1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -4045,6 +4045,7 @@ dependencies = [
|
||||
"crossterm",
|
||||
"ironstorage",
|
||||
"ratatui",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -14,3 +14,6 @@ path = "src/main.rs"
|
||||
crossterm.workspace = true
|
||||
ironstorage.workspace = true
|
||||
ratatui.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,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<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
482
apps/tui/src/editor.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
Reference in New Issue
Block a user