Implement TUI init insert and generate workflows
This commit is contained in:
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user