Implement TUI init insert and generate workflows

This commit is contained in:
Hermes Agent
2026-08-10 09:22:55 +00:00
parent 977eb236da
commit 6ea184cbc1
8 changed files with 1213 additions and 32 deletions

View File

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

View File

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

View File

@@ -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,27 +303,51 @@ fn apply_authentication_event(
event: AuthenticationEvent,
) {
match event {
AuthenticationEvent::Granted(entry) => {
if !app.authentication_granted(entry.clone()) {
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(),
);
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;
}
let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle()) else {
app.authentication_failed(
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_latest_request();
let token = app.begin_request();
executor.submit(token, move || {
load_document(&config, &entry, handle).map(|document| {
AsyncPayload::DocumentLoaded {
entry,
document: Box::new(document),
}
})
Ok(AsyncPayload::WorkflowFinished(
execute_workflow(&config, *submission, &mut handle).map(Box::new),
))
});
}
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"),
}
}
@@ -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") {

View File

@@ -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(),
})
}
}
}

View File

@@ -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
View 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"),
}
}
}