Complete TUI coverage and security audit
This commit is contained in:
@@ -41,6 +41,8 @@ documented in [`docs/otp.md`](docs/otp.md).
|
|||||||
The complete command matrix, shell completion interface, deliberate no-process
|
The complete command matrix, shell completion interface, deliberate no-process
|
||||||
differences, and executable security audit are documented in
|
differences, and executable security audit are documented in
|
||||||
[`docs/cli-parity.md`](docs/cli-parity.md).
|
[`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
|
Lossless structured entry fields, semantic/sensitivity metadata, conflict
|
||||||
tokens, and atomic frontend saves are documented in
|
tokens, and atomic frontend saves are documented in
|
||||||
[`docs/entry-documents.md`](docs/entry-documents.md).
|
[`docs/entry-documents.md`](docs/entry-documents.md).
|
||||||
|
|||||||
@@ -1,32 +1,96 @@
|
|||||||
# TUI colon-command coverage
|
# IronStorage terminal UI
|
||||||
|
|
||||||
The `:` prompt uses `ironstorage::command::parse_from`, so flags, aliases, and
|
The Ratatui frontend uses a Mutt-inspired, keyboard-first interaction model.
|
||||||
validation are shared with the milestone-01 command contract. Quoting is parsed
|
The left pane is the password tree; the right pane is the current viewer,
|
||||||
in-process; no shell or CLI executable is started. Workflow-specific screens
|
editor, search result, Git dashboard, or form. Actions are mode-specific, so a
|
||||||
consume the typed requests emitted by command mode.
|
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 |
|
The `:` prompt calls `ironstorage::command::parse_from`, so flags, aliases, and
|
||||||
| --- | --- |
|
validation are shared with the pass-compatible CLI. Quoting and completion are
|
||||||
| Initialize recipients | `:init GPG-ID…` |
|
in-process; no shell, CLI, `pass`, `git`, or `gpg` executable is started.
|
||||||
| List/show | `:list [PATH]`, `:show [OPTIONS] [ENTRY]` |
|
Secret-bearing commands are masked and omitted from history.
|
||||||
| 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` |
|
|
||||||
|
|
||||||
`:quit` is a TUI convenience. CLI shell-script generation is intentionally
|
## Default key reference
|
||||||
unavailable inside an interactive TUI; Tab and Shift-Tab provide contextual
|
|
||||||
in-process completion instead.
|
|
||||||
|
|
||||||
Remove requests always pass through a `y`/`n` confirmation before their typed
|
| Context | Keys | Action |
|
||||||
storage request is emitted. Command input containing OTP URIs, Git remote URLs,
|
| --- | --- | --- |
|
||||||
or Git configuration values is omitted from history and masked while rendered.
|
| 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.
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ pub struct WorkflowSuccess {
|
|||||||
pub entry: Option<String>,
|
pub entry: Option<String>,
|
||||||
pub document: Option<Box<EntryDocument>>,
|
pub document: Option<Box<EntryDocument>>,
|
||||||
pub grep: Option<GrepResults>,
|
pub grep: Option<GrepResults>,
|
||||||
|
pub presentation: Option<SecretPresentation>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +99,28 @@ pub struct OtpDisplay {
|
|||||||
counter: Option<u64>,
|
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 {
|
impl OtpDisplay {
|
||||||
pub fn entry(&self) -> &str {
|
pub fn entry(&self) -> &str {
|
||||||
&self.entry
|
&self.entry
|
||||||
@@ -196,6 +219,10 @@ pub enum AsyncPayload {
|
|||||||
qr: Option<QrMatrix>,
|
qr: Option<QrMatrix>,
|
||||||
},
|
},
|
||||||
OtpValidated,
|
OtpValidated,
|
||||||
|
SecretPresented {
|
||||||
|
status: String,
|
||||||
|
presentation: SecretPresentation,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -254,6 +281,7 @@ pub enum AppEffect {
|
|||||||
CancelGit,
|
CancelGit,
|
||||||
ResolveGit(Vec<ironstorage::git::GitConflictResolution>),
|
ResolveGit(Vec<ironstorage::git::GitConflictResolution>),
|
||||||
AuthenticateOtp(OtpUiRequest),
|
AuthenticateOtp(OtpUiRequest),
|
||||||
|
AuthenticateShow(ironstorage::command::ShowRequest),
|
||||||
RunCommand(CommandRequest),
|
RunCommand(CommandRequest),
|
||||||
ManualLock,
|
ManualLock,
|
||||||
}
|
}
|
||||||
@@ -289,7 +317,7 @@ pub struct App {
|
|||||||
git_view: Option<GitView>,
|
git_view: Option<GitView>,
|
||||||
git_pending: bool,
|
git_pending: bool,
|
||||||
otp_display: Option<OtpDisplay>,
|
otp_display: Option<OtpDisplay>,
|
||||||
qr_popup: Option<QrMatrix>,
|
qr_popup: Option<QrPopup>,
|
||||||
uri_popup: Option<SecretBytes>,
|
uri_popup: Option<SecretBytes>,
|
||||||
otp_pending: bool,
|
otp_pending: bool,
|
||||||
hotp_confirmation: Option<OtpUiRequest>,
|
hotp_confirmation: Option<OtpUiRequest>,
|
||||||
@@ -445,7 +473,7 @@ impl App {
|
|||||||
.is_some_and(|entry| entry == display.entry())
|
.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()
|
self.qr_popup.as_ref()
|
||||||
}
|
}
|
||||||
pub fn uri_popup(&self) -> Option<&SecretBytes> {
|
pub fn uri_popup(&self) -> Option<&SecretBytes> {
|
||||||
@@ -717,6 +745,9 @@ impl App {
|
|||||||
if let Some((path, directory)) = success.selection {
|
if let Some((path, directory)) = success.selection {
|
||||||
self.sidebar.select_path(&path, directory);
|
self.sidebar.select_path(&path, directory);
|
||||||
}
|
}
|
||||||
|
if let Some(presentation) = success.presentation {
|
||||||
|
self.apply_secret_presentation(presentation);
|
||||||
|
}
|
||||||
self.status = success.status;
|
self.status = success.status;
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -801,7 +832,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
OtpPresentationTarget::Qr => {
|
OtpPresentationTarget::Qr => {
|
||||||
self.uri_popup = None;
|
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.otp_pending = false;
|
||||||
self.status = "OTP URI is valid".to_owned();
|
self.status = "OTP URI is valid".to_owned();
|
||||||
}
|
}
|
||||||
|
Ok(AsyncPayload::SecretPresented {
|
||||||
|
status,
|
||||||
|
presentation,
|
||||||
|
}) => {
|
||||||
|
self.apply_secret_presentation(presentation);
|
||||||
|
self.status = status;
|
||||||
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.git_pending = false;
|
self.git_pending = false;
|
||||||
self.otp_pending = false;
|
self.otp_pending = false;
|
||||||
@@ -1295,6 +1336,49 @@ impl App {
|
|||||||
Some(AppEffect::None)
|
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 {
|
fn submit_command(&mut self) -> AppEffect {
|
||||||
let invocation = match self.command_line.submit() {
|
let invocation = match self.command_line.submit() {
|
||||||
Ok(invocation) => invocation,
|
Ok(invocation) => invocation,
|
||||||
@@ -1439,6 +1523,11 @@ impl App {
|
|||||||
AppEffect::None
|
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)) => {
|
CommandInvocation::Storage(CommandRequest::Edit(request)) => {
|
||||||
if context == Mode::Editor {
|
if context == Mode::Editor {
|
||||||
if self.selected_entry.as_deref() == Some(&request.entry) {
|
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>) {
|
fn open_workflow(&mut self, workflow: WorkflowAction, request: Option<CommandRequest>) {
|
||||||
let form = match (workflow, request) {
|
let form = match (workflow, request) {
|
||||||
(WorkflowAction::Initialize, Some(CommandRequest::Init(request))) => {
|
(WorkflowAction::Initialize, Some(CommandRequest::Init(request))) => {
|
||||||
@@ -2250,6 +2349,15 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert_eq!(app.mode(), Mode::Browser);
|
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"));
|
app.open_test_document("email/personal", fixture_document("email/personal"));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
enter_command(&mut app, "edit email/personal"),
|
enter_command(&mut app, "edit email/personal"),
|
||||||
@@ -2397,6 +2505,37 @@ mod tests {
|
|||||||
assert!(!app.status().contains(secret));
|
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]
|
#[test]
|
||||||
fn hotp_requires_confirmation_and_uri_forms_remain_masked() {
|
fn hotp_requires_confirmation_and_uri_forms_remain_masked() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use std::{error::Error, fmt};
|
|||||||
|
|
||||||
use ironstorage::command::{CliAction, CommandRequest, GitRequest, OtpRequest, parse_from};
|
use ironstorage::command::{CliAction, CommandRequest, GitRequest, OtpRequest, parse_from};
|
||||||
|
|
||||||
|
use crate::action::{ACTIONS, Action};
|
||||||
|
|
||||||
const HISTORY_LIMIT: usize = 100;
|
const HISTORY_LIMIT: usize = 100;
|
||||||
|
|
||||||
const ROOT_COMMANDS: &[&str] = &[
|
const ROOT_COMMANDS: &[&str] = &[
|
||||||
@@ -81,6 +83,256 @@ pub const COMMAND_COVERAGE: &[CommandCoverage] = &[
|
|||||||
coverage("unlock the TUI", ":unlock"),
|
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 {
|
const fn coverage(operation: &'static str, command: &'static str) -> CommandCoverage {
|
||||||
CommandCoverage { operation, command }
|
CommandCoverage { operation, command }
|
||||||
}
|
}
|
||||||
@@ -852,6 +1104,35 @@ mod tests {
|
|||||||
.any(|row| row.command.starts_with(command))
|
.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]
|
#[test]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use std::{
|
|||||||
|
|
||||||
use crossterm::event::{self, Event, KeyEventKind};
|
use crossterm::event::{self, Event, KeyEventKind};
|
||||||
use ratatui::DefaultTerminal;
|
use ratatui::DefaultTerminal;
|
||||||
|
use zeroize::Zeroize as _;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
action::{KeyResolution, KeyResolver},
|
action::{KeyResolution, KeyResolver},
|
||||||
@@ -72,6 +73,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
|||||||
let mut git_control = None;
|
let mut git_control = None;
|
||||||
let mut authentication_initialized = false;
|
let mut authentication_initialized = false;
|
||||||
let mut key_resolver = KeyResolver::default();
|
let mut key_resolver = KeyResolver::default();
|
||||||
|
let color_capability = ui::ColorCapability::detect();
|
||||||
let startup = app.begin_latest_request();
|
let startup = app.begin_latest_request();
|
||||||
executor.submit(startup, || {
|
executor.submit(startup, || {
|
||||||
load_startup().map(|startup| AsyncPayload::Startup(Box::new(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()?;
|
let size = terminal.size()?;
|
||||||
app.resize(size.width, size.height);
|
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)? {
|
if !event::poll(TICK_INTERVAL)? {
|
||||||
app.tick();
|
app.tick();
|
||||||
if let Some((entry, field)) = app.begin_totp_refresh() {
|
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::Resize(width, height) => app.resize(width, height),
|
||||||
Event::FocusLost => {
|
Event::Paste(mut pasted) => {
|
||||||
if let Some(coordinator) = authentication.as_mut() {
|
if let Some(coordinator) = authentication.as_mut()
|
||||||
let _ignored = coordinator.lock();
|
&& 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(())
|
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(
|
fn apply_app_effect(
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
effect: AppEffect,
|
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 => {
|
AppEffect::CancelGit => {
|
||||||
if let Some(control) = git_control.as_ref() {
|
if let Some(control) = git_control.as_ref() {
|
||||||
control.cancel();
|
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> {
|
fn load_startup() -> Result<StartupData, String> {
|
||||||
let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?;
|
let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?;
|
||||||
let repository = ironstorage::repository::Repository::open(config.vault())
|
let repository = ironstorage::repository::Repository::open(config.vault())
|
||||||
@@ -919,6 +1001,18 @@ fn apply_authentication_event(
|
|||||||
let token = app.begin_request();
|
let token = app.begin_request();
|
||||||
executor.submit(token, move || execute_otp_ui(&config, request, handle));
|
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 } => {
|
AuthenticationEvent::Failed { workflow, message } => {
|
||||||
if workflow {
|
if workflow {
|
||||||
app.workflow_authentication_failed(message);
|
app.workflow_authentication_failed(message);
|
||||||
@@ -967,6 +1061,7 @@ fn execute_workflow(
|
|||||||
let identity = GitIdentity::ironstorage();
|
let identity = GitIdentity::ironstorage();
|
||||||
let mut selection = None;
|
let mut selection = None;
|
||||||
let mut grep = None;
|
let mut grep = None;
|
||||||
|
let mut presentation = None;
|
||||||
let mut refresh_tree = true;
|
let mut refresh_tree = true;
|
||||||
let (entry, mut status) = match submission {
|
let (entry, mut status) = match submission {
|
||||||
WorkflowSubmission::Init(request) => {
|
WorkflowSubmission::Init(request) => {
|
||||||
@@ -1007,7 +1102,23 @@ fn execute_workflow(
|
|||||||
PasswordGenerator::new(&repository, &keys, GeneratorConfig::pass_defaults())
|
PasswordGenerator::new(&repository, &keys, GeneratorConfig::pass_defaults())
|
||||||
.generate(&request, overwrite, None, provider, &mut committer)
|
.generate(&request, overwrite, None, provider, &mut committer)
|
||||||
.map_err(|error| error.to_string())?;
|
.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()),
|
Some(entry.clone()),
|
||||||
format!("Generated password for {entry}"),
|
format!("Generated password for {entry}"),
|
||||||
@@ -1155,6 +1266,7 @@ fn execute_workflow(
|
|||||||
entry,
|
entry,
|
||||||
document,
|
document,
|
||||||
grep,
|
grep,
|
||||||
|
presentation,
|
||||||
status,
|
status,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1270,7 +1382,7 @@ mod tests {
|
|||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
command::{
|
command::{
|
||||||
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest,
|
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest,
|
||||||
InsertInput, InsertRequest, MoveRequest, RemoveRequest,
|
InsertInput, InsertRequest, MoveRequest, Presentation, RemoveRequest, ShowRequest,
|
||||||
},
|
},
|
||||||
crypto::{KeyInfo, SecretProvider, SecretProviderError},
|
crypto::{KeyInfo, SecretProvider, SecretProviderError},
|
||||||
repository::SecretBytes,
|
repository::SecretBytes,
|
||||||
@@ -1287,6 +1399,149 @@ mod tests {
|
|||||||
assert!(!is_dispatchable_key_kind(KeyEventKind::Release));
|
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]
|
#[test]
|
||||||
fn editor_save_encrypts_and_automatically_commits_entirely_in_storage() {
|
fn editor_save_encrypts_and_automatically_commits_entirely_in_storage() {
|
||||||
let temporary = tempfile::tempdir().expect("temporary editor store");
|
let temporary = tempfile::tempdir().expect("temporary editor store");
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use std::io;
|
|||||||
|
|
||||||
fn main() -> io::Result<()> {
|
fn main() -> io::Result<()> {
|
||||||
ironstorage_tui::terminal::run(
|
ironstorage_tui::terminal::run(
|
||||||
ratatui::try_init,
|
ironstorage_tui::terminal::initialize,
|
||||||
ratatui::try_restore,
|
ironstorage_tui::terminal::restore,
|
||||||
ironstorage_tui::run,
|
ironstorage_tui::run,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ pub enum AuthenticationTarget {
|
|||||||
Workflow(Box<WorkflowSubmission>),
|
Workflow(Box<WorkflowSubmission>),
|
||||||
Git(ironstorage::command::GitRequest),
|
Git(ironstorage::command::GitRequest),
|
||||||
Otp(crate::app::OtpUiRequest),
|
Otp(crate::app::OtpUiRequest),
|
||||||
|
Show(ironstorage::command::ShowRequest),
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AuthenticationCompletion {
|
struct AuthenticationCompletion {
|
||||||
@@ -89,6 +90,10 @@ impl AuthenticationCoordinator {
|
|||||||
self.request(AuthenticationTarget::Otp(request));
|
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) {
|
fn request(&mut self, target: AuthenticationTarget) {
|
||||||
self.generation = self.generation.wrapping_add(1);
|
self.generation = self.generation.wrapping_add(1);
|
||||||
let generation = self.generation;
|
let generation = self.generation;
|
||||||
@@ -259,4 +264,28 @@ mod tests {
|
|||||||
.expect("worker should return a typed result");
|
.expect("worker should return a typed result");
|
||||||
assert_eq!(app.apply_result(result), ResultDisposition::Applied);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,26 @@ use std::{
|
|||||||
panic::{AssertUnwindSafe, catch_unwind, resume_unwind},
|
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>
|
pub fn run<T, R, I, C, F>(initialize: I, mut restore: C, operation: F) -> io::Result<R>
|
||||||
where
|
where
|
||||||
I: FnOnce() -> io::Result<T>,
|
I: FnOnce() -> io::Result<T>,
|
||||||
|
|||||||
@@ -18,6 +18,29 @@ use crate::{
|
|||||||
const MINIMUM_WIDTH: u16 = 40;
|
const MINIMUM_WIDTH: u16 = 40;
|
||||||
const MINIMUM_HEIGHT: u16 = 8;
|
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)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum LayoutClass {
|
pub enum LayoutClass {
|
||||||
TooSmall,
|
TooSmall,
|
||||||
@@ -39,6 +62,19 @@ pub fn layout_class(area: Rect) -> LayoutClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, app: &App) {
|
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();
|
let area = frame.area();
|
||||||
if layout_class(area) == LayoutClass::TooSmall {
|
if layout_class(area) == LayoutClass::TooSmall {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
@@ -77,7 +113,8 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
return;
|
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 padded_width = matrix.width() + 8;
|
||||||
let needed_width =
|
let needed_width =
|
||||||
u16::try_from(padded_width.saturating_mul(2).saturating_add(2)).unwrap_or(u16::MAX);
|
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(
|
frame.render_widget(
|
||||||
Paragraph::new(text)
|
Paragraph::new(text)
|
||||||
.block(Block::bordered().title("OTP QR — Esc closes"))
|
.block(Block::bordered().title(format!("{} — Esc closes", popup.title())))
|
||||||
.wrap(Wrap { trim: false }),
|
.wrap(Wrap { trim: false }),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
@@ -654,6 +691,8 @@ fn prompt_line(app: &App) -> Paragraph<'static> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
|
||||||
use ratatui::{Terminal, backend::TestBackend};
|
use ratatui::{Terminal, backend::TestBackend};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -685,6 +724,49 @@ mod tests {
|
|||||||
assert_eq!(layout_class(Rect::new(0, 0, 140, 20)), LayoutClass::Wide);
|
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]
|
#[test]
|
||||||
fn minimum_size_message_is_clear() {
|
fn minimum_size_message_is_clear() {
|
||||||
let output = render(39, 8, &App::new());
|
let output = render(39, 8, &App::new());
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ pub struct GenerateForm {
|
|||||||
no_symbols: bool,
|
no_symbols: bool,
|
||||||
overwrite: bool,
|
overwrite: bool,
|
||||||
in_place: bool,
|
in_place: bool,
|
||||||
|
presentation: GeneratedPresentation,
|
||||||
focus: usize,
|
focus: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +298,7 @@ impl WorkflowForm {
|
|||||||
no_symbols: request.no_symbols,
|
no_symbols: request.no_symbols,
|
||||||
overwrite: request.force,
|
overwrite: request.force,
|
||||||
in_place: request.in_place,
|
in_place: request.in_place,
|
||||||
|
presentation: request.presentation,
|
||||||
focus: 0,
|
focus: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -438,6 +440,11 @@ impl WorkflowForm {
|
|||||||
row(form.focus == 2, "Symbols", yes_no(!form.no_symbols)),
|
row(form.focus == 2, "Symbols", yes_no(!form.no_symbols)),
|
||||||
row(form.focus == 3, "Overwrite", yes_no(form.overwrite)),
|
row(form.focus == 3, "Overwrite", yes_no(form.overwrite)),
|
||||||
row(form.focus == 4, "In place", yes_no(form.in_place)),
|
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![
|
Self::Grep(form) => vec![
|
||||||
row(form.focus == 0, "Pattern", &form.pattern),
|
row(form.focus == 0, "Pattern", &form.pattern),
|
||||||
@@ -605,7 +612,7 @@ impl WorkflowForm {
|
|||||||
no_symbols: form.no_symbols,
|
no_symbols: form.no_symbols,
|
||||||
force: form.overwrite,
|
force: form.overwrite,
|
||||||
in_place: form.in_place,
|
in_place: form.in_place,
|
||||||
presentation: GeneratedPresentation::Terminal,
|
presentation: form.presentation,
|
||||||
},
|
},
|
||||||
overwrite: if form.overwrite || form.in_place {
|
overwrite: if form.overwrite || form.in_place {
|
||||||
OverwriteDecision::Allow
|
OverwriteDecision::Allow
|
||||||
@@ -708,7 +715,8 @@ impl WorkflowForm {
|
|||||||
fn focus_count(&self) -> usize {
|
fn focus_count(&self) -> usize {
|
||||||
match self {
|
match self {
|
||||||
Self::Init(form) => form.recipients.len() + 1,
|
Self::Init(form) => form.recipients.len() + 1,
|
||||||
Self::Insert(_) | Self::Generate(_) => 5,
|
Self::Insert(_) => 5,
|
||||||
|
Self::Generate(_) => 6,
|
||||||
Self::Grep(_) => 5,
|
Self::Grep(_) => 5,
|
||||||
Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4,
|
Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4,
|
||||||
Self::Otp(_) => 4,
|
Self::Otp(_) => 4,
|
||||||
@@ -751,6 +759,13 @@ impl WorkflowForm {
|
|||||||
form.overwrite = false;
|
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 == 1 => form.ignore_case ^= true,
|
||||||
Self::Grep(form) if form.focus == 2 => form.invert_match ^= true,
|
Self::Grep(form) if form.focus == 2 => form.invert_match ^= true,
|
||||||
Self::Grep(form) if form.focus == 3 => form.line_number ^= 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" }
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -938,6 +961,25 @@ mod tests {
|
|||||||
assert!(generate.submission().is_ok());
|
assert!(generate.submission().is_ok());
|
||||||
type_text(&mut generate, "0");
|
type_text(&mut generate, "0");
|
||||||
assert!(generate.submission().unwrap_err().contains("positive"));
|
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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user