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

@@ -78,6 +78,7 @@ pub struct WorkflowSuccess {
pub entry: Option<String>,
pub document: Option<Box<EntryDocument>>,
pub grep: Option<GrepResults>,
pub presentation: Option<SecretPresentation>,
pub status: String,
}
@@ -98,6 +99,28 @@ pub struct OtpDisplay {
counter: Option<u64>,
}
#[derive(Debug)]
pub struct QrPopup {
title: String,
matrix: QrMatrix,
}
impl QrPopup {
pub fn title(&self) -> &str {
&self.title
}
pub fn matrix(&self) -> &QrMatrix {
&self.matrix
}
}
#[derive(Debug)]
pub enum SecretPresentation {
Clipboard(SecretBytes),
Qr { title: String, matrix: QrMatrix },
}
impl OtpDisplay {
pub fn entry(&self) -> &str {
&self.entry
@@ -196,6 +219,10 @@ pub enum AsyncPayload {
qr: Option<QrMatrix>,
},
OtpValidated,
SecretPresented {
status: String,
presentation: SecretPresentation,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -254,6 +281,7 @@ pub enum AppEffect {
CancelGit,
ResolveGit(Vec<ironstorage::git::GitConflictResolution>),
AuthenticateOtp(OtpUiRequest),
AuthenticateShow(ironstorage::command::ShowRequest),
RunCommand(CommandRequest),
ManualLock,
}
@@ -289,7 +317,7 @@ pub struct App {
git_view: Option<GitView>,
git_pending: bool,
otp_display: Option<OtpDisplay>,
qr_popup: Option<QrMatrix>,
qr_popup: Option<QrPopup>,
uri_popup: Option<SecretBytes>,
otp_pending: bool,
hotp_confirmation: Option<OtpUiRequest>,
@@ -445,7 +473,7 @@ impl App {
.is_some_and(|entry| entry == display.entry())
})
}
pub fn qr_popup(&self) -> Option<&QrMatrix> {
pub fn qr_popup(&self) -> Option<&QrPopup> {
self.qr_popup.as_ref()
}
pub fn uri_popup(&self) -> Option<&SecretBytes> {
@@ -717,6 +745,9 @@ impl App {
if let Some((path, directory)) = success.selection {
self.sidebar.select_path(&path, directory);
}
if let Some(presentation) = success.presentation {
self.apply_secret_presentation(presentation);
}
self.status = success.status;
}
Err(error) => {
@@ -801,7 +832,10 @@ impl App {
}
OtpPresentationTarget::Qr => {
self.uri_popup = None;
self.qr_popup = qr;
self.qr_popup = qr.map(|matrix| QrPopup {
title: "OTP QR".to_owned(),
matrix,
});
}
}
}
@@ -809,6 +843,13 @@ impl App {
self.otp_pending = false;
self.status = "OTP URI is valid".to_owned();
}
Ok(AsyncPayload::SecretPresented {
status,
presentation,
}) => {
self.apply_secret_presentation(presentation);
self.status = status;
}
Err(error) => {
self.git_pending = false;
self.otp_pending = false;
@@ -1295,6 +1336,49 @@ impl App {
Some(AppEffect::None)
}
/// Insert bracketed paste as inert text into the active input surface.
/// Control characters are rejected so paste can never submit a command or
/// silently create additional password-store lines.
pub fn handle_paste(&mut self, text: &str) -> bool {
if text.chars().any(char::is_control) {
self.status = "Paste rejected: control characters are not accepted".to_owned();
return matches!(
self.mode,
Mode::Command | Mode::Editor | Mode::Dialog | Mode::Browser
);
}
if self.mode == Mode::Command {
for character in text.chars() {
self.command_line.insert(character);
}
return true;
}
if self.mode == Mode::Dialog
&& let Some(workflow) = self.workflow.as_mut()
{
for character in text.chars() {
workflow.handle_key(
crossterm::event::KeyCode::Char(character),
crossterm::event::KeyModifiers::NONE,
);
}
return true;
}
if self.mode == Mode::Editor {
for character in text.chars() {
self.handle_editor_input(crossterm::event::KeyCode::Char(character));
}
return true;
}
if self.sidebar.is_editing_filter() {
for character in text.chars() {
self.sidebar.push_filter_character(character);
}
return true;
}
false
}
fn submit_command(&mut self) -> AppEffect {
let invocation = match self.command_line.submit() {
Ok(invocation) => invocation,
@@ -1439,6 +1523,11 @@ impl App {
AppEffect::None
}
}
CommandInvocation::Storage(CommandRequest::Show(request)) => {
self.transition(Transition::Dismiss);
self.status = "Authenticating for secret presentation…".to_owned();
AppEffect::AuthenticateShow(request)
}
CommandInvocation::Storage(CommandRequest::Edit(request)) => {
if context == Mode::Editor {
if self.selected_entry.as_deref() == Some(&request.entry) {
@@ -1537,6 +1626,16 @@ impl App {
}
}
fn apply_secret_presentation(&mut self, presentation: SecretPresentation) {
match presentation {
SecretPresentation::Clipboard(value) => self.clipboard_request = Some(value),
SecretPresentation::Qr { title, matrix } => {
self.uri_popup = None;
self.qr_popup = Some(QrPopup { title, matrix });
}
}
}
fn open_workflow(&mut self, workflow: WorkflowAction, request: Option<CommandRequest>) {
let form = match (workflow, request) {
(WorkflowAction::Initialize, Some(CommandRequest::Init(request))) => {
@@ -2250,6 +2349,15 @@ mod tests {
));
assert_eq!(app.mode(), Mode::Browser);
let mut app = App::new();
assert!(matches!(
enter_command(&mut app, "show --clip=2 email/personal"),
AppEffect::AuthenticateShow(ironstorage::command::ShowRequest {
entry: Some(entry),
presentation: Presentation::Clipboard { .. },
}) if entry == "email/personal"
));
app.open_test_document("email/personal", fixture_document("email/personal"));
assert!(matches!(
enter_command(&mut app, "edit email/personal"),
@@ -2397,6 +2505,37 @@ mod tests {
assert!(!app.status().contains(secret));
}
#[test]
fn bracketed_paste_is_inert_unicode_text_and_rejects_submission_controls() {
let mut app = App::new();
app.dispatch(Action::Command);
assert!(app.handle_paste("show unicode/咖啡"));
assert_eq!(app.command_line().input(), "show unicode/咖啡");
assert!(app.handle_paste("\nremove everything"));
assert_eq!(app.command_line().input(), "show unicode/咖啡");
assert!(app.status().contains("control characters"));
app.dispatch(Action::Cancel);
app.dispatch(Action::Filter);
assert!(app.handle_paste("咖啡"));
assert_eq!(app.sidebar().filter_query(), "咖啡");
let mut app = App::new();
app.dispatch(Action::InsertEntry);
app.handle_workflow_input(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
);
app.handle_workflow_input(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
);
assert!(app.handle_paste("pasted-secret"));
let rows = app.workflow().expect("insert form").rows().join("\n");
assert!(rows.contains("••••••••"));
assert!(!rows.contains("pasted-secret"));
}
#[test]
fn hotp_requires_confirmation_and_uri_forms_remain_masked() {
let mut app = App::new();