Complete TUI coverage and security audit
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user