Implement TUI search and mutation workflows
This commit is contained in:
@@ -8,6 +8,7 @@ pub mod app;
|
||||
pub mod command;
|
||||
pub mod editor;
|
||||
pub mod runtime;
|
||||
pub mod search;
|
||||
pub mod sidebar;
|
||||
pub mod terminal;
|
||||
pub mod ui;
|
||||
@@ -263,7 +264,29 @@ fn apply_app_effect(
|
||||
app.report_status(format!("locked after secure-store cleanup failed: {error}"));
|
||||
}
|
||||
}
|
||||
AppEffect::RunCommand(_) | AppEffect::OpenWorkflow(_) | AppEffect::None => {}
|
||||
AppEffect::RunCommand(ironstorage::command::CommandRequest::Find(request)) => {
|
||||
if let Some(config) = app.config().cloned() {
|
||||
let query = request.terms.join(" ");
|
||||
let token = app.begin_latest_request();
|
||||
executor.submit(token, move || {
|
||||
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 results = ironstorage::read::VaultReader::new(&repository, &keys)
|
||||
.find(&request.terms)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(AsyncPayload::Found { query, results })
|
||||
});
|
||||
}
|
||||
}
|
||||
AppEffect::RunCommand(request) => {
|
||||
app.report_status(format!(
|
||||
"{} is not implemented by this terminal workflow yet",
|
||||
crate::command::operation_name(&request)
|
||||
));
|
||||
}
|
||||
AppEffect::OpenWorkflow(_) | AppEffect::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,7 +396,10 @@ fn execute_workflow(
|
||||
) -> Result<WorkflowSuccess, String> {
|
||||
use ironstorage::{
|
||||
generate::{GeneratorConfig, PasswordGenerator},
|
||||
git::{AutomaticEntryCommitter, AutomaticPolicyCommitter, GitIdentity},
|
||||
git::{
|
||||
AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitIdentity,
|
||||
},
|
||||
mutation::TreeMutator,
|
||||
recipient::RecipientPolicyManager,
|
||||
write::VaultWriter,
|
||||
};
|
||||
@@ -383,6 +409,9 @@ fn execute_workflow(
|
||||
let keys = ironstorage::crypto::KeyStore::load(config.key_material())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let identity = GitIdentity::ironstorage();
|
||||
let mut selection = None;
|
||||
let mut grep = None;
|
||||
let mut refresh_tree = true;
|
||||
let (entry, mut status) = match submission {
|
||||
WorkflowSubmission::Init(request) => {
|
||||
let directory = request.path.as_deref().unwrap_or_default();
|
||||
@@ -428,13 +457,81 @@ fn execute_workflow(
|
||||
format!("Generated password for {entry}"),
|
||||
)
|
||||
}
|
||||
WorkflowSubmission::Grep(request) => {
|
||||
let results = ironstorage::read::VaultReader::new(&repository, &keys)
|
||||
.grep(&request, provider)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let count = results.entries().len();
|
||||
grep = Some(results);
|
||||
refresh_tree = false;
|
||||
(
|
||||
None,
|
||||
if count == 0 {
|
||||
"Decrypted search completed with no matches".to_owned()
|
||||
} else {
|
||||
format!("Decrypted search found {count} matching entries")
|
||||
},
|
||||
)
|
||||
}
|
||||
WorkflowSubmission::Remove(request) => {
|
||||
let target = request.entry.clone();
|
||||
let mut committer = AutomaticTreeCommitter::for_source(&repository, &target, identity)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let outcome = TreeMutator::new(&repository, &keys)
|
||||
.remove(
|
||||
&request,
|
||||
ironstorage::write::OverwriteDecision::Allow,
|
||||
&mut committer,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
selection = outcome
|
||||
.selection()
|
||||
.map(|selection| (selection.display_path(), selection.is_directory()));
|
||||
(
|
||||
None,
|
||||
format!(
|
||||
"Removed {target} ({} encrypted entries)",
|
||||
outcome.entries().len()
|
||||
),
|
||||
)
|
||||
}
|
||||
WorkflowSubmission::Move { request, overwrite } => {
|
||||
let source = request.source.clone();
|
||||
let destination = request.destination.clone();
|
||||
let mut committer = AutomaticTreeCommitter::for_source(&repository, &source, identity)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let outcome = TreeMutator::new(&repository, &keys)
|
||||
.move_tree(&request, overwrite, None, provider, &mut committer)
|
||||
.map_err(|error| error.to_string())?;
|
||||
selection = outcome
|
||||
.selection()
|
||||
.map(|selection| (selection.display_path(), selection.is_directory()));
|
||||
(None, format!("Moved {source} to {destination}"))
|
||||
}
|
||||
WorkflowSubmission::Copy { request, overwrite } => {
|
||||
let source = request.source.clone();
|
||||
let destination = request.destination.clone();
|
||||
let mut committer = AutomaticTreeCommitter::for_source(&repository, &source, identity)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let outcome = TreeMutator::new(&repository, &keys)
|
||||
.copy(&request, overwrite, None, provider, &mut committer)
|
||||
.map_err(|error| error.to_string())?;
|
||||
selection = outcome
|
||||
.selection()
|
||||
.map(|selection| (selection.display_path(), selection.is_directory()));
|
||||
(None, format!("Copied {source} to {destination}"))
|
||||
}
|
||||
};
|
||||
let tree = ironstorage::read::VaultReader::new(&repository, &keys)
|
||||
.list(&ironstorage::repository::DirectoryPath::root())
|
||||
.map_err(|error| {
|
||||
status.push_str(&format!("; sidebar refresh failed: {error}"));
|
||||
let tree = refresh_tree
|
||||
.then(|| {
|
||||
ironstorage::read::VaultReader::new(&repository, &keys)
|
||||
.list(&ironstorage::repository::DirectoryPath::root())
|
||||
.map_err(|error| {
|
||||
status.push_str(&format!("; sidebar refresh failed: {error}"));
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.ok();
|
||||
.flatten();
|
||||
let document = entry
|
||||
.as_deref()
|
||||
.map(|entry| {
|
||||
@@ -451,8 +548,10 @@ fn execute_workflow(
|
||||
.flatten();
|
||||
Ok(WorkflowSuccess {
|
||||
tree,
|
||||
selection,
|
||||
entry,
|
||||
document,
|
||||
grep,
|
||||
status,
|
||||
})
|
||||
}
|
||||
@@ -567,7 +666,8 @@ mod tests {
|
||||
|
||||
use ironstorage::{
|
||||
command::{
|
||||
GenerateRequest, GeneratedPresentation, InitRequest, InsertInput, InsertRequest,
|
||||
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest,
|
||||
InsertInput, InsertRequest, MoveRequest, RemoveRequest,
|
||||
},
|
||||
crypto::{KeyInfo, SecretProvider, SecretProviderError},
|
||||
repository::SecretBytes,
|
||||
@@ -575,7 +675,7 @@ mod tests {
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::{editor::EntryEditor, viewer::test_support::fixture_document_from};
|
||||
use crate::{action::Action, editor::EntryEditor, viewer::test_support::fixture_document_from};
|
||||
|
||||
#[test]
|
||||
fn press_and_terminal_repeat_events_dispatch_but_release_does_not() {
|
||||
@@ -646,6 +746,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct UnavailableSecrets;
|
||||
|
||||
impl SecretProvider for UnavailableSecrets {
|
||||
fn secret_for(&mut self, _key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
|
||||
Err(SecretProviderError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_workflows_use_storage_rules_refresh_and_commit_in_isolation() {
|
||||
let temporary = tempfile::tempdir().expect("temporary workflow store");
|
||||
@@ -790,6 +898,259 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypted_search_handles_cancel_empty_failure_and_result_navigation() {
|
||||
let temporary = tempfile::tempdir().expect("temporary search 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 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 app = App::new();
|
||||
app.sidebar_mut()
|
||||
.replace_tree(&load_tree(&config).expect("tree"));
|
||||
let repository =
|
||||
ironstorage::repository::Repository::open(config.vault()).expect("repository");
|
||||
let keys =
|
||||
ironstorage::crypto::KeyStore::load(config.key_material()).expect("key material");
|
||||
let terms = vec!["personal".to_owned()];
|
||||
let find = ironstorage::read::VaultReader::new(&repository, &keys)
|
||||
.find(&terms)
|
||||
.expect("name find");
|
||||
let token = app.begin_request();
|
||||
assert_eq!(
|
||||
app.apply_result(crate::app::AsyncResult {
|
||||
token,
|
||||
payload: Ok(AsyncPayload::Found {
|
||||
query: "personal".to_owned(),
|
||||
results: find,
|
||||
}),
|
||||
}),
|
||||
crate::app::ResultDisposition::Applied
|
||||
);
|
||||
assert_eq!(app.sidebar().filter_query(), "personal");
|
||||
app.sidebar_mut().next_match();
|
||||
assert_eq!(
|
||||
app.sidebar().selected().map(|selected| selected.path()),
|
||||
Some("email/personal")
|
||||
);
|
||||
app.sidebar_mut().clear_filter_results();
|
||||
assert!(matches!(app.dispatch(Action::Grep), AppEffect::None));
|
||||
assert_eq!(app.mode(), crate::app::Mode::Dialog);
|
||||
app.handle_workflow_input(
|
||||
crossterm::event::KeyCode::Esc,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
);
|
||||
assert_eq!(app.mode(), crate::app::Mode::Browser);
|
||||
assert!(app.status().contains("cancelled"));
|
||||
|
||||
let request = |pattern: &str| GrepRequest {
|
||||
pattern: pattern.to_owned(),
|
||||
ignore_case: false,
|
||||
invert_match: false,
|
||||
line_number: true,
|
||||
fixed_strings: true,
|
||||
};
|
||||
let empty = execute_workflow(
|
||||
&config,
|
||||
WorkflowSubmission::Grep(request("not-present-anywhere")),
|
||||
&mut FixtureSecrets,
|
||||
)
|
||||
.expect("empty grep");
|
||||
assert!(
|
||||
empty
|
||||
.grep
|
||||
.as_ref()
|
||||
.is_some_and(|results| results.is_empty())
|
||||
);
|
||||
assert!(empty.status.contains("no matches"));
|
||||
|
||||
let failure = execute_workflow(
|
||||
&config,
|
||||
WorkflowSubmission::Grep(request("password")),
|
||||
&mut UnavailableSecrets,
|
||||
)
|
||||
.expect_err("decryption must need a secret");
|
||||
assert!(!failure.contains("fixture-alice-passphrase"));
|
||||
|
||||
let found = execute_workflow(
|
||||
&config,
|
||||
WorkflowSubmission::Grep(request("password")),
|
||||
&mut FixtureSecrets,
|
||||
)
|
||||
.expect("matching grep");
|
||||
let token = app.begin_request();
|
||||
assert_eq!(
|
||||
app.apply_result(crate::app::AsyncResult {
|
||||
token,
|
||||
payload: Ok(AsyncPayload::WorkflowFinished(Ok(Box::new(found)))),
|
||||
}),
|
||||
crate::app::ResultDisposition::Applied
|
||||
);
|
||||
let first = app
|
||||
.grep_view()
|
||||
.and_then(crate::search::GrepView::selected_entry)
|
||||
.expect("first result")
|
||||
.path()
|
||||
.to_string();
|
||||
app.dispatch(Action::Next);
|
||||
let second = app
|
||||
.grep_view()
|
||||
.and_then(crate::search::GrepView::selected_entry)
|
||||
.expect("second result")
|
||||
.path()
|
||||
.to_string();
|
||||
assert_ne!(first, second);
|
||||
assert!(matches!(
|
||||
app.dispatch(Action::Activate),
|
||||
AppEffect::AuthenticateEntry(ref entry) if entry == &second
|
||||
));
|
||||
assert_eq!(
|
||||
app.sidebar().selected().map(|selected| selected.path()),
|
||||
Some(second.as_str())
|
||||
);
|
||||
assert!(app.grep_view().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_tree_mutations_commit_refresh_and_preserve_failed_collisions() {
|
||||
let temporary = tempfile::tempdir().expect("temporary mutation 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();
|
||||
let git = ironstorage::git::GitRepository::init(&repository, identity.clone())
|
||||
.expect("initialize git");
|
||||
let initial_commits = git.log(None).expect("initial log").len();
|
||||
drop(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 copied = execute_workflow(
|
||||
&config,
|
||||
WorkflowSubmission::Copy {
|
||||
request: CopyRequest {
|
||||
source: "email/personal".to_owned(),
|
||||
destination: "team/copied".to_owned(),
|
||||
force: false,
|
||||
},
|
||||
overwrite: OverwriteDecision::Decline,
|
||||
},
|
||||
&mut secrets,
|
||||
)
|
||||
.expect("copy across recipient boundary");
|
||||
assert_eq!(copied.selection, Some(("team/copied".to_owned(), false)));
|
||||
assert!(copied.tree.is_some());
|
||||
assert_eq!(
|
||||
fixture_document_from(&store, "team/copied")
|
||||
.password()
|
||||
.expect("copied password")
|
||||
.value(),
|
||||
b"correct horse fixture"
|
||||
);
|
||||
|
||||
let collision_ciphertext =
|
||||
fs::read(store.join("team/service.gpg")).expect("collision ciphertext");
|
||||
let git =
|
||||
ironstorage::git::GitRepository::open(&repository, identity.clone()).expect("open git");
|
||||
let commits_before_collision = git.log(None).expect("log").len();
|
||||
drop(git);
|
||||
let collision = execute_workflow(
|
||||
&config,
|
||||
WorkflowSubmission::Copy {
|
||||
request: CopyRequest {
|
||||
source: "email/personal".to_owned(),
|
||||
destination: "team/service".to_owned(),
|
||||
force: false,
|
||||
},
|
||||
overwrite: OverwriteDecision::Decline,
|
||||
},
|
||||
&mut secrets,
|
||||
);
|
||||
assert!(collision.is_err());
|
||||
assert_eq!(
|
||||
fs::read(store.join("team/service.gpg")).expect("unchanged collision"),
|
||||
collision_ciphertext
|
||||
);
|
||||
let git =
|
||||
ironstorage::git::GitRepository::open(&repository, identity.clone()).expect("open git");
|
||||
assert_eq!(git.log(None).expect("log").len(), commits_before_collision);
|
||||
drop(git);
|
||||
|
||||
let moved = execute_workflow(
|
||||
&config,
|
||||
WorkflowSubmission::Move {
|
||||
request: MoveRequest {
|
||||
source: "team/copied".to_owned(),
|
||||
destination: "shared/".to_owned(),
|
||||
force: false,
|
||||
},
|
||||
overwrite: OverwriteDecision::Decline,
|
||||
},
|
||||
&mut secrets,
|
||||
)
|
||||
.expect("move using explicit destination directory semantics");
|
||||
assert_eq!(moved.selection, Some(("shared/copied".to_owned(), false)));
|
||||
assert!(store.join("shared/copied.gpg").is_file());
|
||||
assert!(!store.join("team/copied.gpg").exists());
|
||||
|
||||
let copied_directory = execute_workflow(
|
||||
&config,
|
||||
WorkflowSubmission::Copy {
|
||||
request: CopyRequest {
|
||||
source: "team".to_owned(),
|
||||
destination: "archive".to_owned(),
|
||||
force: false,
|
||||
},
|
||||
overwrite: OverwriteDecision::Decline,
|
||||
},
|
||||
&mut secrets,
|
||||
)
|
||||
.expect("copy folder");
|
||||
assert_eq!(
|
||||
copied_directory.selection,
|
||||
Some(("archive".to_owned(), true))
|
||||
);
|
||||
assert!(store.join("archive/service.gpg").is_file());
|
||||
execute_workflow(
|
||||
&config,
|
||||
WorkflowSubmission::Remove(RemoveRequest {
|
||||
entry: "archive".to_owned(),
|
||||
recursive: true,
|
||||
force: false,
|
||||
}),
|
||||
&mut secrets,
|
||||
)
|
||||
.expect("recursive remove");
|
||||
assert!(!store.join("archive").exists());
|
||||
|
||||
let git = ironstorage::git::GitRepository::open(&repository, identity).expect("open git");
|
||||
assert_eq!(git.log(None).expect("log").len(), initial_commits + 4);
|
||||
}
|
||||
|
||||
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