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

@@ -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.

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();

View File

@@ -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<Action>,
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<Action>,
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]

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");

View File

@@ -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,
)
}

View File

@@ -37,6 +37,7 @@ pub enum AuthenticationTarget {
Workflow(Box<WorkflowSubmission>),
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);
}
}

View File

@@ -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<DefaultTerminal> {
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<T, R, I, C, F>(initialize: I, mut restore: C, operation: F) -> io::Result<R>
where
I: FnOnce() -> io::Result<T>,

View File

@@ -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());

View File

@@ -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]