Files
IronStorage/apps/tui/src/lib.rs

2271 lines
88 KiB
Rust

#![forbid(unsafe_code)]
#![deny(clippy::disallowed_types)]
//! Presentation-only state, rendering, and event plumbing for the terminal UI.
pub mod action;
pub mod app;
pub mod command;
pub mod editor;
pub mod runtime;
pub mod search;
pub mod sidebar;
pub mod terminal;
pub mod ui;
pub mod viewer;
pub mod workflow;
use std::{
io,
path::PathBuf,
sync::mpsc::{self, Receiver, Sender},
time::{Duration, Instant},
};
use crossterm::event::{self, Event, KeyEventKind};
use ratatui::DefaultTerminal;
use zeroize::Zeroize as _;
use crate::{
action::{KeyResolution, KeyResolver},
app::{
App, AppEffect, AsyncPayload, EditorSaveFailure, EditorSaveFailureKind, StartupData,
WorkflowSuccess,
},
runtime::{
AsyncExecutor, AuthenticationCoordinator, AuthenticationEvent, AuthenticationTarget,
},
workflow::WorkflowSubmission,
};
const TICK_INTERVAL: Duration = Duration::from_millis(250);
struct ClipboardCancellation {
sender: Sender<()>,
finished: Receiver<Result<(), ironstorage::presentation::ClipboardError>>,
}
#[derive(Default)]
struct ClipboardCancellations(Option<ClipboardCancellation>);
impl ClipboardCancellations {
fn replace(
&mut self,
cancellation: Sender<()>,
finished: Receiver<Result<(), ironstorage::presentation::ClipboardError>>,
) -> Option<Receiver<Result<(), ironstorage::presentation::ClipboardError>>> {
let previous = self.0.take().map(|active| {
let _ignored = active.sender.send(());
active.finished
});
self.0 = Some(ClipboardCancellation {
sender: cancellation,
finished,
});
previous
}
fn cancel_all(&mut self) {
if let Some(cancellation) = self.0.take() {
let _ignored = cancellation.sender.send(());
}
}
}
impl Drop for ClipboardCancellations {
fn drop(&mut self) {
self.cancel_all();
}
}
/// Run the interactive event loop. Polling keeps input responsive while storage
/// work runs on the executor and periodic repaints are pending.
pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
let mut app = App::new();
let executor = AsyncExecutor::new();
let mut clipboard_cancellations = ClipboardCancellations::default();
let mut authentication = None;
let mut git_control = None;
let mut authentication_initialized = false;
let mut key_resolver = KeyResolver::default();
let color_capability = ui::ColorCapability::detect();
let startup = app.begin_latest_request();
executor.submit(startup, || {
load_startup().map(|startup| AsyncPayload::Startup(Box::new(startup)))
});
while !app.should_quit() {
for result in executor.drain() {
app.apply_result(result);
}
if let Some(effect) = app.take_clipboard_effect() {
apply_app_effect(
&mut app,
effect,
&mut authentication,
&executor,
&mut git_control,
&mut clipboard_cancellations,
);
}
if !app.git_pending() {
git_control = None;
}
if !authentication_initialized
&& let (Some(config), Some(key)) = (app.config(), app.default_key())
{
authentication_initialized = true;
match AuthenticationCoordinator::system(config.authentication_timeout(), key.clone()) {
Ok(coordinator) => authentication = Some(coordinator),
Err(error) => app.authentication_failed(error),
}
}
if let Some(coordinator) = authentication.as_mut()
&& let Some(event) = coordinator.completion()
{
apply_authentication_event(
&mut app,
coordinator,
&executor,
&mut git_control,
&mut clipboard_cancellations,
event,
);
}
let unix_seconds = current_unix_seconds().map_err(io::Error::other)?;
app.observe_time(unix_seconds);
schedule_totp_refresh(&mut app, authentication.as_ref(), &executor);
let size = terminal.size()?;
app.resize(size.width, size.height);
terminal.draw(|frame| ui::draw_with_color_capability(frame, &app, color_capability))?;
if !event::poll(TICK_INTERVAL)? {
app.tick();
if let Some(coordinator) = authentication.as_mut() {
if let Some(event) = coordinator.poll_lease() {
apply_authentication_event(
&mut app,
coordinator,
&executor,
&mut git_control,
&mut clipboard_cancellations,
event,
);
}
app.update_remaining_lease(coordinator.remaining_time());
}
continue;
}
match event::read()? {
Event::Key(key) if is_dispatchable_key_kind(key.kind) => {
if let Some(coordinator) = authentication.as_mut()
&& let Some(event) = coordinator.touch_user_activity()
{
apply_authentication_event(
&mut app,
coordinator,
&executor,
&mut git_control,
&mut clipboard_cancellations,
event,
);
}
if let Some(effect) = app.handle_command_input(key.code, key.modifiers) {
key_resolver.reset();
apply_app_effect(
&mut app,
effect,
&mut authentication,
&executor,
&mut git_control,
&mut clipboard_cancellations,
);
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 git_control,
&mut clipboard_cancellations,
);
continue;
}
if !key.modifiers.intersects(
crossterm::event::KeyModifiers::CONTROL
| crossterm::event::KeyModifiers::ALT
| crossterm::event::KeyModifiers::SUPER,
) && app.handle_editor_input(key.code)
{
key_resolver.reset();
continue;
}
if app.sidebar().is_editing_filter() {
key_resolver.reset();
if handle_filter_key(&mut app, key.code) {
submit_filter(&mut app, &executor);
}
} else {
match key_resolver.feed(app.mode(), key.code, key.modifiers) {
KeyResolution::Action(action) => {
let effect = app.dispatch(action);
apply_app_effect(
&mut app,
effect,
&mut authentication,
&executor,
&mut git_control,
&mut clipboard_cancellations,
);
}
KeyResolution::Pending => {
app.report_status("Key sequence pending; Esc cancels".to_owned());
}
KeyResolution::Unavailable => {
app.report_status("Key is unavailable in the current mode".to_owned());
}
}
}
}
Event::Resize(width, height) => app.resize(width, height),
Event::Paste(mut pasted) => {
if let Some(coordinator) = authentication.as_mut()
&& let Some(event) = coordinator.touch_user_activity()
{
apply_authentication_event(
&mut app,
coordinator,
&executor,
&mut git_control,
&mut clipboard_cancellations,
event,
);
}
let filtering = app.sidebar().is_editing_filter();
if app.handle_paste(&pasted) && filtering {
submit_filter(&mut app, &executor);
}
pasted.zeroize();
}
Event::FocusLost => {
handle_terminal_ownership_lost(
&mut app,
&mut authentication,
&mut git_control,
&mut clipboard_cancellations,
);
}
_ => {}
}
}
Ok(())
}
fn current_unix_seconds() -> Result<u64, std::time::SystemTimeError> {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
}
fn schedule_totp_refresh(
app: &mut App,
authentication: Option<&AuthenticationCoordinator>,
executor: &AsyncExecutor,
) {
let Some((entry, field)) = app.begin_totp_refresh() else {
return;
};
let (Some(handle), Some(config)) = (
authentication.and_then(AuthenticationCoordinator::handle),
app.config().cloned(),
) else {
app.authentication_failed("authentication lease expired".to_owned());
return;
};
let token = app.begin_request();
executor.submit(token, move || {
execute_otp_ui(
&config,
crate::app::OtpUiRequest {
request: ironstorage::command::OtpRequest::Code(
ironstorage::command::OtpCodeRequest {
entry,
clipboard: false,
},
),
confirmed_hotp: false,
field: Some(field),
},
handle,
)
});
}
fn handle_terminal_ownership_lost(
app: &mut App,
authentication: &mut Option<AuthenticationCoordinator>,
git_control: &mut Option<ironstorage::git::GitOperationControl>,
clipboard_cancellations: &mut ClipboardCancellations,
) {
clipboard_cancellations.cancel_all();
if let Some(control) = git_control.take() {
control.cancel();
}
if let Some(coordinator) = authentication.as_mut() {
let _ignored = coordinator.lock();
}
app.forced_relock("terminal ownership was lost");
}
fn apply_app_effect(
app: &mut App,
effect: AppEffect,
authentication: &mut Option<AuthenticationCoordinator>,
executor: &AsyncExecutor,
git_control: &mut Option<ironstorage::git::GitOperationControl>,
clipboard_cancellations: &mut ClipboardCancellations,
) {
match effect {
AppEffect::RefreshTree => {
if let Some(config) = app.config().cloned() {
let token = app.begin_latest_request();
executor.submit(token, move || {
load_tree(&config).map(AsyncPayload::Refreshed)
});
}
}
AppEffect::AuthenticateEntry(entry) => {
if let Some(coordinator) = authentication.as_mut() {
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::AuthenticateGit(request) => {
if let Some(coordinator) = authentication.as_mut() {
app.begin_git_operation(crate::command::operation_name(
&ironstorage::command::CommandRequest::Git(request.clone()),
));
coordinator.request_git(request);
} else {
app.authentication_failed(
"operating-system secure storage is unavailable".to_owned(),
);
}
}
AppEffect::AuthenticateOtp(request) => {
if let Some(coordinator) = authentication.as_mut() {
coordinator.request_otp(request);
} else {
app.authentication_failed(
"operating-system secure storage is unavailable".to_owned(),
);
}
}
AppEffect::AuthenticateShow(request) => {
if let Some(coordinator) = authentication.as_mut() {
coordinator.request_show(request);
} else {
app.authentication_failed(
"operating-system secure storage is unavailable".to_owned(),
);
}
}
AppEffect::CancelGit => {
if let Some(control) = git_control.as_ref() {
control.cancel();
}
}
AppEffect::ResolveGit(resolutions) => {
if let Some(config) = app.config().cloned() {
let token = app.begin_request();
let control =
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone());
executor.submit(token, move || {
execute_git_resolution(&config, &resolutions, &control)
});
}
}
AppEffect::CopyFocused {
presentation,
value,
} => {
let token = app.begin_request();
let Some(config) = app.config().cloned() else {
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(AsyncPayload::ClipboardFinished {
presentation,
result: Err(ironstorage::presentation::ClipboardError::Unavailable),
}),
});
return;
};
let (cancel, cancellation) = mpsc::channel();
let (finished, completion) = mpsc::channel();
let previous = clipboard_cancellations.replace(cancel, completion);
let started = executor.clipboard_started_reporter(token);
executor.submit(token, move || {
// Finish the old cleanup before taking the new clipboard snapshot,
// otherwise the new presentation could later restore an older secret.
let previous = previous.map_or(Ok(()), |previous| {
previous.recv().unwrap_or(Err(
ironstorage::presentation::ClipboardError::CleanupFailed,
))
});
let payload = match (previous, cancellation.try_recv()) {
(Err(error), _) => AsyncPayload::ClipboardFinished {
presentation,
result: Err(error),
},
(Ok(()), Err(mpsc::TryRecvError::Empty)) => run_clipboard_presentation(
ironstorage::presentation::NativeClipboardManager::system(
config.clipboard_timeout(),
),
presentation,
value,
started,
move |deadline| match cancellation
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
{
Err(mpsc::RecvTimeoutError::Timeout) => {
ironstorage::presentation::ClipboardWait::Elapsed
}
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => {
ironstorage::presentation::ClipboardWait::Cancelled
}
},
),
(Ok(()), Ok(())) | (Ok(()), Err(mpsc::TryRecvError::Disconnected)) => {
AsyncPayload::ClipboardFinished {
presentation,
result: Err(ironstorage::presentation::ClipboardError::Cancelled),
}
}
};
let cleanup = match &payload {
AsyncPayload::ClipboardFinished {
result: Ok(_) | Err(ironstorage::presentation::ClipboardError::Cancelled),
..
} => Ok(()),
AsyncPayload::ClipboardFinished {
result: Err(error), ..
} => Err(*error),
_ => unreachable!("clipboard worker returns a clipboard result"),
};
let _ignored = finished.send(cleanup);
Ok(payload)
});
}
AppEffect::GenerateField(target) => {
let token = app.begin_request();
executor.submit(token, move || {
ironstorage::generate::GeneratorConfig::pass_defaults()
.generate_secret(None, false)
.map(|password| AsyncPayload::GeneratedField { target, password })
.map_err(|error| error.to_string())
});
}
AppEffect::SaveDocument {
config,
entry,
editor,
} => {
let token = app.begin_request();
executor.submit(token, move || Ok(save_document(&config, entry, editor)));
}
AppEffect::ManualLock => {
clipboard_cancellations.cancel_all();
if let Some(coordinator) = authentication.as_mut()
&& let Err(error) = coordinator.lock()
{
app.forced_relock("manual lock requested");
app.report_status(format!("locked after secure-store cleanup failed: {error}"));
}
}
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(ironstorage::command::CommandRequest::Git(request)) => {
if git_requires_authentication(&request) {
if let Some(coordinator) = authentication.as_mut() {
app.begin_git_operation(crate::command::operation_name(
&ironstorage::command::CommandRequest::Git(request.clone()),
));
coordinator.request_git(request);
} else {
app.authentication_failed(
"operating-system secure storage is unavailable".to_owned(),
);
}
} else if let Some(config) = app.config().cloned() {
app.begin_git_operation(crate::command::operation_name(
&ironstorage::command::CommandRequest::Git(request.clone()),
));
let token = app.begin_request();
let control =
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone());
executor.submit(token, move || execute_git(&config, request, None, &control));
}
}
AppEffect::RunCommand(ironstorage::command::CommandRequest::Otp(
ironstorage::command::OtpRequest::Validate { uri },
)) => {
let token = app.begin_request();
executor.submit(token, move || {
ironstorage::otp::OtpService::validate(&uri)
.map(|()| AsyncPayload::OtpValidated)
.map_err(|error| error.to_string())
});
}
AppEffect::RunCommand(request) => {
app.report_status(format!(
"{} is not implemented by this terminal workflow yet",
crate::command::operation_name(&request)
));
}
AppEffect::None => {}
}
}
fn run_clipboard_presentation<B, S, W>(
clipboard: Result<
ironstorage::presentation::ClipboardManager<B>,
ironstorage::presentation::ClipboardError,
>,
presentation: crate::app::ClipboardPresentationId,
value: ironstorage::repository::SecretBytes,
started: S,
wait: W,
) -> AsyncPayload
where
B: ironstorage::presentation::ClipboardBackend,
S: FnOnce(crate::app::ClipboardPresentationId, Instant),
W: FnOnce(Instant) -> ironstorage::presentation::ClipboardWait,
{
let result = clipboard.and_then(|mut clipboard| {
clipboard.copy_with(&value, |duration| {
let deadline = Instant::now() + duration;
started(presentation, deadline);
wait(deadline)
})
});
AsyncPayload::ClipboardFinished {
presentation,
result,
}
}
fn is_dispatchable_key_kind(kind: KeyEventKind) -> bool {
matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat)
}
fn git_requires_authentication(request: &ironstorage::command::GitRequest) -> bool {
matches!(
request,
ironstorage::command::GitRequest::Diff { .. }
| ironstorage::command::GitRequest::Fetch { .. }
| ironstorage::command::GitRequest::Pull { .. }
| ironstorage::command::GitRequest::Push { .. }
| ironstorage::command::GitRequest::Sync { .. }
)
}
fn execute_git(
config: &ironstorage::config::Config,
request: ironstorage::command::GitRequest,
mut credentials: Option<ironstorage::authentication::NativeAuthenticationHandle>,
control: &ironstorage::git::GitOperationControl,
) -> Result<AsyncPayload, String> {
use ironstorage::{
command::{GitConfigRequest, GitRemoteRequest, GitRequest},
git::{EmbeddedFetchTransport, GitError, GitIdentity, GitRepository, ReqwestGitTransport},
repository::{Repository, SecretBytes},
};
control
.report(ironstorage::git::GitProgressPhase::Validating)
.map_err(|error| error.to_string())?;
let repository = Repository::open(config.vault()).map_err(|error| error.to_string())?;
let snapshot_remote = match &request {
GitRequest::Fetch { remote }
| GitRequest::Pull { remote, .. }
| GitRequest::Push { remote, .. }
| GitRequest::Sync { remote } => remote.clone(),
_ => None,
};
let mut git = match request {
GitRequest::Init => GitRepository::init(&repository, GitIdentity::ironstorage()),
_ => GitRepository::open(&repository, GitIdentity::ironstorage()),
}
.map_err(|error| error.to_string())?;
let mut changed_snapshot = false;
let mut details = None;
let operation = match request {
GitRequest::Init => "Initialized embedded Git repository".to_owned(),
GitRequest::Status => "Git status refreshed".to_owned(),
GitRequest::Log { maximum } => {
let log = git
.log(maximum.map(std::num::NonZeroUsize::get))
.map_err(|error| error.to_string())?;
details = Some(SecretBytes::new(
log.iter()
.map(|entry| {
format!(
"{} {}\n",
&entry.id()[..entry.id().len().min(12)],
entry.message()
)
})
.collect::<String>()
.into_bytes(),
));
format!("Git log: {} commit(s)", log.len())
}
GitRequest::Diff { paths } => {
let handle = credentials
.as_mut()
.ok_or_else(|| "Git diff requires authentication".to_owned())?;
let keys = ironstorage::crypto::KeyStore::load(config.key_material())
.map_err(|error| error.to_string())?;
details = Some(
git.render_diff(
&paths.into_iter().map(PathBuf::from).collect::<Vec<_>>(),
&keys,
handle,
)
.map_err(|error| error.to_string())?,
);
"Rendered helper-free Git diff".to_owned()
}
GitRequest::Add { paths } => {
git.stage(&paths.into_iter().map(PathBuf::from).collect::<Vec<_>>())
.map_err(|error| error.to_string())?;
"Staged Git paths".to_owned()
}
GitRequest::Commit { message } => {
let id = git.commit(&message).map_err(|error| error.to_string())?;
format!("Created commit {}", &id[..id.len().min(12)])
}
GitRequest::Remote(remote) => match remote {
GitRemoteRequest::List => {
details = Some(SecretBytes::new(git.remotes().join("\n").into_bytes()));
"Listed Git remotes".to_owned()
}
GitRemoteRequest::GetUrl { name } => {
details = Some(SecretBytes::new(
git.remote_url(&name)
.map_err(|error| error.to_string())?
.into_bytes(),
));
format!("Git remote {name}")
}
GitRemoteRequest::Add { name, url } => {
git.add_remote(&name, &url)
.map_err(|error| error.to_string())?;
format!("Added Git remote {name}")
}
GitRemoteRequest::SetUrl { name, url } => {
git.set_remote_url(&name, &url)
.map_err(|error| error.to_string())?;
format!("Updated Git remote {name}")
}
GitRemoteRequest::Remove { name } => {
git.remove_remote(&name)
.map_err(|error| error.to_string())?;
format!("Removed Git remote {name}")
}
},
GitRequest::Config(request) => match request {
GitConfigRequest::Get { key } => {
let value = git
.config_get(&key)
.map_err(|error| error.to_string())?
.unwrap_or_default();
details = Some(SecretBytes::new(value.into_bytes()));
format!("Git configuration {key}")
}
GitConfigRequest::Set { key, value } => {
git.config_set(&key, &value)
.map_err(|error| error.to_string())?;
format!("Updated Git configuration {key}")
}
},
GitRequest::Fetch { remote } => {
let configured = config
.git_remote(remote.as_deref())
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
let handle = credentials
.as_ref()
.ok_or_else(|| "Git fetch requires authentication".to_owned())?;
let outcome = git
.fetch_with_transport_controlled(
configured,
handle,
&EmbeddedFetchTransport,
control,
)
.map_err(|error| error.to_string())?;
format!(
"Fetched {}{}",
outcome.remote(),
if outcome.received_pack() {
" (received objects)"
} else {
" (up to date)"
}
)
}
GitRequest::Pull { remote, branch } => {
let configured = config
.git_remote(remote.as_deref())
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
let handle = credentials
.as_ref()
.ok_or_else(|| "Git pull requires authentication".to_owned())?;
match git.pull_with_transport_controlled(
configured,
branch.as_deref(),
handle,
&EmbeddedFetchTransport,
control,
) {
Ok(outcome) => {
changed_snapshot = true;
format!("Git pull completed: {outcome:?}")
}
Err(GitError::MergeConflicts { conflicts }) => {
let snapshot = git
.snapshot(Some(configured), 10)
.map_err(|error| error.to_string())?;
return Ok(AsyncPayload::GitFinished {
snapshot: Box::new(snapshot),
tree: None,
message: format!(
"Git pull requires resolution of {} conflict(s)",
conflicts.len()
),
conflicts,
details: None,
});
}
Err(error) => return Err(error.to_string()),
}
}
GitRequest::Push { remote, branch } => {
let configured = config
.git_remote(remote.as_deref())
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
let handle = credentials
.as_ref()
.ok_or_else(|| "Git push requires authentication".to_owned())?;
let outcome = git
.push_with_transport_controlled(
configured,
branch.as_deref(),
handle,
&ReqwestGitTransport,
control,
)
.map_err(|error| error.to_string())?;
format!(
"Pushed {} {}",
outcome.remote(),
&outcome.new_id()[..outcome.new_id().len().min(12)]
)
}
GitRequest::Sync { remote } => {
let configured = config
.git_remote(remote.as_deref())
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
let handle = credentials
.as_ref()
.ok_or_else(|| "Git synchronization requires authentication".to_owned())?;
let pull = match git.pull_with_transport_controlled(
configured,
None,
handle,
&EmbeddedFetchTransport,
control,
) {
Ok(outcome) => outcome,
Err(GitError::MergeConflicts { conflicts }) => {
let snapshot = git
.snapshot(Some(configured), 10)
.map_err(|error| error.to_string())?;
return Ok(AsyncPayload::GitFinished {
snapshot: Box::new(snapshot),
tree: None,
message: format!(
"Git synchronization requires resolution of {} conflict(s)",
conflicts.len()
),
conflicts,
details: None,
});
}
Err(error) => return Err(error.to_string()),
};
let push = git
.push_with_transport_controlled(
configured,
None,
handle,
&ReqwestGitTransport,
control,
)
.map_err(|error| error.to_string())?;
changed_snapshot = true;
format!(
"Git synchronization completed: {pull:?}; pushed {}",
&push.new_id()[..push.new_id().len().min(12)]
)
}
};
control
.report(ironstorage::git::GitProgressPhase::Refreshing)
.map_err(|error| error.to_string())?;
let configured = config.git_remote(snapshot_remote.as_deref());
let snapshot = git
.snapshot(configured, 10)
.map_err(|error| error.to_string())?;
let tree = if changed_snapshot {
Some(load_tree(config)?)
} else {
None
};
Ok(AsyncPayload::GitFinished {
snapshot: Box::new(snapshot),
tree,
message: operation,
conflicts: Vec::new(),
details,
})
}
fn execute_git_resolution(
config: &ironstorage::config::Config,
resolutions: &[ironstorage::git::GitConflictResolution],
control: &ironstorage::git::GitOperationControl,
) -> Result<AsyncPayload, String> {
use ironstorage::git::{GitIdentity, GitRepository};
control
.report(ironstorage::git::GitProgressPhase::Integrating)
.map_err(|error| error.to_string())?;
let configured = config
.git_remote(None)
.ok_or_else(|| "no HTTPS Git remote is configured".to_owned())?;
let repository = ironstorage::repository::Repository::open(config.vault())
.map_err(|error| error.to_string())?;
let git = GitRepository::open(&repository, GitIdentity::ironstorage())
.map_err(|error| error.to_string())?;
git.resolve_fetched(configured, None, resolutions)
.map_err(|error| error.to_string())?;
control
.report(ironstorage::git::GitProgressPhase::Refreshing)
.map_err(|error| error.to_string())?;
let snapshot = git
.snapshot(Some(configured), 10)
.map_err(|error| error.to_string())?;
let tree = load_tree(config)?;
Ok(AsyncPayload::GitFinished {
snapshot: Box::new(snapshot),
tree: Some(tree),
message: "Merge conflicts resolved and committed".to_owned(),
conflicts: Vec::new(),
details: None,
})
}
fn execute_otp_ui(
config: &ironstorage::config::Config,
request: crate::app::OtpUiRequest,
mut provider: ironstorage::authentication::NativeAuthenticationHandle,
) -> Result<AsyncPayload, String> {
use ironstorage::{
command::{OtpRequest, OtpUriPresentation},
otp::{OtpKind, OtpService},
presentation::QrMatrix,
};
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 service = OtpService::new(&repository, &keys);
let field = request.field;
match request.request {
OtpRequest::Code(code_request) => {
let uri = service
.uri(&code_request.entry, &mut provider)
.map_err(|error| error.to_string())?;
if uri.kind() == OtpKind::Hotp && !request.confirmed_hotp {
return Err("HOTP generation requires explicit confirmation".to_owned());
}
let unix_seconds = current_unix_seconds()
.map_err(|_| "the system clock is before the Unix epoch".to_owned())?;
let outcome = service
.code_automatic(&code_request.entry, unix_seconds, None, &mut provider)
.map_err(|error| error.to_string())?;
let validity = outcome.validity();
let code = ironstorage::repository::SecretBytes::new(outcome.code().expose().to_vec());
let tree = validity.counter().map(|_| load_tree(config)).transpose()?;
Ok(AsyncPayload::OtpCodeFinished {
entry: code_request.entry,
field,
code,
validity,
observed_at: unix_seconds,
clipboard: code_request.clipboard,
tree,
})
}
OtpRequest::Uri(uri_request) => {
let uri = service
.uri(&uri_request.entry, &mut provider)
.map_err(|error| error.to_string())?;
let payload =
ironstorage::repository::SecretBytes::new(uri.encoded().expose().to_vec());
let (presentation, qr) = match uri_request.presentation {
OtpUriPresentation::Terminal => (crate::app::OtpPresentationTarget::Terminal, None),
OtpUriPresentation::Clipboard => {
(crate::app::OtpPresentationTarget::Clipboard, None)
}
OtpUriPresentation::QrCode => (
crate::app::OtpPresentationTarget::Qr,
Some(QrMatrix::encode(&payload).map_err(|error| error.to_string())?),
),
};
Ok(AsyncPayload::OtpUriFinished {
entry: uri_request.entry,
presentation,
payload,
qr,
})
}
_ => Err("this OTP request is not a presentation operation".to_owned()),
}
}
fn execute_show_presentation(
config: &ironstorage::config::Config,
request: ironstorage::command::ShowRequest,
mut provider: impl ironstorage::crypto::SecretProvider,
) -> Result<AsyncPayload, String> {
use ironstorage::{
presentation::QrMatrix,
read::{PresentationChannel, ShowOutput, VaultReader},
};
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 ShowOutput::Present(secret) = VaultReader::new(&repository, &keys)
.execute_show(&request, &mut provider)
.map_err(|error| error.to_string())?
else {
return Err("the show request did not select a presentation channel".to_owned());
};
let status = format!("Presented {} line {}", secret.entry(), secret.line());
let presentation = match secret.channel() {
PresentationChannel::Clipboard => crate::app::SecretPresentation::Clipboard(
ironstorage::repository::SecretBytes::new(secret.contents().expose().to_vec()),
),
PresentationChannel::QrCode => crate::app::SecretPresentation::Qr {
title: "Entry QR".to_owned(),
matrix: QrMatrix::encode(secret.contents()).map_err(|error| error.to_string())?,
},
};
Ok(AsyncPayload::SecretPresented {
status,
presentation,
})
}
fn load_startup() -> Result<StartupData, String> {
let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?;
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 key_handle = keys
.resolve(config.default_key().as_str())
.map_err(|error| error.to_string())?;
let key = keys
.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,
keys: all_keys,
})
}
fn apply_authentication_event(
app: &mut App,
coordinator: &AuthenticationCoordinator,
executor: &AsyncExecutor,
git_control: &mut Option<ironstorage::git::GitOperationControl>,
clipboard_cancellations: &mut ClipboardCancellations,
event: AuthenticationEvent,
) {
match event {
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(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::Granted(AuthenticationTarget::Git(request)) => {
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_request();
let control =
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone());
executor.submit(token, move || {
execute_git(&config, request, Some(handle), &control)
});
}
AuthenticationEvent::Granted(AuthenticationTarget::Otp(request)) => {
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_request();
executor.submit(token, move || execute_otp_ui(&config, request, handle));
}
AuthenticationEvent::Granted(AuthenticationTarget::Show(request)) => {
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_request();
executor.submit(token, move || {
execute_show_presentation(&config, request, handle)
});
}
AuthenticationEvent::Failed { workflow, message } => {
if workflow {
app.workflow_authentication_failed(message);
} else {
app.authentication_failed(message);
}
}
AuthenticationEvent::Expired => {
handle_authentication_expiry(app, clipboard_cancellations);
}
}
}
fn handle_authentication_expiry(
app: &mut App,
clipboard_cancellations: &mut ClipboardCancellations,
) {
clipboard_cancellations.cancel_all();
app.forced_relock("authentication lease expired");
}
fn load_document(
config: &ironstorage::config::Config,
entry: &str,
mut handle: ironstorage::authentication::NativeAuthenticationHandle,
) -> Result<ironstorage::document::EntryDocument, String> {
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())?;
ironstorage::document::EntryDocumentService::new(&repository, &keys)
.open(entry, &mut handle)
.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, AutomaticTreeCommitter, GitIdentity,
},
mutation::TreeMutator,
otp::OtpService,
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 mut selection = None;
let mut grep = None;
let mut presentation = None;
let mut refresh_tree = true;
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())?;
presentation = match outcome.channel() {
ironstorage::generate::GeneratedChannel::Terminal => None,
ironstorage::generate::GeneratedChannel::Clipboard => {
Some(crate::app::SecretPresentation::Clipboard(
ironstorage::repository::SecretBytes::new(
outcome.password().expose().to_vec(),
),
))
}
ironstorage::generate::GeneratedChannel::QrCode => {
Some(crate::app::SecretPresentation::Qr {
title: "Generated password QR".to_owned(),
matrix: ironstorage::presentation::QrMatrix::encode(outcome.password())
.map_err(|error| error.to_string())?,
})
}
};
(
Some(entry.clone()),
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::Kdbx { request, password } => {
let outcome = ironstorage::kdbx::KdbxImporter::new(&repository, &keys)
.import(&request, password, provider, identity)
.map_err(|error| error.to_string())?;
(
None,
format!(
"KDBX import: {} added, {} updated, {} unchanged, {} skipped",
outcome.added(),
outcome.updated(),
outcome.unchanged(),
outcome.skipped()
),
)
}
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}"))
}
WorkflowSubmission::OtpInsert { request, input } => {
let service = OtpService::new(&repository, &keys);
let plan = service
.prepare_insert(&request, input)
.map_err(|error| error.to_string())?;
let path = plan.path().to_string();
let mut committer =
AutomaticEntryCommitter::for_entry(&repository, &path, GitIdentity::ironstorage())
.map_err(|error| error.to_string())?;
let outcome = service
.finish_insert(
plan,
ironstorage::write::OverwriteDecision::Allow,
ironstorage::write::OverwriteDecision::Allow,
None,
&mut committer,
)
.map_err(|error| error.to_string())?;
selection = Some((outcome.path().to_string(), false));
(None, format!("Inserted OTP URI at {}", outcome.path()))
}
WorkflowSubmission::OtpAppend { request, input } => {
let service = OtpService::new(&repository, &keys);
let session = service
.begin_append(&request, provider)
.map_err(|error| error.to_string())?;
let path = session.path().to_string();
let mut committer =
AutomaticEntryCommitter::for_entry(&repository, &path, GitIdentity::ironstorage())
.map_err(|error| error.to_string())?;
let outcome = service
.finish_append(
session,
input,
ironstorage::write::OverwriteDecision::Allow,
None,
&mut committer,
)
.map_err(|error| error.to_string())?;
selection = Some((outcome.path().to_string(), false));
(None, format!("Updated OTP URI at {}", outcome.path()))
}
WorkflowSubmission::OtpValidate { uri } => {
OtpService::validate_input(uri).map_err(|error| error.to_string())?;
refresh_tree = false;
(None, "OTP URI is valid".to_owned())
}
};
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()
})
.flatten();
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,
selection,
entry,
document,
grep,
presentation,
status,
})
}
fn save_document(
config: &ironstorage::config::Config,
entry: String,
editor: Box<editor::EntryEditor>,
) -> AsyncPayload {
let result = save_document_inner(config, &entry, &editor);
AsyncPayload::DocumentSaveFinished {
entry,
editor,
result,
}
}
fn save_document_inner(
config: &ironstorage::config::Config,
entry: &str,
editor: &editor::EntryEditor,
) -> Result<ironstorage::write::WriteOutcome, EditorSaveFailure> {
let repository =
ironstorage::repository::Repository::open(config.vault()).map_err(|error| {
EditorSaveFailure::new(EditorSaveFailureKind::Storage, error.to_string())
})?;
let keys = ironstorage::crypto::KeyStore::load(config.key_material()).map_err(|error| {
EditorSaveFailure::new(EditorSaveFailureKind::Storage, error.to_string())
})?;
let identity = ironstorage::git::GitIdentity::ironstorage();
let mut committer =
ironstorage::git::AutomaticEntryCommitter::for_entry(&repository, entry, identity)
.map_err(|error| {
EditorSaveFailure::new(EditorSaveFailureKind::Storage, error.to_string())
})?;
ironstorage::document::EntryDocumentService::new(&repository, &keys)
.save_recoverable(editor.document(), None, &mut committer)
.map_err(|error| {
let kind = if matches!(
&error,
ironstorage::document::DocumentError::Write(
ironstorage::write::WriteError::ConcurrentModification { .. }
)
) {
EditorSaveFailureKind::Conflict
} else if matches!(
&error,
ironstorage::document::DocumentError::Write(
ironstorage::write::WriteError::Unchanged
)
) {
EditorSaveFailureKind::Unchanged
} else {
EditorSaveFailureKind::Storage
};
EditorSaveFailure::new(kind, error.to_string())
})
}
fn load_tree(config: &ironstorage::config::Config) -> Result<ironstorage::read::TreeModel, String> {
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())?;
ironstorage::read::VaultReader::new(&repository, &keys)
.list(&ironstorage::repository::DirectoryPath::root())
.map_err(|error| error.to_string())
}
fn submit_filter(app: &mut App, executor: &AsyncExecutor) {
let query = app.sidebar().filter_query().to_owned();
if query.is_empty() {
app.sidebar_mut().reset_filter_results();
return;
}
let Some(config) = app.config().cloned() else {
return;
};
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(std::slice::from_ref(&query))
.map_err(|error| error.to_string())?;
Ok(AsyncPayload::Filtered { query, results })
});
}
fn handle_filter_key(app: &mut App, code: crossterm::event::KeyCode) -> bool {
use crossterm::event::KeyCode;
match code {
KeyCode::Esc => {
app.sidebar_mut().clear_filter_results();
false
}
KeyCode::Enter => {
app.sidebar_mut().finish_filter();
false
}
KeyCode::Backspace => app.sidebar_mut().pop_filter_character(),
KeyCode::Char(character) => app.sidebar_mut().push_filter_character(character),
_ => false,
}
}
#[cfg(test)]
mod tests {
use std::{fs, path::Path};
use ironstorage::{
command::{
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest,
InsertInput, InsertRequest, MoveRequest, Presentation, RemoveRequest, ShowRequest,
},
crypto::{KeyInfo, SecretProvider, SecretProviderError},
repository::SecretBytes,
write::{InsertContent, OverwriteDecision},
};
use super::*;
use crate::{
action::Action,
editor::EntryEditor,
viewer::test_support::{fixture_document, fixture_document_from},
};
#[test]
fn press_and_terminal_repeat_events_dispatch_but_release_does_not() {
assert!(is_dispatchable_key_kind(KeyEventKind::Press));
assert!(is_dispatchable_key_kind(KeyEventKind::Repeat));
assert!(!is_dispatchable_key_kind(KeyEventKind::Release));
}
#[test]
fn production_tui_sources_preserve_the_presentation_only_boundary() {
let sources = [
("action.rs", include_str!("action.rs")),
("app.rs", include_str!("app.rs")),
("command.rs", include_str!("command.rs")),
("editor.rs", include_str!("editor.rs")),
("lib.rs", include_str!("lib.rs")),
("runtime.rs", include_str!("runtime.rs")),
("search.rs", include_str!("search.rs")),
("sidebar.rs", include_str!("sidebar.rs")),
("terminal.rs", include_str!("terminal.rs")),
("ui.rs", include_str!("ui.rs")),
("viewer.rs", include_str!("viewer.rs")),
("workflow.rs", include_str!("workflow.rs")),
];
for (name, source) in sources {
let production = source.split("#[cfg(test)]").next().unwrap_or(source);
let forbidden_tokens = [
["std::", "process"].concat(),
["process", "::Command"].concat(),
["Command", "::new("].concat(),
["std::", "fs::"].concat(),
["fs::", "read("].concat(),
["fs::", "write("].concat(),
[".read", "_entry("].concat(),
[".write", "_entry("].concat(),
["OtpUri", "::parse"].concat(),
["qrcode", "::QrCode"].concat(),
["unsafe", " {"].concat(),
];
for forbidden in &forbidden_tokens {
assert!(
!production.contains(forbidden),
"{name} crosses the TUI architecture boundary with {forbidden}"
);
}
}
}
#[test]
fn terminal_ownership_loss_cancels_presentations_and_forces_relock() {
let mut app = App::new();
app.open_test_document("email/personal", fixture_document("email/personal"));
let presentation = match app.dispatch(Action::Copy) {
AppEffect::CopyFocused { presentation, .. } => presentation,
effect => panic!("unexpected effect: {effect:?}"),
};
let mut authentication = None;
let mut git_control = None;
let mut clipboard = ClipboardCancellations::default();
let (first_cancel, first_cancelled) = std::sync::mpsc::channel();
let (first_finished, first_completion) = std::sync::mpsc::channel();
assert!(clipboard.replace(first_cancel, first_completion).is_none());
let (cancel, cancelled) = std::sync::mpsc::channel();
let (_finished, completion) = std::sync::mpsc::channel();
let previous = clipboard
.replace(cancel, completion)
.expect("previous clipboard completion");
first_cancelled
.recv_timeout(std::time::Duration::from_secs(1))
.expect("replacement cancels the previous clipboard presentation");
first_finished.send(Ok(())).expect("finish first cleanup");
previous
.recv_timeout(std::time::Duration::from_secs(1))
.expect("replacement waits for previous cleanup")
.expect("previous cleanup succeeds");
handle_terminal_ownership_lost(
&mut app,
&mut authentication,
&mut git_control,
&mut clipboard,
);
assert_eq!(app.mode(), crate::app::Mode::Locked);
assert!(!app.clipboard_pending());
assert!(app.status().contains("terminal ownership was lost"));
cancelled
.recv_timeout(std::time::Duration::from_secs(1))
.expect("clipboard cancellation");
assert_eq!(presentation.0, 0);
}
#[test]
fn authentication_expiry_cancels_the_active_clipboard_presentation() {
let mut app = App::new();
app.open_test_document("email/personal", fixture_document("email/personal"));
assert!(matches!(
app.dispatch(Action::Copy),
AppEffect::CopyFocused { .. }
));
let mut clipboard = ClipboardCancellations::default();
let (cancel, cancelled) = std::sync::mpsc::channel();
let (_finished, completion) = std::sync::mpsc::channel();
assert!(clipboard.replace(cancel, completion).is_none());
handle_authentication_expiry(&mut app, &mut clipboard);
cancelled
.recv_timeout(Duration::from_secs(1))
.expect("clipboard cancellation on expiry");
assert_eq!(app.mode(), crate::app::Mode::Locked);
assert!(!app.clipboard_pending());
}
#[derive(Default)]
struct MemoryClipboard(Vec<u8>);
impl ironstorage::presentation::ClipboardBackend for MemoryClipboard {
fn read(
&mut self,
) -> Result<
ironstorage::presentation::ClipboardContent,
ironstorage::presentation::ClipboardError,
> {
if self.0.is_empty() {
Ok(ironstorage::presentation::ClipboardContent::EmptyOrNonText)
} else {
Ok(ironstorage::presentation::ClipboardContent::text(
self.0.clone(),
))
}
}
fn write(
&mut self,
value: &SecretBytes,
) -> Result<(), ironstorage::presentation::ClipboardError> {
self.0 = value.expose().to_vec();
Ok(())
}
fn clear(&mut self) -> Result<(), ironstorage::presentation::ClipboardError> {
self.0.clear();
Ok(())
}
}
#[test]
fn clipboard_worker_reports_the_deadline_used_by_its_cleanup_wait() {
let presentation = crate::app::ClipboardPresentationId(7);
let timeout = ironstorage::presentation::ClipboardTimeout::new(Duration::from_secs(3))
.expect("timeout");
let mut started = None;
let mut waited_until = None;
let payload = run_clipboard_presentation(
Ok(ironstorage::presentation::ClipboardManager::new(
MemoryClipboard(b"previous".to_vec()),
timeout,
)),
presentation,
SecretBytes::new(b"copied".to_vec()),
|reported, deadline| started = Some((reported, deadline)),
|deadline| {
waited_until = Some(deadline);
ironstorage::presentation::ClipboardWait::Elapsed
},
);
let (reported, deadline) = started.expect("clipboard start");
assert_eq!(reported, presentation);
assert_eq!(waited_until, Some(deadline));
assert!(matches!(
payload,
AsyncPayload::ClipboardFinished {
presentation: finished,
result: Ok(ironstorage::presentation::ClipboardDisposition::RestoredPrevious),
} if finished == presentation
));
}
#[test]
fn show_and_generate_presentation_requests_reach_clipboard_and_qr_outputs() {
let temporary = tempfile::tempdir().expect("temporary presentation 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 shown = execute_show_presentation(
&config,
ShowRequest {
entry: Some("email/personal".to_owned()),
presentation: Presentation::Clipboard {
line: std::num::NonZeroUsize::new(1).expect("nonzero"),
},
},
FixtureSecrets,
)
.expect("show clipboard");
assert!(matches!(
shown,
AsyncPayload::SecretPresented {
presentation: crate::app::SecretPresentation::Clipboard(secret),
..
} if secret.expose() == b"correct horse fixture"
));
let shown_qr = execute_show_presentation(
&config,
ShowRequest {
entry: Some("email/personal".to_owned()),
presentation: Presentation::QrCode {
line: std::num::NonZeroUsize::new(1).expect("nonzero"),
},
},
FixtureSecrets,
)
.expect("show QR");
assert!(matches!(
shown_qr,
AsyncPayload::SecretPresented {
presentation: crate::app::SecretPresentation::Qr { matrix, .. },
..
} if matrix.width() > 0
));
let generated = execute_workflow(
&config,
WorkflowSubmission::Generate {
request: GenerateRequest {
entry: "generated/tui-clipboard".to_owned(),
length: Some(std::num::NonZeroUsize::new(24).expect("nonzero")),
no_symbols: true,
force: false,
in_place: false,
presentation: GeneratedPresentation::Clipboard,
},
overwrite: OverwriteDecision::Decline,
},
&mut FixtureSecrets,
)
.expect("generate clipboard");
assert!(matches!(
generated.presentation,
Some(crate::app::SecretPresentation::Clipboard(secret))
if secret.expose().len() == 24
));
}
#[test]
fn editor_save_encrypts_and_automatically_commits_entirely_in_storage() {
let temporary = tempfile::tempdir().expect("temporary editor 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();
assert!(initial_commits >= 1);
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 editor = EntryEditor::new(fixture_document_from(&store, "email/personal"));
editor.begin_input();
editor.insert_character('x');
let outcome = save_document_inner(&config, "email/personal", &editor).expect("save");
assert_eq!(outcome.path().to_string(), "email/personal");
let reopened = fixture_document_from(&store, "email/personal");
assert!(
reopened
.password()
.expect("password")
.value()
.ends_with(b"x")
);
let git = ironstorage::git::GitRepository::open(&repository, identity).expect("reopen git");
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),
}
}
}
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");
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
);
}
#[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") {
let entry = entry.expect("directory entry");
let target = destination.join(entry.file_name());
if entry.file_type().expect("file type").is_dir() {
copy_directory(&entry.path(), &target);
} else {
fs::copy(entry.path(), target).expect("copy fixture file");
}
}
}
}