Complete TUI coverage and security audit

This commit is contained in:
Hermes Agent
2026-08-10 11:39:45 +00:00
parent 71bbff934e
commit c2632656bc
10 changed files with 958 additions and 44 deletions

View File

@@ -24,6 +24,7 @@ use std::{
use crossterm::event::{self, Event, KeyEventKind};
use ratatui::DefaultTerminal;
use zeroize::Zeroize as _;
use crate::{
action::{KeyResolution, KeyResolver},
@@ -72,6 +73,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
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)))
@@ -111,7 +113,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
let size = terminal.size()?;
app.resize(size.width, size.height);
terminal.draw(|frame| ui::draw(frame, &app))?;
terminal.draw(|frame| ui::draw_with_color_capability(frame, &app, color_capability))?;
if !event::poll(TICK_INTERVAL)? {
app.tick();
if let Some((entry, field)) = app.begin_totp_refresh() {
@@ -231,11 +233,31 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
}
}
Event::Resize(width, height) => app.resize(width, height),
Event::FocusLost => {
if let Some(coordinator) = authentication.as_mut() {
let _ignored = coordinator.lock();
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,
event,
);
}
app.forced_relock("terminal ownership was lost");
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,
);
}
_ => {}
}
@@ -243,6 +265,22 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
Ok(())
}
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,
@@ -299,6 +337,15 @@ fn apply_app_effect(
);
}
}
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();
@@ -823,6 +870,41 @@ fn execute_otp_ui(
}
}
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())
@@ -919,6 +1001,18 @@ fn apply_authentication_event(
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);
@@ -967,6 +1061,7 @@ fn execute_workflow(
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) => {
@@ -1007,7 +1102,23 @@ fn execute_workflow(
PasswordGenerator::new(&repository, &keys, GeneratorConfig::pass_defaults())
.generate(&request, overwrite, None, provider, &mut committer)
.map_err(|error| error.to_string())?;
drop(outcome);
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}"),
@@ -1155,6 +1266,7 @@ fn execute_workflow(
entry,
document,
grep,
presentation,
status,
})
}
@@ -1270,7 +1382,7 @@ mod tests {
use ironstorage::{
command::{
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest,
InsertInput, InsertRequest, MoveRequest, RemoveRequest,
InsertInput, InsertRequest, MoveRequest, Presentation, RemoveRequest, ShowRequest,
},
crypto::{KeyInfo, SecretProvider, SecretProviderError},
repository::SecretBytes,
@@ -1287,6 +1399,149 @@ mod tests {
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();
let mut authentication = None;
let mut git_control = None;
let mut clipboard = ClipboardCancellations::default();
let (cancel, cancelled) = std::sync::mpsc::channel();
clipboard.register(cancel);
handle_terminal_ownership_lost(
&mut app,
&mut authentication,
&mut git_control,
&mut clipboard,
);
assert_eq!(app.mode(), crate::app::Mode::Locked);
assert!(app.status().contains("terminal ownership was lost"));
cancelled
.recv_timeout(std::time::Duration::from_secs(1))
.expect("clipboard cancellation");
}
#[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");