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