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",
|
||||
"ratatui",
|
||||
"tempfile",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -14,6 +14,7 @@ path = "src/main.rs"
|
||||
crossterm.workspace = true
|
||||
ironstorage.workspace = true
|
||||
ratatui.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::{
|
||||
editor::EntryEditor,
|
||||
sidebar::{Sidebar, SidebarIntent},
|
||||
viewer::EntryViewer,
|
||||
workflow::{WorkflowForm, WorkflowInput, WorkflowSubmission},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
@@ -64,6 +65,15 @@ pub struct StartupData {
|
||||
pub config: Config,
|
||||
pub tree: TreeModel,
|
||||
pub key: KeyInfo,
|
||||
pub keys: Vec<KeyInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WorkflowSuccess {
|
||||
pub tree: Option<TreeModel>,
|
||||
pub entry: Option<String>,
|
||||
pub document: Option<Box<EntryDocument>>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -88,6 +98,7 @@ pub enum AsyncPayload {
|
||||
editor: Box<EntryEditor>,
|
||||
result: Result<WriteOutcome, EditorSaveFailure>,
|
||||
},
|
||||
WorkflowFinished(Result<Box<WorkflowSuccess>, String>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -141,6 +152,7 @@ pub enum AppEffect {
|
||||
entry: String,
|
||||
editor: Box<EntryEditor>,
|
||||
},
|
||||
AuthenticateWorkflow(Box<WorkflowSubmission>),
|
||||
OpenWorkflow(WorkflowAction),
|
||||
RunCommand(CommandRequest),
|
||||
ManualLock,
|
||||
@@ -160,6 +172,7 @@ pub struct App {
|
||||
status: String,
|
||||
config: Option<Config>,
|
||||
default_key: Option<KeyInfo>,
|
||||
available_keys: Vec<KeyInfo>,
|
||||
sidebar: Sidebar,
|
||||
selected_entry: Option<String>,
|
||||
viewer: Option<EntryViewer>,
|
||||
@@ -172,6 +185,8 @@ pub struct App {
|
||||
command_open_target: Option<CommandOpenTarget>,
|
||||
editor_generation_pending: Option<EntryFieldId>,
|
||||
authentication_pending: Option<String>,
|
||||
workflow: Option<WorkflowForm>,
|
||||
workflow_pending: bool,
|
||||
remaining_lease: Option<std::time::Duration>,
|
||||
terminal_size: (u16, u16),
|
||||
ticks: u64,
|
||||
@@ -196,6 +211,7 @@ impl App {
|
||||
status: "Starting…".to_owned(),
|
||||
config: None,
|
||||
default_key: None,
|
||||
available_keys: Vec::new(),
|
||||
sidebar: Sidebar::default(),
|
||||
selected_entry: None,
|
||||
viewer: None,
|
||||
@@ -208,6 +224,8 @@ impl App {
|
||||
command_open_target: None,
|
||||
editor_generation_pending: None,
|
||||
authentication_pending: None,
|
||||
workflow: None,
|
||||
workflow_pending: false,
|
||||
remaining_lease: None,
|
||||
terminal_size: (0, 0),
|
||||
ticks: 0,
|
||||
@@ -291,7 +309,11 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn authentication_pending(&self) -> bool {
|
||||
self.authentication_pending.is_some()
|
||||
self.authentication_pending.is_some() || self.workflow_pending
|
||||
}
|
||||
|
||||
pub fn workflow(&self) -> Option<&WorkflowForm> {
|
||||
self.workflow.as_ref()
|
||||
}
|
||||
|
||||
pub fn remaining_lease(&self) -> Option<std::time::Duration> {
|
||||
@@ -354,6 +376,7 @@ impl App {
|
||||
self.status = format!("Vault: {}", startup.config.vault().display());
|
||||
self.sidebar.replace_tree(&startup.tree);
|
||||
self.default_key = Some(startup.key);
|
||||
self.available_keys = startup.keys;
|
||||
self.config = Some(startup.config);
|
||||
}
|
||||
Ok(AsyncPayload::Refreshed(tree)) => {
|
||||
@@ -459,6 +482,35 @@ impl App {
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(AsyncPayload::WorkflowFinished(result)) => {
|
||||
self.workflow_pending = false;
|
||||
match result {
|
||||
Ok(success) => {
|
||||
if let Some(tree) = success.tree.as_ref() {
|
||||
self.sidebar.replace_tree(tree);
|
||||
}
|
||||
self.workflow = None;
|
||||
self.suspended_mode = None;
|
||||
if let (Some(entry), Some(document)) = (success.entry, success.document) {
|
||||
self.selected_entry = Some(entry);
|
||||
self.viewer = Some(EntryViewer::new(*document));
|
||||
self.editor = None;
|
||||
self.mode = Mode::Viewer;
|
||||
self.focus = PaneFocus::Main;
|
||||
} else {
|
||||
self.selected_entry = None;
|
||||
self.viewer = None;
|
||||
self.editor = None;
|
||||
self.mode = Mode::Browser;
|
||||
self.focus = PaneFocus::Sidebar;
|
||||
}
|
||||
self.status = success.status;
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = format!("Workflow failed: {error}. Form retained.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = error;
|
||||
self.editor_generation_pending = None;
|
||||
@@ -510,6 +562,8 @@ impl App {
|
||||
self.keep_editing();
|
||||
} else if self.mode == Mode::Dialog && self.command_confirmation.is_some() {
|
||||
self.cancel_command_confirmation();
|
||||
} else if self.mode == Mode::Dialog && self.workflow.is_some() {
|
||||
self.cancel_workflow();
|
||||
} else {
|
||||
if self.mode == Mode::Command {
|
||||
self.command_line.clear();
|
||||
@@ -706,6 +760,15 @@ impl App {
|
||||
| Action::GitPull
|
||||
| Action::GitPush => {
|
||||
let workflow = action.workflow().expect("matched workflow action");
|
||||
if matches!(
|
||||
workflow,
|
||||
WorkflowAction::Initialize
|
||||
| WorkflowAction::InsertEntry
|
||||
| WorkflowAction::GenerateEntry
|
||||
) {
|
||||
self.open_workflow(workflow, None);
|
||||
return AppEffect::None;
|
||||
}
|
||||
self.status = format!("Selected {} workflow", workflow_label(workflow));
|
||||
return AppEffect::OpenWorkflow(workflow);
|
||||
}
|
||||
@@ -723,6 +786,42 @@ impl App {
|
||||
.is_some_and(EntryEditor::is_input_active)
|
||||
}
|
||||
|
||||
pub fn handle_workflow_input(
|
||||
&mut self,
|
||||
code: crossterm::event::KeyCode,
|
||||
modifiers: crossterm::event::KeyModifiers,
|
||||
) -> Option<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(
|
||||
&mut self,
|
||||
code: crossterm::event::KeyCode,
|
||||
@@ -893,6 +992,19 @@ impl App {
|
||||
self.status = message;
|
||||
AppEffect::None
|
||||
}
|
||||
CommandInvocation::Storage(request @ CommandRequest::Init(_))
|
||||
| CommandInvocation::Storage(request @ CommandRequest::Insert(_))
|
||||
| CommandInvocation::Storage(request @ CommandRequest::Generate(_)) => {
|
||||
self.transition(Transition::Dismiss);
|
||||
let workflow = match &request {
|
||||
CommandRequest::Init(_) => WorkflowAction::Initialize,
|
||||
CommandRequest::Insert(_) => WorkflowAction::InsertEntry,
|
||||
CommandRequest::Generate(_) => WorkflowAction::GenerateEntry,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
self.open_workflow(workflow, Some(request));
|
||||
AppEffect::None
|
||||
}
|
||||
CommandInvocation::Storage(request) => {
|
||||
let operation = operation_name(&request);
|
||||
self.transition(Transition::Dismiss);
|
||||
@@ -929,6 +1041,53 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_workflow(&mut self, workflow: WorkflowAction, request: Option<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> {
|
||||
if self.mode != Mode::Dialog {
|
||||
return None;
|
||||
@@ -1235,6 +1394,8 @@ impl App {
|
||||
self.command_help = None;
|
||||
self.command_open_target = None;
|
||||
self.editor_generation_pending = None;
|
||||
self.workflow = None;
|
||||
self.workflow_pending = false;
|
||||
self.status = "Locked".to_owned();
|
||||
} else if current == Mode::Locked {
|
||||
self.status = "Authentication required".to_owned();
|
||||
@@ -1498,11 +1659,10 @@ mod tests {
|
||||
#[test]
|
||||
fn direct_workflow_keys_dispatch_typed_ui_effects_without_domain_work() {
|
||||
let mut app = App::new();
|
||||
assert!(matches!(
|
||||
app.dispatch(Action::InsertEntry),
|
||||
AppEffect::OpenWorkflow(WorkflowAction::InsertEntry)
|
||||
));
|
||||
assert!(app.status().contains("entry insertion"));
|
||||
assert!(matches!(app.dispatch(Action::InsertEntry), AppEffect::None));
|
||||
assert_eq!(app.mode(), Mode::Dialog);
|
||||
assert!(app.workflow().is_some());
|
||||
app.dispatch(Action::Cancel);
|
||||
app.open_test_document("email/personal", fixture_document("email/personal"));
|
||||
assert!(matches!(
|
||||
app.dispatch(Action::RemoveEntry),
|
||||
@@ -1511,6 +1671,34 @@ mod tests {
|
||||
assert!(app.status().contains("entry removal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colon_write_commands_open_the_same_secure_forms_with_typed_options() {
|
||||
let mut app = App::new();
|
||||
assert!(matches!(
|
||||
enter_command(&mut app, "insert --multiline --force nested/note"),
|
||||
AppEffect::None
|
||||
));
|
||||
assert_eq!(app.mode(), Mode::Dialog);
|
||||
let rows = app.workflow().expect("insert form").rows().join("\n");
|
||||
assert!(rows.contains("nested/note"));
|
||||
assert!(rows.contains("multiline"));
|
||||
assert!(rows.contains("Overwrite: yes"));
|
||||
app.handle_workflow_input(
|
||||
crossterm::event::KeyCode::Esc,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
);
|
||||
assert_eq!(app.mode(), Mode::Browser);
|
||||
|
||||
assert!(matches!(
|
||||
enter_command(&mut app, "generate --no-symbols --in-place nested/note 31"),
|
||||
AppEffect::None
|
||||
));
|
||||
let rows = app.workflow().expect("generate form").rows().join("\n");
|
||||
assert!(rows.contains("Length: 31"));
|
||||
assert!(rows.contains("Symbols: no"));
|
||||
assert!(rows.contains("In place: yes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colon_show_and_edit_route_to_authenticated_tui_panes() {
|
||||
let mut app = App::new();
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod sidebar;
|
||||
pub mod terminal;
|
||||
pub mod ui;
|
||||
pub mod viewer;
|
||||
pub mod workflow;
|
||||
|
||||
use std::{
|
||||
io,
|
||||
@@ -24,8 +25,14 @@ use ratatui::DefaultTerminal;
|
||||
|
||||
use crate::{
|
||||
action::{KeyResolution, KeyResolver},
|
||||
app::{App, AppEffect, AsyncPayload, EditorSaveFailure, EditorSaveFailureKind, StartupData},
|
||||
runtime::{AsyncExecutor, AuthenticationCoordinator, AuthenticationEvent},
|
||||
app::{
|
||||
App, AppEffect, AsyncPayload, EditorSaveFailure, EditorSaveFailureKind, StartupData,
|
||||
WorkflowSuccess,
|
||||
},
|
||||
runtime::{
|
||||
AsyncExecutor, AuthenticationCoordinator, AuthenticationEvent, AuthenticationTarget,
|
||||
},
|
||||
workflow::WorkflowSubmission,
|
||||
};
|
||||
|
||||
const TICK_INTERVAL: Duration = Duration::from_millis(250);
|
||||
@@ -112,6 +119,17 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(effect) = app.handle_workflow_input(key.code, key.modifiers) {
|
||||
key_resolver.reset();
|
||||
apply_app_effect(
|
||||
&mut app,
|
||||
effect,
|
||||
&mut authentication,
|
||||
&executor,
|
||||
&mut clipboard_cancellations,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if !key.modifiers.intersects(
|
||||
crossterm::event::KeyModifiers::CONTROL
|
||||
| crossterm::event::KeyModifiers::ALT
|
||||
@@ -178,13 +196,22 @@ fn apply_app_effect(
|
||||
}
|
||||
AppEffect::AuthenticateEntry(entry) => {
|
||||
if let Some(coordinator) = authentication.as_mut() {
|
||||
coordinator.request(entry);
|
||||
coordinator.request_entry(entry);
|
||||
} else {
|
||||
app.authentication_failed(
|
||||
"operating-system secure storage is unavailable".to_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
AppEffect::AuthenticateWorkflow(submission) => {
|
||||
if let Some(coordinator) = authentication.as_mut() {
|
||||
coordinator.request_workflow(submission);
|
||||
} else {
|
||||
app.workflow_authentication_failed(
|
||||
"operating-system secure storage is unavailable".to_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
AppEffect::CopyFocused(value) => {
|
||||
if let Some(config) = app.config().cloned() {
|
||||
let (cancel, cancellation) = mpsc::channel();
|
||||
@@ -257,10 +284,16 @@ fn load_startup() -> Result<StartupData, String> {
|
||||
.infos()
|
||||
.find(|key| key.fingerprint() == key_handle.fingerprint())
|
||||
.ok_or_else(|| "the configured OpenPGP key is unavailable".to_owned())?;
|
||||
let all_keys = keys.infos().collect::<Vec<_>>();
|
||||
let tree = ironstorage::read::VaultReader::new(&repository, &keys)
|
||||
.list(&ironstorage::repository::DirectoryPath::root())
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(StartupData { config, tree, key })
|
||||
Ok(StartupData {
|
||||
config,
|
||||
tree,
|
||||
key,
|
||||
keys: all_keys,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_authentication_event(
|
||||
@@ -270,11 +303,10 @@ fn apply_authentication_event(
|
||||
event: AuthenticationEvent,
|
||||
) {
|
||||
match event {
|
||||
AuthenticationEvent::Granted(entry) => {
|
||||
if !app.authentication_granted(entry.clone()) {
|
||||
return;
|
||||
}
|
||||
let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle()) else {
|
||||
AuthenticationEvent::Granted(AuthenticationTarget::Entry(entry)) => {
|
||||
if app.authentication_granted(entry.clone()) {
|
||||
let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle())
|
||||
else {
|
||||
app.authentication_failed(
|
||||
"authentication completed without an active secure-store lease".to_owned(),
|
||||
);
|
||||
@@ -290,7 +322,32 @@ fn apply_authentication_event(
|
||||
})
|
||||
});
|
||||
}
|
||||
AuthenticationEvent::Failed(error) => app.authentication_failed(error),
|
||||
}
|
||||
AuthenticationEvent::Granted(AuthenticationTarget::Workflow(submission)) => {
|
||||
if !app.workflow_authentication_granted() {
|
||||
return;
|
||||
}
|
||||
let (Some(config), Some(mut handle)) = (app.config().cloned(), coordinator.handle())
|
||||
else {
|
||||
app.workflow_authentication_failed(
|
||||
"authentication completed without an active secure-store lease".to_owned(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let token = app.begin_request();
|
||||
executor.submit(token, move || {
|
||||
Ok(AsyncPayload::WorkflowFinished(
|
||||
execute_workflow(&config, *submission, &mut handle).map(Box::new),
|
||||
))
|
||||
});
|
||||
}
|
||||
AuthenticationEvent::Failed { workflow, message } => {
|
||||
if workflow {
|
||||
app.workflow_authentication_failed(message);
|
||||
} else {
|
||||
app.authentication_failed(message);
|
||||
}
|
||||
}
|
||||
AuthenticationEvent::Expired => app.forced_relock("authentication lease expired"),
|
||||
}
|
||||
}
|
||||
@@ -309,6 +366,97 @@ fn load_document(
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn execute_workflow(
|
||||
config: &ironstorage::config::Config,
|
||||
submission: WorkflowSubmission,
|
||||
provider: &mut impl ironstorage::crypto::SecretProvider,
|
||||
) -> Result<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(
|
||||
config: &ironstorage::config::Config,
|
||||
entry: String,
|
||||
@@ -417,6 +565,15 @@ fn handle_filter_key(app: &mut App, code: crossterm::event::KeyCode) -> bool {
|
||||
mod tests {
|
||||
use std::{fs, path::Path};
|
||||
|
||||
use ironstorage::{
|
||||
command::{
|
||||
GenerateRequest, GeneratedPresentation, InitRequest, InsertInput, InsertRequest,
|
||||
},
|
||||
crypto::{KeyInfo, SecretProvider, SecretProviderError},
|
||||
repository::SecretBytes,
|
||||
write::{InsertContent, OverwriteDecision},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::{editor::EntryEditor, viewer::test_support::fixture_document_from};
|
||||
|
||||
@@ -473,6 +630,166 @@ mod tests {
|
||||
assert_eq!(git.log(None).expect("saved log").len(), initial_commits + 1);
|
||||
}
|
||||
|
||||
struct FixtureSecrets;
|
||||
|
||||
impl SecretProvider for FixtureSecrets {
|
||||
fn secret_for(&mut self, key: &KeyInfo) -> Result<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) {
|
||||
fs::create_dir_all(destination).expect("create destination");
|
||||
for entry in fs::read_dir(source).expect("read source") {
|
||||
|
||||
@@ -16,6 +16,7 @@ use ironstorage::{
|
||||
};
|
||||
|
||||
use crate::app::{AsyncPayload, AsyncResult, RequestToken};
|
||||
use crate::workflow::WorkflowSubmission;
|
||||
|
||||
pub struct AsyncExecutor {
|
||||
sender: Sender<AsyncResult>,
|
||||
@@ -23,16 +24,22 @@ pub struct AsyncExecutor {
|
||||
tasks: Mutex<Vec<thread::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
#[derive(Debug)]
|
||||
pub enum AuthenticationEvent {
|
||||
Granted(String),
|
||||
Failed(String),
|
||||
Granted(AuthenticationTarget),
|
||||
Failed { workflow: bool, message: String },
|
||||
Expired,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AuthenticationTarget {
|
||||
Entry(String),
|
||||
Workflow(Box<WorkflowSubmission>),
|
||||
}
|
||||
|
||||
struct AuthenticationCompletion {
|
||||
generation: u64,
|
||||
entry: String,
|
||||
target: AuthenticationTarget,
|
||||
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);
|
||||
let generation = self.generation;
|
||||
let session = self.session.clone();
|
||||
@@ -76,7 +91,7 @@ impl AuthenticationCoordinator {
|
||||
.map_err(|error| error.to_string());
|
||||
let _ignored = sender.send(AuthenticationCompletion {
|
||||
generation,
|
||||
entry,
|
||||
target,
|
||||
result,
|
||||
});
|
||||
});
|
||||
@@ -91,11 +106,14 @@ impl AuthenticationCoordinator {
|
||||
match completion.result {
|
||||
Ok(handle) => {
|
||||
self.handle = Some(handle);
|
||||
Some(AuthenticationEvent::Granted(completion.entry))
|
||||
Some(AuthenticationEvent::Granted(completion.target))
|
||||
}
|
||||
Err(error) => {
|
||||
self.handle = None;
|
||||
Some(AuthenticationEvent::Failed(error))
|
||||
Some(AuthenticationEvent::Failed {
|
||||
workflow: matches!(completion.target, AuthenticationTarget::Workflow(_)),
|
||||
message: error,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,7 +122,10 @@ impl AuthenticationCoordinator {
|
||||
let handle = self.handle.as_ref()?;
|
||||
if let Err(error) = handle.touch_user_activity() {
|
||||
self.handle = None;
|
||||
return Some(AuthenticationEvent::Failed(error.to_string()));
|
||||
return Some(AuthenticationEvent::Failed {
|
||||
workflow: false,
|
||||
message: error.to_string(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -118,7 +139,10 @@ impl AuthenticationCoordinator {
|
||||
Ok(false) => None,
|
||||
Err(error) => {
|
||||
self.handle = None;
|
||||
Some(AuthenticationEvent::Failed(error.to_string()))
|
||||
Some(AuthenticationEvent::Failed {
|
||||
workflow: false,
|
||||
message: error.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,18 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
return;
|
||||
}
|
||||
|
||||
if app.mode() == Mode::Dialog
|
||||
&& let Some(workflow) = app.workflow()
|
||||
{
|
||||
frame.render_widget(
|
||||
Paragraph::new(workflow.rows().join("\n"))
|
||||
.block(Block::bordered().title(workflow.title()))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let class = layout_class(frame.area());
|
||||
let (direction, constraints) = match class {
|
||||
LayoutClass::Narrow => (
|
||||
@@ -422,6 +434,9 @@ fn status_line(app: &App) -> Paragraph<'_> {
|
||||
}
|
||||
|
||||
fn context_line(app: &App) -> Paragraph<'static> {
|
||||
if app.mode() == Mode::Dialog && app.workflow().is_some() {
|
||||
return Paragraph::new("Tab/Shift-Tab focus Space toggle Ctrl-S submit Esc cancel");
|
||||
}
|
||||
let text = context_actions(app.mode())
|
||||
.map(|spec| format!("{} {}", spec.bindings[0].display, spec.label))
|
||||
.collect::<Vec<_>>()
|
||||
@@ -436,6 +451,9 @@ fn prompt_line(app: &App) -> Paragraph<'static> {
|
||||
Mode::Dialog if app.command_confirmation_message().is_some() => {
|
||||
Paragraph::new("confirm> y / n / Esc").style(Style::default().fg(Color::Yellow))
|
||||
}
|
||||
Mode::Dialog if app.workflow().is_some() => {
|
||||
Paragraph::new("form> ").style(Style::default().fg(Color::Yellow))
|
||||
}
|
||||
Mode::Dialog => Paragraph::new("dialog> ").style(Style::default().fg(Color::Yellow)),
|
||||
Mode::Editor if app.editor().is_some_and(EntryEditor::is_input_active) => {
|
||||
Paragraph::new("-- INSERT -- Esc stops input; Tab changes field; C-s saves")
|
||||
@@ -575,6 +593,38 @@ mod tests {
|
||||
assert!(confirmation.contains("confirm> y / n / Esc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_dialog_is_keyboard_discoverable_and_masks_inserted_secrets() {
|
||||
let mut app = App::new();
|
||||
app.dispatch(crate::action::Action::InsertEntry);
|
||||
for character in "nested/account".chars() {
|
||||
app.handle_workflow_input(
|
||||
crossterm::event::KeyCode::Char(character),
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
);
|
||||
}
|
||||
app.handle_workflow_input(
|
||||
crossterm::event::KeyCode::Tab,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
);
|
||||
app.handle_workflow_input(
|
||||
crossterm::event::KeyCode::Tab,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
);
|
||||
for character in "never-render-this".chars() {
|
||||
app.handle_workflow_input(
|
||||
crossterm::event::KeyCode::Char(character),
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
);
|
||||
}
|
||||
let output = render(100, 24, &app);
|
||||
assert!(output.contains("Insert entry"));
|
||||
assert!(output.contains("nested/account"));
|
||||
assert!(output.contains("Ctrl-S submit"));
|
||||
assert!(output.contains("••••••••"));
|
||||
assert!(!output.contains("never-render-this"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hierarchy_selection_and_storage_indicators_have_stable_rendering() {
|
||||
let mut app = App::new();
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
pub fn for_entry(
|
||||
repository: &Repository,
|
||||
|
||||
Reference in New Issue
Block a user