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

@@ -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") {