From c2632656bc51f2cd2fee50211cbc15f72f2fc7bc Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 10 Aug 2026 11:39:45 +0000 Subject: [PATCH] Complete TUI coverage and security audit --- README.md | 2 + apps/tui/COMMANDS.md | 120 +++++++++++++---- apps/tui/src/app.rs | 145 +++++++++++++++++++- apps/tui/src/command.rs | 281 +++++++++++++++++++++++++++++++++++++++ apps/tui/src/lib.rs | 269 ++++++++++++++++++++++++++++++++++++- apps/tui/src/main.rs | 4 +- apps/tui/src/runtime.rs | 29 ++++ apps/tui/src/terminal.rs | 20 +++ apps/tui/src/ui.rs | 86 +++++++++++- apps/tui/src/workflow.rs | 46 ++++++- 10 files changed, 958 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 9da542c..573950d 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ documented in [`docs/otp.md`](docs/otp.md). The complete command matrix, shell completion interface, deliberate no-process differences, and executable security audit are documented in [`docs/cli-parity.md`](docs/cli-parity.md). +The Mutt-inspired terminal interaction model, default keys, and complete TUI +operation matrix are documented in [`apps/tui/COMMANDS.md`](apps/tui/COMMANDS.md). Lossless structured entry fields, semantic/sensitivity metadata, conflict tokens, and atomic frontend saves are documented in [`docs/entry-documents.md`](docs/entry-documents.md). diff --git a/apps/tui/COMMANDS.md b/apps/tui/COMMANDS.md index e085ef3..684d03b 100644 --- a/apps/tui/COMMANDS.md +++ b/apps/tui/COMMANDS.md @@ -1,32 +1,96 @@ -# TUI colon-command coverage +# IronStorage terminal UI -The `:` prompt uses `ironstorage::command::parse_from`, so flags, aliases, and -validation are shared with the milestone-01 command contract. Quoting is parsed -in-process; no shell or CLI executable is started. Workflow-specific screens -consume the typed requests emitted by command mode. +The Ratatui frontend uses a Mutt-inspired, keyboard-first interaction model. +The left pane is the password tree; the right pane is the current viewer, +editor, search result, Git dashboard, or form. Actions are mode-specific, so a +key never silently changes meaning inside an active editor or confirmation. +Press `?` for the current mode's generated help and `:` for the typed command +prompt. `Esc` closes the current overlay or entry. `Tab` and `Shift-Tab` move +between panes, fields, and form controls. -| Milestone-01 operation | TUI command | -| --- | --- | -| Initialize recipients | `:init GPG-ID…` | -| List/show | `:list [PATH]`, `:show [OPTIONS] [ENTRY]` | -| Find/grep | `:find TERM…`, `:grep [OPTIONS] PATTERN` | -| Insert/edit | `:insert [OPTIONS] ENTRY`, `:edit ENTRY` | -| Generate | `:generate [OPTIONS] ENTRY [LENGTH]` | -| Remove/move/copy | `:remove`, `:move`, `:copy` with their CLI arguments | -| Git init/status/log/diff/add/commit | `:git init`, `:git status`, `:git log`, `:git diff`, `:git add`, `:git commit` | -| Git remote/config | `:git remote …`, `:git config …` | -| Git fetch/pull/push/sync | `:git fetch`, `:git pull`, `:git push`, `:git sync` | -| OTP code | `:otp code [OPTIONS] ENTRY` | -| OTP insert/append | `:otp insert …`, `:otp append …` | -| OTP URI/validate | `:otp uri …`, `:otp validate URI` | -| OTP version | `:otp version` | -| Help/version | `:help [TOPIC]`, `:version` | -| TUI lease | `:lock`, `:unlock` | +The `:` prompt calls `ironstorage::command::parse_from`, so flags, aliases, and +validation are shared with the pass-compatible CLI. Quoting and completion are +in-process; no shell, CLI, `pass`, `git`, or `gpg` executable is started. +Secret-bearing commands are masked and omitted from history. -`:quit` is a TUI convenience. CLI shell-script generation is intentionally -unavailable inside an interactive TUI; Tab and Shift-Tab provide contextual -in-process completion instead. +## Default key reference -Remove requests always pass through a `y`/`n` confirmation before their typed -storage request is emitted. Command input containing OTP URIs, Git remote URLs, -or Git configuration values is omitted from history and masked while rendered. +| Context | Keys | Action | +| --- | --- | --- | +| Global | `?`, `:`, `C-l`/`C-z`, `q` | help, command prompt, secure lock, quit | +| Tree | `j`/`k`, arrows, `h`/`l`, `Enter`, `/`, `n`/`N` | move, collapse/expand, open, filter, cycle matches | +| Entry | `Tab`/`Shift-Tab`, `v`/`V`, `y`, `e`, `Esc` | focus, reveal/hide, timed copy, edit, close | +| Editor | `i`, `a`, `d`, `K`/`J`, `g`, `C-s` | edit/add/remove/reorder/generate/save fields | +| Store | `I`, `i`, `p`, `\\`, `d d`, `m`, `c` | init, insert, generate, grep, remove, move, copy | +| Git | `g p`, `g P` | pull, push; all other Git operations use `:git …` | +| OTP | `o c`, `o y`, `o u`, `o x`, `o q` | code, copy code, URI, copy URI, QR | +| OTP write | `o i`, `o a`, `o v` | insert, append, validate URI forms | +| Dialog | `y`, `n`, `Esc` | confirm, decline, cancel | +| Locked | `Enter`, `:unlock` | begin authentication | + +Bracketed paste is accepted only as inert text in an active command, filter, +editor, or form. A paste containing control characters is rejected and never +submits a command or creates extra entry lines. Losing terminal ownership +(including terminal suspension/focus loss) immediately revokes the lease, +cancels Git and clipboard work, removes plaintext presentation state, and +returns to the locked screen. `NO_COLOR` or `TERM=dumb` selects the monochrome +fallback while preserving focus through text and bold attributes. + +## Pass and pass-otp coverage matrix + +An em dash in the hotkey column means the command prompt is the intentional +keyboard UI for that less-frequent operation. The executable counterpart of +this table is `TUI_COVERAGE` in `src/command.rs`; tests require every base +command-contract operation to have an element, command, and registered hotkey +where one is listed. + +| Upstream operation | TUI element | Default hotkey | `:` command | +| --- | --- | --- | --- | +| init | recipient form | `I` | `:init GPG-ID…` | +| list | password tree | `Enter` | `:list [PATH]` | +| show | structured viewer | `Enter` | `:show [ENTRY]` | +| show clipboard | clipboard feedback | `y` | `:show --clip [LINE] ENTRY` | +| show QR | resizable QR popup | — | `:show --qrcode [LINE] ENTRY` | +| find | incremental tree filter | `/` | `:find TERM…` | +| grep | decrypted result pane | `\\` | `:grep [OPTIONS] PATTERN` | +| insert | masked insert form | `i` | `:insert [OPTIONS] ENTRY` | +| edit | structured editor | `e` | `:edit ENTRY` | +| generate | generation form | `p` | `:generate [OPTIONS] ENTRY [LENGTH]` | +| generate clipboard/QR | presentation selector | `p` | `:generate --clip/--qrcode ENTRY` | +| remove | confirmed removal form | `d d` | `:remove [OPTIONS] ENTRY` | +| move | move form | `m` | `:move [OPTIONS] SOURCE DESTINATION` | +| copy | copy form | `c` | `:copy [OPTIONS] SOURCE DESTINATION` | +| git init/status/log/diff/add/commit | Git dashboard/detail pane | — | `:git SUBCOMMAND …` | +| git remote/config | Git dashboard | — | `:git remote …`, `:git config …` | +| git fetch/sync | cancellable progress view | — | `:git fetch …`, `:git sync …` | +| git pull/push | cancellable progress view | `g p` / `g P` | `:git pull …`, `:git push …` | +| git conflict resolution | conflict view | — | `:git resolve-local`, `:git resolve-remote` | +| otp code | focused OTP field | `o c` | `:otp code ENTRY` | +| otp code clipboard | clipboard feedback | `o y` | `:otp code --clip ENTRY` | +| otp insert/append | masked OTP forms | `o i` / `o a` | `:otp insert …`, `:otp append …` | +| otp URI terminal/clipboard/QR | secret popup/clipboard/QR | `o u` / `o x` / `o q` | `:otp uri [--clip\|--qrcode] ENTRY` | +| otp validate | masked validation form | `o v` | `:otp validate URI` | +| otp version | status line | — | `:otp version` | +| help/version | help overlay/status line | `?` / — | `:help [TOPIC]`, `:version` | +| lock/unlock | locked screen | `C-l`/`C-z`, `Enter` | `:lock`, `:unlock` | + +Remove and HOTP operations always require explicit confirmation. TOTP repaint +ticks, storage completion, Git progress, and clipboard timers do not extend the +authentication lease. Clipboard and QR payloads are storage-produced, +zeroizing values and disappear immediately on relock. + +`:quit` is a TUI convenience. Shell-completion script generation is a CLI +build-time/integration surface; interactive Tab completion replaces it here. + +## Milestone audit evidence + +The TUI tests exercise narrow, normal, wide, and rejected terminal sizes; +Unicode-width content; masked paste; resize; focus-loss relock; panic +restoration; stale async results; and storage work blocked behind a deterministic +channel while UI state continues to update. The workspace +`authentication_leases` tests use a manual monotonic clock for both the default +120-second lease and configured timeouts; TUI state tests verify that the +resulting expiry event removes viewers, editor drafts, OTP/QR data, clipboard +feedback, and pending results. Source audit tests scan every production TUI +module for process construction, direct repository reads/writes, OTP/QR parser +duplication, filesystem access, and unsafe blocks. diff --git a/apps/tui/src/app.rs b/apps/tui/src/app.rs index 8706458..641ca10 100644 --- a/apps/tui/src/app.rs +++ b/apps/tui/src/app.rs @@ -78,6 +78,7 @@ pub struct WorkflowSuccess { pub entry: Option, pub document: Option>, pub grep: Option, + pub presentation: Option, pub status: String, } @@ -98,6 +99,28 @@ pub struct OtpDisplay { counter: Option, } +#[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, }, OtpValidated, + SecretPresented { + status: String, + presentation: SecretPresentation, + }, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -254,6 +281,7 @@ pub enum AppEffect { CancelGit, ResolveGit(Vec), AuthenticateOtp(OtpUiRequest), + AuthenticateShow(ironstorage::command::ShowRequest), RunCommand(CommandRequest), ManualLock, } @@ -289,7 +317,7 @@ pub struct App { git_view: Option, git_pending: bool, otp_display: Option, - qr_popup: Option, + qr_popup: Option, uri_popup: Option, otp_pending: bool, hotp_confirmation: Option, @@ -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) { 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(); diff --git a/apps/tui/src/command.rs b/apps/tui/src/command.rs index 0c5badf..ffe1159 100644 --- a/apps/tui/src/command.rs +++ b/apps/tui/src/command.rs @@ -4,6 +4,8 @@ use std::{error::Error, fmt}; use ironstorage::command::{CliAction, CommandRequest, GitRequest, OtpRequest, parse_from}; +use crate::action::{ACTIONS, Action}; + const HISTORY_LIMIT: usize = 100; const ROOT_COMMANDS: &[&str] = &[ @@ -81,6 +83,256 @@ pub const COMMAND_COVERAGE: &[CommandCoverage] = &[ coverage("unlock the TUI", ":unlock"), ]; +/// Executable feature matrix for the complete upstream pass and pass-otp +/// surface. `None` means the command prompt is the intentional keyboard UI; +/// otherwise the action registry supplies the default hotkey and help entry. +pub const TUI_COVERAGE: &[TuiCoverage] = &[ + tui( + "initialize recipients", + "recipient form", + Some(Action::Initialize), + ":init GPG-ID…", + ), + tui( + "list a directory", + "password tree", + Some(Action::Activate), + ":list [PATH]", + ), + tui( + "show an entry", + "structured viewer", + Some(Action::Activate), + ":show [ENTRY]", + ), + tui( + "copy a selected entry line", + "clipboard feedback", + Some(Action::Copy), + ":show --clip [LINE] ENTRY", + ), + tui( + "show a selected entry line as QR", + "resizable QR popup", + None, + ":show --qrcode [LINE] ENTRY", + ), + tui( + "find names", + "incremental tree filter", + Some(Action::Filter), + ":find TERM…", + ), + tui( + "grep decrypted entries", + "decrypted result pane", + Some(Action::Grep), + ":grep [OPTIONS] PATTERN", + ), + tui( + "insert an entry", + "masked insert form", + Some(Action::InsertEntry), + ":insert [OPTIONS] ENTRY", + ), + tui( + "edit an entry", + "structured editor", + Some(Action::EditEntry), + ":edit ENTRY", + ), + tui( + "generate a password", + "generation form", + Some(Action::GenerateEntry), + ":generate [OPTIONS] ENTRY [LENGTH]", + ), + tui( + "present a generated password", + "generation presentation selector", + Some(Action::GenerateEntry), + ":generate (--clip | --qrcode) ENTRY [LENGTH]", + ), + tui( + "remove entries or directories", + "confirmed removal form", + Some(Action::RemoveEntry), + ":remove [OPTIONS] ENTRY", + ), + tui( + "move entries or directories", + "move form", + Some(Action::MoveEntry), + ":move [OPTIONS] SOURCE DESTINATION", + ), + tui( + "copy entries or directories", + "copy form", + Some(Action::CopyEntry), + ":copy [OPTIONS] SOURCE DESTINATION", + ), + tui("initialize Git", "Git dashboard", None, ":git init"), + tui("show Git status", "Git dashboard", None, ":git status"), + tui("show Git log", "Git dashboard", None, ":git log [OPTIONS]"), + tui( + "show Git diff", + "zeroizing Git detail pane", + None, + ":git diff [PATH]…", + ), + tui("stage Git paths", "Git dashboard", None, ":git add PATH…"), + tui( + "create a Git commit", + "Git dashboard", + None, + ":git commit -m MESSAGE", + ), + tui( + "manage Git remotes", + "Git dashboard", + None, + ":git remote [COMMAND]", + ), + tui( + "manage Git configuration", + "Git dashboard", + None, + ":git config (--get KEY | KEY VALUE)", + ), + tui( + "fetch Git remote", + "Git progress view", + None, + ":git fetch [REMOTE]", + ), + tui( + "pull Git remote", + "Git progress view", + Some(Action::GitPull), + ":git pull [REMOTE] [BRANCH]", + ), + tui( + "push Git remote", + "Git progress view", + Some(Action::GitPush), + ":git push [REMOTE] [BRANCH]", + ), + tui( + "synchronize Git remote", + "Git progress view", + None, + ":git sync [REMOTE]", + ), + tui( + "resolve Git conflicts locally", + "Git conflict view", + None, + ":git resolve-local", + ), + tui( + "resolve Git conflicts remotely", + "Git conflict view", + None, + ":git resolve-remote", + ), + tui( + "generate an OTP code", + "focused OTP field", + Some(Action::OtpCode), + ":otp code [OPTIONS] ENTRY", + ), + tui( + "copy an OTP code", + "clipboard feedback", + Some(Action::OtpCopyCode), + ":otp code --clip ENTRY", + ), + tui( + "insert an OTP entry", + "masked OTP insert form", + Some(Action::OtpInsert), + ":otp insert [OPTIONS] [ENTRY]", + ), + tui( + "append OTP data", + "masked OTP append form", + Some(Action::OtpAppend), + ":otp append [OPTIONS] ENTRY", + ), + tui( + "present an OTP URI", + "secret URI popup", + Some(Action::OtpUri), + ":otp uri ENTRY", + ), + tui( + "copy an OTP URI", + "clipboard feedback", + Some(Action::OtpCopyUri), + ":otp uri --clip ENTRY", + ), + tui( + "present an OTP URI as QR", + "resizable QR popup", + Some(Action::OtpQr), + ":otp uri --qrcode ENTRY", + ), + tui( + "validate an OTP URI", + "masked validation form", + Some(Action::OtpValidate), + ":otp validate URI", + ), + tui("show pass-otp version", "status line", None, ":otp version"), + tui( + "show help", + "contextual help overlay", + Some(Action::Help), + ":help [TOPIC]", + ), + tui("show version", "status line", None, ":version"), + tui("lock the TUI", "locked screen", Some(Action::Lock), ":lock"), + tui( + "unlock the TUI", + "locked screen", + Some(Action::Unlock), + ":unlock", + ), +]; + +const fn tui( + operation: &'static str, + element: &'static str, + action: Option, + command: &'static str, +) -> TuiCoverage { + TuiCoverage { + operation, + element, + action, + command, + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TuiCoverage { + pub operation: &'static str, + pub element: &'static str, + pub action: Option, + pub command: &'static str, +} + +impl TuiCoverage { + pub fn default_hotkey(self) -> Option<&'static str> { + let action = self.action?; + ACTIONS + .iter() + .find(|spec| spec.action == action) + .and_then(|spec| spec.bindings.first()) + .map(|binding| binding.display) + } +} + const fn coverage(operation: &'static str, command: &'static str) -> CommandCoverage { CommandCoverage { operation, command } } @@ -852,6 +1104,35 @@ mod tests { .any(|row| row.command.starts_with(command)) ); } + + for command_row in COMMAND_COVERAGE { + assert!( + TUI_COVERAGE + .iter() + .any(|row| row.operation == command_row.operation), + "missing TUI coverage for {}", + command_row.operation + ); + } + for (index, row) in TUI_COVERAGE.iter().enumerate() { + assert!(!row.operation.is_empty()); + assert!(!row.element.is_empty()); + assert!(row.command.starts_with(':')); + assert!( + !TUI_COVERAGE[index + 1..] + .iter() + .any(|other| other.operation == row.operation), + "duplicate TUI operation {}", + row.operation + ); + if row.action.is_some() { + assert!( + row.default_hotkey().is_some(), + "{} has no registered default hotkey", + row.operation + ); + } + } } #[test] diff --git a/apps/tui/src/lib.rs b/apps/tui/src/lib.rs index 5f76177..616b46e 100644 --- a/apps/tui/src/lib.rs +++ b/apps/tui/src/lib.rs @@ -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, + git_control: &mut Option, + 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 { + 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 { 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"); diff --git a/apps/tui/src/main.rs b/apps/tui/src/main.rs index b6e3cc0..f0596b9 100644 --- a/apps/tui/src/main.rs +++ b/apps/tui/src/main.rs @@ -5,8 +5,8 @@ use std::io; fn main() -> io::Result<()> { ironstorage_tui::terminal::run( - ratatui::try_init, - ratatui::try_restore, + ironstorage_tui::terminal::initialize, + ironstorage_tui::terminal::restore, ironstorage_tui::run, ) } diff --git a/apps/tui/src/runtime.rs b/apps/tui/src/runtime.rs index 7414496..8e2cf79 100644 --- a/apps/tui/src/runtime.rs +++ b/apps/tui/src/runtime.rs @@ -37,6 +37,7 @@ pub enum AuthenticationTarget { Workflow(Box), Git(ironstorage::command::GitRequest), Otp(crate::app::OtpUiRequest), + Show(ironstorage::command::ShowRequest), } struct AuthenticationCompletion { @@ -89,6 +90,10 @@ impl AuthenticationCoordinator { self.request(AuthenticationTarget::Otp(request)); } + pub fn request_show(&mut self, request: ironstorage::command::ShowRequest) { + self.request(AuthenticationTarget::Show(request)); + } + fn request(&mut self, target: AuthenticationTarget) { self.generation = self.generation.wrapping_add(1); let generation = self.generation; @@ -259,4 +264,28 @@ mod tests { .expect("worker should return a typed result"); assert_eq!(app.apply_result(result), ResultDisposition::Applied); } + + #[test] + fn blocked_storage_work_never_blocks_interaction_state() { + let executor = AsyncExecutor::new(); + let mut app = App::new(); + let token = app.begin_request(); + let (release, blocked) = std::sync::mpsc::channel(); + executor.submit(token, move || { + blocked.recv().expect("release slow storage"); + Err("slow result".to_owned()) + }); + + app.resize(60, 12); + app.tick(); + assert!(app.transition(crate::app::Transition::OpenHelp)); + assert_eq!(app.mode(), crate::app::Mode::Help); + + release.send(()).expect("release worker"); + let result = executor + .receiver + .recv_timeout(Duration::from_secs(2)) + .expect("worker result"); + assert_eq!(app.apply_result(result), ResultDisposition::Applied); + } } diff --git a/apps/tui/src/terminal.rs b/apps/tui/src/terminal.rs index 0dd8f2f..2989d2d 100644 --- a/apps/tui/src/terminal.rs +++ b/apps/tui/src/terminal.rs @@ -5,6 +5,26 @@ use std::{ panic::{AssertUnwindSafe, catch_unwind, resume_unwind}, }; +use crossterm::event::{ + DisableBracketedPaste, DisableFocusChange, EnableBracketedPaste, EnableFocusChange, +}; +use ratatui::DefaultTerminal; + +pub fn initialize() -> io::Result { + let terminal = ratatui::try_init()?; + if let Err(error) = crossterm::execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange) { + let _ignored = ratatui::try_restore(); + return Err(error); + } + Ok(terminal) +} + +pub fn restore() -> io::Result<()> { + let event_modes = crossterm::execute!(io::stdout(), DisableFocusChange, DisableBracketedPaste); + let terminal = ratatui::try_restore(); + event_modes.and(terminal) +} + pub fn run(initialize: I, mut restore: C, operation: F) -> io::Result where I: FnOnce() -> io::Result, diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index fb39573..d4d3965 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -18,6 +18,29 @@ use crate::{ const MINIMUM_WIDTH: u16 = 40; const MINIMUM_HEIGHT: u16 = 8; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ColorCapability { + Color, + Monochrome, +} + +impl ColorCapability { + pub fn detect() -> Self { + Self::from_environment( + std::env::var_os("TERM").as_deref(), + std::env::var_os("NO_COLOR").is_some(), + ) + } + + fn from_environment(term: Option<&std::ffi::OsStr>, no_color: bool) -> Self { + if no_color || term.is_some_and(|term| term == "dumb") { + Self::Monochrome + } else { + Self::Color + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum LayoutClass { TooSmall, @@ -39,6 +62,19 @@ pub fn layout_class(area: Rect) -> LayoutClass { } pub fn draw(frame: &mut Frame, app: &App) { + draw_with_color_capability(frame, app, ColorCapability::Color); +} + +pub fn draw_with_color_capability(frame: &mut Frame, app: &App, capability: ColorCapability) { + draw_inner(frame, app); + if capability == ColorCapability::Monochrome { + for cell in &mut frame.buffer_mut().content { + cell.set_fg(Color::Reset).set_bg(Color::Reset); + } + } +} + +fn draw_inner(frame: &mut Frame, app: &App) { let area = frame.area(); if layout_class(area) == LayoutClass::TooSmall { frame.render_widget( @@ -77,7 +113,8 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { return; } - if let Some(matrix) = app.qr_popup() { + if let Some(popup) = app.qr_popup() { + let matrix = popup.matrix(); let padded_width = matrix.width() + 8; let needed_width = u16::try_from(padded_width.saturating_mul(2).saturating_add(2)).unwrap_or(u16::MAX); @@ -98,7 +135,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { }; frame.render_widget( Paragraph::new(text) - .block(Block::bordered().title("OTP QR — Esc closes")) + .block(Block::bordered().title(format!("{} — Esc closes", popup.title()))) .wrap(Wrap { trim: false }), area, ); @@ -654,6 +691,8 @@ fn prompt_line(app: &App) -> Paragraph<'static> { #[cfg(test)] mod tests { + use std::ffi::OsStr; + use ratatui::{Terminal, backend::TestBackend}; use super::*; @@ -685,6 +724,49 @@ mod tests { assert_eq!(layout_class(Rect::new(0, 0, 140, 20)), LayoutClass::Wide); } + #[test] + fn color_capability_has_a_complete_monochrome_fallback() { + assert_eq!( + ColorCapability::from_environment(Some(OsStr::new("xterm-256color")), false), + ColorCapability::Color + ); + assert_eq!( + ColorCapability::from_environment(Some(OsStr::new("dumb")), false), + ColorCapability::Monochrome + ); + assert_eq!( + ColorCapability::from_environment(Some(OsStr::new("xterm")), true), + ColorCapability::Monochrome + ); + + let app = App::new(); + let backend = TestBackend::new(100, 20); + let mut terminal = Terminal::new(backend).expect("test terminal"); + terminal + .draw(|frame| draw_with_color_capability(frame, &app, ColorCapability::Monochrome)) + .expect("monochrome draw"); + assert!( + terminal + .backend() + .buffer() + .content + .iter() + .all(|cell| { cell.fg == Color::Reset && cell.bg == Color::Reset }) + ); + + terminal + .draw(|frame| draw_with_color_capability(frame, &app, ColorCapability::Color)) + .expect("color draw"); + assert!( + terminal + .backend() + .buffer() + .content + .iter() + .any(|cell| { cell.fg != Color::Reset || cell.bg != Color::Reset }) + ); + } + #[test] fn minimum_size_message_is_clear() { let output = render(39, 8, &App::new()); diff --git a/apps/tui/src/workflow.rs b/apps/tui/src/workflow.rs index 53dce82..88954e9 100644 --- a/apps/tui/src/workflow.rs +++ b/apps/tui/src/workflow.rs @@ -106,6 +106,7 @@ pub struct GenerateForm { no_symbols: bool, overwrite: bool, in_place: bool, + presentation: GeneratedPresentation, focus: usize, } @@ -297,6 +298,7 @@ impl WorkflowForm { no_symbols: request.no_symbols, overwrite: request.force, in_place: request.in_place, + presentation: request.presentation, focus: 0, }) } @@ -438,6 +440,11 @@ impl WorkflowForm { row(form.focus == 2, "Symbols", yes_no(!form.no_symbols)), row(form.focus == 3, "Overwrite", yes_no(form.overwrite)), row(form.focus == 4, "In place", yes_no(form.in_place)), + row( + form.focus == 5, + "Presentation", + generated_presentation_label(form.presentation), + ), ], Self::Grep(form) => vec![ row(form.focus == 0, "Pattern", &form.pattern), @@ -605,7 +612,7 @@ impl WorkflowForm { no_symbols: form.no_symbols, force: form.overwrite, in_place: form.in_place, - presentation: GeneratedPresentation::Terminal, + presentation: form.presentation, }, overwrite: if form.overwrite || form.in_place { OverwriteDecision::Allow @@ -708,7 +715,8 @@ impl WorkflowForm { fn focus_count(&self) -> usize { match self { Self::Init(form) => form.recipients.len() + 1, - Self::Insert(_) | Self::Generate(_) => 5, + Self::Insert(_) => 5, + Self::Generate(_) => 6, Self::Grep(_) => 5, Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4, Self::Otp(_) => 4, @@ -751,6 +759,13 @@ impl WorkflowForm { form.overwrite = false; } } + Self::Generate(form) if form.focus == 5 => { + form.presentation = match form.presentation { + GeneratedPresentation::Terminal => GeneratedPresentation::Clipboard, + GeneratedPresentation::Clipboard => GeneratedPresentation::QrCode, + GeneratedPresentation::QrCode => GeneratedPresentation::Terminal, + } + } Self::Grep(form) if form.focus == 1 => form.ignore_case ^= true, Self::Grep(form) if form.focus == 2 => form.invert_match ^= true, Self::Grep(form) if form.focus == 3 => form.line_number ^= true, @@ -892,6 +907,14 @@ fn yes_no(value: bool) -> &'static str { if value { "yes" } else { "no" } } +fn generated_presentation_label(presentation: GeneratedPresentation) -> &'static str { + match presentation { + GeneratedPresentation::Terminal => "masked viewer", + GeneratedPresentation::Clipboard => "clipboard with timeout", + GeneratedPresentation::QrCode => "terminal QR", + } +} + #[cfg(test)] mod tests { use super::*; @@ -938,6 +961,25 @@ mod tests { assert!(generate.submission().is_ok()); type_text(&mut generate, "0"); assert!(generate.submission().unwrap_err().contains("positive")); + + let qr = WorkflowForm::generate(Some(GenerateRequest { + entry: "entry".to_owned(), + length: None, + no_symbols: false, + force: false, + in_place: false, + presentation: GeneratedPresentation::QrCode, + })); + assert!(matches!( + qr.submission(), + Ok(WorkflowSubmission::Generate { + request: GenerateRequest { + presentation: GeneratedPresentation::QrCode, + .. + }, + .. + }) + )); } #[test]