2271 lines
88 KiB
Rust
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");
|
|
}
|
|
}
|
|
}
|
|
}
|