diff --git a/apps/tui/src/action.rs b/apps/tui/src/action.rs index f035a9b..ba204d9 100644 --- a/apps/tui/src/action.rs +++ b/apps/tui/src/action.rs @@ -27,8 +27,6 @@ pub enum Action { PreviousMatch, FocusNext, FocusPrevious, - Reveal, - Hide, Copy, ScrollDown, ScrollUp, @@ -374,20 +372,6 @@ pub static ACTIONS: &[ActionSpec] = &[ bindings: keys!((KeyCode::BackTab, KeyModifiers::SHIFT, "S-Tab")), modes: UNLOCKED, }, - ActionSpec { - action: Action::Reveal, - label: "reveal field", - command: "reveal", - bindings: keys!((KeyCode::Char('v'), KeyModifiers::NONE, "v")), - modes: &[Mode::Viewer, Mode::Editor], - }, - ActionSpec { - action: Action::Hide, - label: "hide field", - command: "hide", - bindings: keys!((KeyCode::Char('V'), KeyModifiers::SHIFT, "V")), - modes: &[Mode::Viewer, Mode::Editor], - }, ActionSpec { action: Action::Copy, label: "copy field", diff --git a/apps/tui/src/app.rs b/apps/tui/src/app.rs index 2aae0d7..a85a4f7 100644 --- a/apps/tui/src/app.rs +++ b/apps/tui/src/app.rs @@ -1,6 +1,6 @@ //! Pure UI state machine. Storage behavior is represented only by typed results. -use std::collections::BTreeSet; +use std::{collections::BTreeSet, time::Instant}; use ironstorage::{ command::{ @@ -12,7 +12,7 @@ use ironstorage::{ document::{DocumentError, EntryDocument, EntryFieldId}, git::{GitConflict, GitProgressPhase, GitSnapshot}, otp::OtpCodeValidity, - presentation::{ClipboardDisposition, QrMatrix}, + presentation::{ClipboardDisposition, ClipboardError, QrMatrix}, read::{FindResults, GrepResults, TreeModel}, repository::SecretBytes, write::WriteOutcome, @@ -64,6 +64,15 @@ pub struct RequestToken { generation: u64, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ClipboardPresentationId(pub(crate) u64); + +#[derive(Clone, Copy, Debug)] +struct ClipboardPresentation { + id: ClipboardPresentationId, + deadline: Option, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct StartupData { pub config: Config, @@ -188,7 +197,14 @@ pub enum AsyncPayload { entry: String, document: Box, }, - ClipboardFinished(ClipboardDisposition), + ClipboardStarted { + presentation: ClipboardPresentationId, + deadline: Instant, + }, + ClipboardFinished { + presentation: ClipboardPresentationId, + result: Result, + }, GeneratedField { target: EntryFieldId, password: SecretBytes, @@ -273,7 +289,10 @@ pub enum AppEffect { None, RefreshTree, AuthenticateEntry(String), - CopyFocused(SecretBytes), + CopyFocused { + presentation: ClipboardPresentationId, + value: SecretBytes, + }, GenerateField(EntryFieldId), SaveDocument { config: Box, @@ -326,6 +345,8 @@ pub struct App { otp_pending: bool, hotp_confirmation: Option, clipboard_request: Option, + clipboard_presentation: Option, + next_clipboard_presentation: u64, remaining_lease: Option, terminal_size: (u16, u16), ticks: u64, @@ -372,6 +393,8 @@ impl App { otp_pending: false, hotp_confirmation: None, clipboard_request: None, + clipboard_presentation: None, + next_clipboard_presentation: 0, remaining_lease: None, terminal_size: (0, 0), ticks: 0, @@ -489,8 +512,23 @@ impl App { pub fn hotp_confirmation(&self) -> bool { self.hotp_confirmation.is_some() } - pub fn take_clipboard_request(&mut self) -> Option { - self.clipboard_request.take() + pub fn take_clipboard_effect(&mut self) -> Option { + let value = self.clipboard_request.take()?; + Some(self.begin_clipboard_presentation(value)) + } + + pub fn clipboard_remaining_seconds(&self) -> Option { + self.clipboard_remaining_seconds_at(Instant::now()) + } + + fn clipboard_remaining_seconds_at(&self, now: Instant) -> Option { + let deadline = self.clipboard_presentation?.deadline?; + let milliseconds = deadline.saturating_duration_since(now).as_millis(); + Some(u64::try_from(milliseconds.div_ceil(1_000)).unwrap_or(u64::MAX)) + } + + pub fn clipboard_pending(&self) -> bool { + self.clipboard_presentation.is_some() } pub fn begin_git_operation(&mut self, label: &str) { @@ -595,7 +633,10 @@ impl App { if result.token.generation != self.generation || !self.pending.contains(&result.token.id) { return ResultDisposition::Stale; } - if !matches!(result.payload, Ok(AsyncPayload::GitProgress(_))) { + if !matches!( + result.payload, + Ok(AsyncPayload::GitProgress(_) | AsyncPayload::ClipboardStarted { .. }) + ) { self.pending.remove(&result.token.id); } match result.payload { @@ -645,18 +686,39 @@ impl App { } self.focus = PaneFocus::Main; } - Ok(AsyncPayload::ClipboardFinished(disposition)) => { - if self.mode == Mode::Locked { + Ok(AsyncPayload::ClipboardStarted { + presentation, + deadline, + }) => { + if let Some(active) = self + .clipboard_presentation + .as_mut() + .filter(|active| active.id == presentation) + { + active.deadline = Some(deadline); + self.status = "Secret copied".to_owned(); + } + } + Ok(AsyncPayload::ClipboardFinished { + presentation, + result, + }) => { + if self + .clipboard_presentation + .is_none_or(|active| active.id != presentation) + { return ResultDisposition::Applied; } - self.status = match disposition { - ClipboardDisposition::RestoredPrevious => { + self.clipboard_presentation = None; + self.status = match result { + Ok(ClipboardDisposition::RestoredPrevious) => { "Clipboard restored to its previous value".to_owned() } - ClipboardDisposition::Cleared => "Clipboard secret cleared".to_owned(), - ClipboardDisposition::PreservedNewer => { + Ok(ClipboardDisposition::Cleared) => "Clipboard secret cleared".to_owned(), + Ok(ClipboardDisposition::PreservedNewer) => { "Clipboard changed; the newer value was preserved".to_owned() } + Err(error) => format!("Clipboard presentation failed: {error}"), }; } Ok(AsyncPayload::GeneratedField { target, password }) => { @@ -1046,44 +1108,11 @@ impl App { PaneFocus::Main => PaneFocus::Sidebar, }; } - Action::Reveal => { - let revealed = if self.mode == Mode::Editor { - self.editor - .as_mut() - .is_some_and(EntryEditor::reveal_focused) - } else { - self.viewer - .as_mut() - .is_some_and(EntryViewer::reveal_focused) - }; - if revealed { - self.status = "Focused sensitive field revealed".to_owned(); - } - } - Action::Hide => { - let hidden = if self.mode == Mode::Editor { - self.editor.as_mut().is_some_and(EntryEditor::hide_revealed) - } else { - self.viewer.as_mut().is_some_and(EntryViewer::hide_revealed) - }; - if hidden { - self.status = "Sensitive field hidden".to_owned(); - } - } Action::Copy => { if let Some(viewer) = self.viewer.as_ref() { match viewer.copy_focused() { Ok(value) => { - self.status = self.config.as_ref().map_or_else( - || "Copying focused field…".to_owned(), - |config| { - format!( - "Copied focused field; cleanup in {}s", - config.clipboard_timeout().duration().as_secs() - ) - }, - ); - return AppEffect::CopyFocused(value); + return self.begin_clipboard_presentation(value); } Err(error) => self.status = error.to_string(), } @@ -1660,6 +1689,20 @@ impl App { } } + fn begin_clipboard_presentation(&mut self, value: SecretBytes) -> AppEffect { + let presentation = ClipboardPresentationId(self.next_clipboard_presentation); + self.next_clipboard_presentation = self.next_clipboard_presentation.wrapping_add(1); + self.clipboard_presentation = Some(ClipboardPresentation { + id: presentation, + deadline: None, + }); + self.status = "Copying secret to the native clipboard…".to_owned(); + AppEffect::CopyFocused { + presentation, + value, + } + } + fn open_workflow(&mut self, workflow: WorkflowAction, request: Option) { let form = match (workflow, request) { (WorkflowAction::Initialize, Some(CommandRequest::Init(request))) => { @@ -1935,6 +1978,7 @@ impl App { self.qr_popup = None; self.uri_popup = None; self.clipboard_request = None; + self.clipboard_presentation = None; self.authentication_pending = None; self.selected_entry = None; self.viewer = None; @@ -1968,6 +2012,7 @@ impl App { self.qr_popup = None; self.uri_popup = None; self.clipboard_request = None; + self.clipboard_presentation = None; self.remaining_lease = None; self.status = if discarded_edit { format!("Locked: {reason}; unsaved edits were discarded") @@ -2012,11 +2057,7 @@ impl App { return false; }; if matches!(destination, Mode::Dialog | Mode::Help | Mode::Command) { - if let Some(viewer) = self.viewer.as_mut() { - viewer.hide_revealed(); - } if let Some(editor) = self.editor.as_mut() { - editor.hide_revealed(); editor.end_input(); } self.suspended_mode = Some(current); @@ -2050,6 +2091,7 @@ impl App { self.qr_popup = None; self.uri_popup = None; self.clipboard_request = None; + self.clipboard_presentation = None; self.status = "Locked".to_owned(); } else if current == Mode::Locked { self.status = "Authentication required".to_owned(); @@ -2220,6 +2262,104 @@ mod tests { assert_eq!(app.focus(), PaneFocus::Main); } + #[test] + fn clipboard_countdown_resets_finishes_and_cannot_survive_lock() { + let mut app = App::new(); + app.open_test_document("email/personal", fixture_document("email/personal")); + let first = match app.dispatch(Action::Copy) { + AppEffect::CopyFocused { presentation, .. } => presentation, + effect => panic!("unexpected effect: {effect:?}"), + }; + let first_token = app.begin_request(); + let first_deadline = Instant::now() + std::time::Duration::from_secs(3); + assert_eq!( + app.apply_result(AsyncResult { + token: first_token, + payload: Ok(AsyncPayload::ClipboardStarted { + presentation: first, + deadline: first_deadline, + }), + }), + ResultDisposition::Applied + ); + assert_eq!( + app.clipboard_remaining_seconds_at(first_deadline - std::time::Duration::from_secs(3)), + Some(3) + ); + assert_eq!( + app.clipboard_remaining_seconds_at(first_deadline - std::time::Duration::from_secs(1)), + Some(1) + ); + assert_eq!(app.clipboard_remaining_seconds_at(first_deadline), Some(0)); + + let second = match app.dispatch(Action::Copy) { + AppEffect::CopyFocused { presentation, .. } => presentation, + effect => panic!("unexpected effect: {effect:?}"), + }; + assert_ne!(first, second); + assert_eq!( + app.apply_result(AsyncResult { + token: first_token, + payload: Ok(AsyncPayload::ClipboardFinished { + presentation: first, + result: Err(ClipboardError::Cancelled), + }), + }), + ResultDisposition::Applied + ); + assert!(app.clipboard_pending()); + assert_eq!(app.clipboard_remaining_seconds(), None); + + let second_token = app.begin_request(); + let second_deadline = Instant::now() + std::time::Duration::from_secs(5); + app.apply_result(AsyncResult { + token: second_token, + payload: Ok(AsyncPayload::ClipboardStarted { + presentation: second, + deadline: second_deadline, + }), + }); + assert_eq!( + app.clipboard_remaining_seconds_at(second_deadline - std::time::Duration::from_secs(5)), + Some(5) + ); + app.apply_result(AsyncResult { + token: second_token, + payload: Ok(AsyncPayload::ClipboardFinished { + presentation: second, + result: Ok(ClipboardDisposition::Cleared), + }), + }); + assert!(!app.clipboard_pending()); + assert_eq!(app.clipboard_remaining_seconds(), None); + assert_eq!(app.status(), "Clipboard secret cleared"); + + let third = match app.dispatch(Action::Copy) { + AppEffect::CopyFocused { presentation, .. } => presentation, + effect => panic!("unexpected effect: {effect:?}"), + }; + let third_token = app.begin_request(); + app.apply_result(AsyncResult { + token: third_token, + payload: Ok(AsyncPayload::ClipboardStarted { + presentation: third, + deadline: Instant::now() + std::time::Duration::from_secs(5), + }), + }); + assert!(matches!(app.dispatch(Action::Lock), AppEffect::ManualLock)); + assert!(!app.clipboard_pending()); + assert_eq!( + app.apply_result(AsyncResult { + token: third_token, + payload: Ok(AsyncPayload::ClipboardFinished { + presentation: third, + result: Ok(ClipboardDisposition::Cleared), + }), + }), + ResultDisposition::Stale + ); + } + #[test] fn authentication_must_match_the_pending_entry_before_viewer_transition() { let mut app = App::new(); diff --git a/apps/tui/src/editor.rs b/apps/tui/src/editor.rs index b368efb..65be1be 100644 --- a/apps/tui/src/editor.rs +++ b/apps/tui/src/editor.rs @@ -21,7 +21,6 @@ pub struct EntryEditor { buffer: SecretBytes, cursor: usize, input_active: bool, - revealed: bool, dirty: bool, } @@ -38,7 +37,6 @@ impl EntryEditor { buffer, cursor, input_active: false, - revealed: false, dirty: false, } } @@ -77,27 +75,6 @@ impl EntryEditor { pub fn end_input(&mut self) { self.input_active = false; - self.revealed = false; - } - - pub fn is_revealed(&self, id: EntryFieldId) -> bool { - self.revealed && self.focused_field().is_some_and(|field| field.id() == id) - } - - pub fn reveal_focused(&mut self) -> bool { - if self - .focused_field() - .is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive) - { - self.revealed = true; - true - } else { - false - } - } - - pub fn hide_revealed(&mut self) -> bool { - std::mem::take(&mut self.revealed) } pub fn is_dirty(&self) -> bool { @@ -106,7 +83,6 @@ impl EntryEditor { pub fn focus_next(&mut self) -> Result<(), DocumentError> { self.flush()?; - self.hide_revealed(); if !self.document.fields().is_empty() { self.focused = (self.focused + 1) % self.document.fields().len(); self.load_focused(); @@ -116,7 +92,6 @@ impl EntryEditor { pub fn focus_previous(&mut self) -> Result<(), DocumentError> { self.flush()?; - self.hide_revealed(); if !self.document.fields().is_empty() { self.focused = self .focused @@ -209,7 +184,6 @@ impl EntryEditor { { self.load_focused(); } - self.revealed = false; Ok(()) } @@ -319,7 +293,6 @@ impl EntryEditor { |field| SecretBytes::new(field.contents().expose().to_vec()), ); self.cursor = self.buffer.expose().len(); - self.revealed = false; if self.focused_field().is_none() { self.input_active = false; } @@ -348,7 +321,6 @@ impl fmt::Debug for EntryEditor { .field("buffer", &"[REDACTED]") .field("cursor", &self.cursor) .field("input_active", &self.input_active) - .field("revealed", &self.revealed) .field("dirty", &self.dirty) .finish() } @@ -386,15 +358,12 @@ mod tests { use crate::viewer::test_support::fixture_document; #[test] - fn unicode_input_focus_and_masking_are_predictable() { + fn unicode_input_and_focus_are_predictable() { let mut editor = EntryEditor::new(fixture_document("unicode/咖啡")); - assert!(editor.reveal_focused()); editor.begin_input(); editor.move_cursor_end(); editor.insert_character('界'); - assert!(editor.is_revealed(editor.focused_field().expect("field").id())); editor.focus_next().expect("focus next"); - assert!(!editor.revealed); editor.focus_previous().expect("focus previous"); assert!( editor diff --git a/apps/tui/src/lib.rs b/apps/tui/src/lib.rs index aa3d4c6..f33c502 100644 --- a/apps/tui/src/lib.rs +++ b/apps/tui/src/lib.rs @@ -18,8 +18,8 @@ pub mod workflow; use std::{ io, path::PathBuf, - sync::mpsc::{self, Sender}, - time::Duration, + sync::mpsc::{self, Receiver, Sender}, + time::{Duration, Instant}, }; use crossterm::event::{self, Event, KeyEventKind}; @@ -40,26 +40,41 @@ use crate::{ const TICK_INTERVAL: Duration = Duration::from_millis(250); +struct ClipboardCancellation { + sender: Sender<()>, + finished: Receiver>, +} + #[derive(Default)] -struct ClipboardCancellations(Vec>); +struct ClipboardCancellations(Option); impl ClipboardCancellations { - fn register(&mut self, cancellation: Sender<()>) { - self.0.push(cancellation); + fn replace( + &mut self, + cancellation: Sender<()>, + finished: Receiver>, + ) -> Option>> { + let previous = self.0.take().map(|active| { + let _ignored = active.sender.send(()); + active.finished + }); + self.0 = Some(ClipboardCancellation { + sender: cancellation, + finished, + }); + previous } fn cancel_all(&mut self) { - for cancellation in self.0.drain(..) { - let _ignored = cancellation.send(()); + if let Some(cancellation) = self.0.take() { + let _ignored = cancellation.sender.send(()); } } } impl Drop for ClipboardCancellations { fn drop(&mut self) { - for cancellation in self.0.drain(..) { - let _ignored = cancellation.send(()); - } + self.cancel_all(); } } @@ -83,10 +98,10 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { for result in executor.drain() { app.apply_result(result); } - if let Some(value) = app.take_clipboard_request() { + if let Some(effect) = app.take_clipboard_effect() { apply_app_effect( &mut app, - AppEffect::CopyFocused(value), + effect, &mut authentication, &executor, &mut git_control, @@ -108,7 +123,14 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { if let Some(coordinator) = authentication.as_mut() && let Some(event) = coordinator.completion() { - apply_authentication_event(&mut app, coordinator, &executor, &mut git_control, event); + apply_authentication_event( + &mut app, + coordinator, + &executor, + &mut git_control, + &mut clipboard_cancellations, + event, + ); } let unix_seconds = current_unix_seconds().map_err(io::Error::other)?; @@ -127,6 +149,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { coordinator, &executor, &mut git_control, + &mut clipboard_cancellations, event, ); } @@ -145,6 +168,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { coordinator, &executor, &mut git_control, + &mut clipboard_cancellations, event, ); } @@ -218,6 +242,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { coordinator, &executor, &mut git_control, + &mut clipboard_cancellations, event, ); } @@ -378,35 +403,76 @@ fn apply_app_effect( }); } } - AppEffect::CopyFocused(value) => { - if let Some(config) = app.config().cloned() { - app.report_status(format!( - "Secret copied; cleanup in {}s", - config.clipboard_timeout().duration().as_secs() - )); - let (cancel, cancellation) = mpsc::channel(); - clipboard_cancellations.register(cancel); - let token = app.begin_request(); - executor.submit(token, move || { - let mut clipboard = ironstorage::presentation::NativeClipboardManager::system( - config.clipboard_timeout(), - ) - .map_err(|error| error.to_string())?; - clipboard - .copy_with(&value, |duration| { - match cancellation.recv_timeout(duration) { - Err(mpsc::RecvTimeoutError::Timeout) => { - ironstorage::presentation::ClipboardWait::Elapsed - } - Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => { - ironstorage::presentation::ClipboardWait::Cancelled - } - } - }) - .map(AsyncPayload::ClipboardFinished) - .map_err(|error| error.to_string()) + AppEffect::CopyFocused { + presentation, + value, + } => { + let token = app.begin_request(); + let Some(config) = app.config().cloned() else { + app.apply_result(crate::app::AsyncResult { + token, + payload: Ok(AsyncPayload::ClipboardFinished { + presentation, + result: Err(ironstorage::presentation::ClipboardError::Unavailable), + }), }); - } + return; + }; + let (cancel, cancellation) = mpsc::channel(); + let (finished, completion) = mpsc::channel(); + let previous = clipboard_cancellations.replace(cancel, completion); + let started = executor.clipboard_started_reporter(token); + executor.submit(token, move || { + // Finish the old cleanup before taking the new clipboard snapshot, + // otherwise the new presentation could later restore an older secret. + let previous = previous.map_or(Ok(()), |previous| { + previous.recv().unwrap_or(Err( + ironstorage::presentation::ClipboardError::CleanupFailed, + )) + }); + let payload = match (previous, cancellation.try_recv()) { + (Err(error), _) => AsyncPayload::ClipboardFinished { + presentation, + result: Err(error), + }, + (Ok(()), Err(mpsc::TryRecvError::Empty)) => run_clipboard_presentation( + ironstorage::presentation::NativeClipboardManager::system( + config.clipboard_timeout(), + ), + presentation, + value, + started, + move |deadline| match cancellation + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + { + Err(mpsc::RecvTimeoutError::Timeout) => { + ironstorage::presentation::ClipboardWait::Elapsed + } + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => { + ironstorage::presentation::ClipboardWait::Cancelled + } + }, + ), + (Ok(()), Ok(())) | (Ok(()), Err(mpsc::TryRecvError::Disconnected)) => { + AsyncPayload::ClipboardFinished { + presentation, + result: Err(ironstorage::presentation::ClipboardError::Cancelled), + } + } + }; + let cleanup = match &payload { + AsyncPayload::ClipboardFinished { + result: Ok(_) | Err(ironstorage::presentation::ClipboardError::Cancelled), + .. + } => Ok(()), + AsyncPayload::ClipboardFinished { + result: Err(error), .. + } => Err(*error), + _ => unreachable!("clipboard worker returns a clipboard result"), + }; + let _ignored = finished.send(cleanup); + Ok(payload) + }); } AppEffect::GenerateField(target) => { let token = app.begin_request(); @@ -493,6 +559,34 @@ fn apply_app_effect( } } +fn run_clipboard_presentation( + clipboard: Result< + ironstorage::presentation::ClipboardManager, + ironstorage::presentation::ClipboardError, + >, + presentation: crate::app::ClipboardPresentationId, + value: ironstorage::repository::SecretBytes, + started: S, + wait: W, +) -> AsyncPayload +where + B: ironstorage::presentation::ClipboardBackend, + S: FnOnce(crate::app::ClipboardPresentationId, Instant), + W: FnOnce(Instant) -> ironstorage::presentation::ClipboardWait, +{ + let result = clipboard.and_then(|mut clipboard| { + clipboard.copy_with(&value, |duration| { + let deadline = Instant::now() + duration; + started(presentation, deadline); + wait(deadline) + }) + }); + AsyncPayload::ClipboardFinished { + presentation, + result, + } +} + fn is_dispatchable_key_kind(kind: KeyEventKind) -> bool { matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat) } @@ -948,6 +1042,7 @@ fn apply_authentication_event( coordinator: &AuthenticationCoordinator, executor: &AsyncExecutor, git_control: &mut Option, + clipboard_cancellations: &mut ClipboardCancellations, event: AuthenticationEvent, ) { match event { @@ -1033,10 +1128,20 @@ fn apply_authentication_event( app.authentication_failed(message); } } - AuthenticationEvent::Expired => app.forced_relock("authentication lease expired"), + AuthenticationEvent::Expired => { + handle_authentication_expiry(app, clipboard_cancellations); + } } } +fn handle_authentication_expiry( + app: &mut App, + clipboard_cancellations: &mut ClipboardCancellations, +) { + clipboard_cancellations.cancel_all(); + app.forced_relock("authentication lease expired"); +} + fn load_document( config: &ironstorage::config::Config, entry: &str, @@ -1403,7 +1508,11 @@ mod tests { }; use super::*; - use crate::{action::Action, editor::EntryEditor, viewer::test_support::fixture_document_from}; + use crate::{ + action::Action, + editor::EntryEditor, + viewer::test_support::{fixture_document, fixture_document_from}, + }; #[test] fn press_and_terminal_repeat_events_dispatch_but_release_does_not() { @@ -1455,11 +1564,30 @@ mod tests { #[test] fn terminal_ownership_loss_cancels_presentations_and_forces_relock() { let mut app = App::new(); + app.open_test_document("email/personal", fixture_document("email/personal")); + let presentation = match app.dispatch(Action::Copy) { + AppEffect::CopyFocused { presentation, .. } => presentation, + effect => panic!("unexpected effect: {effect:?}"), + }; let mut authentication = None; let mut git_control = None; let mut clipboard = ClipboardCancellations::default(); + let (first_cancel, first_cancelled) = std::sync::mpsc::channel(); + let (first_finished, first_completion) = std::sync::mpsc::channel(); + assert!(clipboard.replace(first_cancel, first_completion).is_none()); let (cancel, cancelled) = std::sync::mpsc::channel(); - clipboard.register(cancel); + let (_finished, completion) = std::sync::mpsc::channel(); + let previous = clipboard + .replace(cancel, completion) + .expect("previous clipboard completion"); + first_cancelled + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("replacement cancels the previous clipboard presentation"); + first_finished.send(Ok(())).expect("finish first cleanup"); + previous + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("replacement waits for previous cleanup") + .expect("previous cleanup succeeds"); handle_terminal_ownership_lost( &mut app, @@ -1469,10 +1597,100 @@ mod tests { ); assert_eq!(app.mode(), crate::app::Mode::Locked); + assert!(!app.clipboard_pending()); assert!(app.status().contains("terminal ownership was lost")); cancelled .recv_timeout(std::time::Duration::from_secs(1)) .expect("clipboard cancellation"); + assert_eq!(presentation.0, 0); + } + + #[test] + fn authentication_expiry_cancels_the_active_clipboard_presentation() { + let mut app = App::new(); + app.open_test_document("email/personal", fixture_document("email/personal")); + assert!(matches!( + app.dispatch(Action::Copy), + AppEffect::CopyFocused { .. } + )); + let mut clipboard = ClipboardCancellations::default(); + let (cancel, cancelled) = std::sync::mpsc::channel(); + let (_finished, completion) = std::sync::mpsc::channel(); + assert!(clipboard.replace(cancel, completion).is_none()); + + handle_authentication_expiry(&mut app, &mut clipboard); + + cancelled + .recv_timeout(Duration::from_secs(1)) + .expect("clipboard cancellation on expiry"); + assert_eq!(app.mode(), crate::app::Mode::Locked); + assert!(!app.clipboard_pending()); + } + + #[derive(Default)] + struct MemoryClipboard(Vec); + + impl ironstorage::presentation::ClipboardBackend for MemoryClipboard { + fn read( + &mut self, + ) -> Result< + ironstorage::presentation::ClipboardContent, + ironstorage::presentation::ClipboardError, + > { + if self.0.is_empty() { + Ok(ironstorage::presentation::ClipboardContent::EmptyOrNonText) + } else { + Ok(ironstorage::presentation::ClipboardContent::text( + self.0.clone(), + )) + } + } + + fn write( + &mut self, + value: &SecretBytes, + ) -> Result<(), ironstorage::presentation::ClipboardError> { + self.0 = value.expose().to_vec(); + Ok(()) + } + + fn clear(&mut self) -> Result<(), ironstorage::presentation::ClipboardError> { + self.0.clear(); + Ok(()) + } + } + + #[test] + fn clipboard_worker_reports_the_deadline_used_by_its_cleanup_wait() { + let presentation = crate::app::ClipboardPresentationId(7); + let timeout = ironstorage::presentation::ClipboardTimeout::new(Duration::from_secs(3)) + .expect("timeout"); + let mut started = None; + let mut waited_until = None; + let payload = run_clipboard_presentation( + Ok(ironstorage::presentation::ClipboardManager::new( + MemoryClipboard(b"previous".to_vec()), + timeout, + )), + presentation, + SecretBytes::new(b"copied".to_vec()), + |reported, deadline| started = Some((reported, deadline)), + |deadline| { + waited_until = Some(deadline); + ironstorage::presentation::ClipboardWait::Elapsed + }, + ); + + let (reported, deadline) = started.expect("clipboard start"); + assert_eq!(reported, presentation); + assert_eq!(waited_until, Some(deadline)); + assert!(matches!( + payload, + AsyncPayload::ClipboardFinished { + presentation: finished, + result: Ok(ironstorage::presentation::ClipboardDisposition::RestoredPrevious), + } if finished == presentation + )); } #[test] diff --git a/apps/tui/src/runtime.rs b/apps/tui/src/runtime.rs index 8e2cf79..17acc68 100644 --- a/apps/tui/src/runtime.rs +++ b/apps/tui/src/runtime.rs @@ -227,6 +227,22 @@ impl AsyncExecutor { } } + pub fn clipboard_started_reporter( + &self, + token: RequestToken, + ) -> impl Fn(crate::app::ClipboardPresentationId, std::time::Instant) + Send + 'static { + let sender = self.sender.clone(); + move |presentation, deadline| { + let _ignored = sender.send(AsyncResult { + token, + payload: Ok(AsyncPayload::ClipboardStarted { + presentation, + deadline, + }), + }); + } + } + pub fn drain(&self) -> impl Iterator + '_ { self.receiver.try_iter() } diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index a35fa36..8cc4cb7 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -74,9 +74,7 @@ pub fn draw_with_color_capability(frame: &mut Frame, app: &App, capability: Colo draw_inner(frame, app); if capability == ColorCapability::Monochrome { for cell in &mut frame.buffer_mut().content { - if (cell.fg == SELECTED_FOREGROUND && cell.bg == SELECTED_BACKGROUND) - || (cell.fg == Color::White && cell.bg == Color::Blue) - { + if cell.fg == SELECTED_FOREGROUND && cell.bg == SELECTED_BACKGROUND { cell.modifier.insert(Modifier::REVERSED); } cell.set_fg(Color::Reset).set_bg(Color::Reset); @@ -551,9 +549,7 @@ fn countdown_bar(remaining: u64, period: u64, width: usize) -> String { fn pane_block(title: &'static str, focused: bool) -> Block<'static> { let style = if focused { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) + selected_style() } else { Style::default() }; @@ -563,11 +559,15 @@ fn pane_block(title: &'static str, focused: bool) -> Block<'static> { .border_style(style) } -fn selected_line<'a>(mut spans: Vec>) -> Line<'a> { - let style = Style::default() +fn selected_style() -> Style { + Style::default() .fg(SELECTED_FOREGROUND) .bg(SELECTED_BACKGROUND) - .add_modifier(Modifier::BOLD); + .add_modifier(Modifier::BOLD) +} + +fn selected_line<'a>(mut spans: Vec>) -> Line<'a> { + let style = selected_style(); for span in &mut spans { span.style = style; } @@ -600,21 +600,18 @@ fn sidebar_lines(app: &App) -> Vec> { if row.indicators.has_conflict() { indicators.push_str(" !"); } - let style = if selected == Some(&row.id) { - Style::default().bg(Color::Blue).fg(Color::White) + let spans = vec![Span::raw(format!( + "{}{} {}{}", + " ".repeat(row.depth), + marker, + row.name, + indicators + ))]; + if selected == Some(&row.id) { + selected_line(spans) } else { - Style::default() - }; - Line::styled( - format!( - "{}{} {}{}", - " ".repeat(row.depth), - marker, - row.name, - indicators - ), - style, - ) + Line::from(spans) + } }) .collect() } @@ -663,12 +660,7 @@ fn viewer_lines<'a>( }, str::to_owned, ); - let value = if metadata.sensitivity() - == ironstorage::document::EntrySensitivity::Sensitive - && !viewer.is_revealed(field.id()) - { - Span::styled("••••••••", Style::default().fg(Color::DarkGray)) - } else if field.value().is_empty() { + let value = if field.value().is_empty() { Span::styled("(empty)", Style::default().fg(Color::DarkGray)) } else { match std::str::from_utf8(field.value()) { @@ -757,27 +749,13 @@ fn editor_lines(editor: &EntryEditor) -> Vec> { || format!("{:?}", field.metadata().kind()), |name| format!("{:?} ({name})", field.metadata().kind()), ); - let masked = field.metadata().sensitivity() - == ironstorage::document::EntrySensitivity::Sensitive - && !editor.is_revealed(field.id()); let mut spans = vec![Span::styled( format!("#{:02} {label}: ", index + 1), Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), )]; - if masked { - spans.push(Span::styled( - "••••••••", - Style::default().fg(Color::DarkGray), - )); - if selected && editor.is_input_active() { - spans.push(Span::styled( - " [hidden input]", - Style::default().fg(Color::Yellow), - )); - } - } else if let Ok(value) = std::str::from_utf8(contents) { + if let Ok(value) = std::str::from_utf8(contents) { if selected && editor.is_input_active() && value.is_char_boundary(editor.cursor()) { let (before, after) = value.split_at(editor.cursor()); spans.push(Span::raw(before)); @@ -813,16 +791,20 @@ fn grep_lines(view: &crate::search::GrepView) -> Vec> { let mut lines = Vec::new(); for (index, entry) in view.entries().iter().enumerate() { let selected = view.selected_index() == Some(index); - lines.push(Line::styled( - format!("{} {}", if selected { ">" } else { " " }, entry.path()), - if selected { - Style::default().bg(Color::Blue).fg(Color::White) - } else { + let entry_line = vec![Span::raw(format!( + "{} {}", + if selected { ">" } else { " " }, + entry.path() + ))]; + lines.push(if selected { + selected_line(entry_line) + } else { + Line::from(entry_line).style( Style::default() .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - }, - )); + .add_modifier(Modifier::BOLD), + ) + }); for matched in entry.lines() { let contents = std::str::from_utf8(matched.contents().expose()) .unwrap_or("[non-UTF-8 matched line]"); @@ -831,7 +813,12 @@ fn grep_lines(view: &crate::search::GrepView) -> Vec> { } else { String::new() }; - lines.push(Line::raw(format!(" {prefix}{contents}"))); + let matched = vec![Span::raw(format!(" {prefix}{contents}"))]; + lines.push(if selected { + selected_line(matched) + } else { + Line::from(matched) + }); } } lines @@ -932,6 +919,10 @@ fn mode_title(mode: Mode) -> &'static str { } fn status_line(app: &App) -> Paragraph<'_> { + Paragraph::new(status_content(app)) +} + +fn status_content(app: &App) -> Line<'_> { let busy = if app.is_busy() { " [working]" } else { "" }; let warning = app .remaining_lease() @@ -939,13 +930,18 @@ fn status_line(app: &App) -> Paragraph<'_> { .map_or_else(String::new, |remaining| { format!(" [locks in {}s]", remaining.as_secs()) }); - Paragraph::new(Line::from(vec![ - Span::styled( - " status ", - Style::default().bg(Color::Blue).fg(Color::White), - ), - Span::raw(format!(" {}{busy}{warning}", app.status())), - ])) + let clipboard = if app.clipboard_pending() { + app.clipboard_remaining_seconds().map_or_else( + || " [copying to clipboard]".to_owned(), + |remaining| format!(" [clipboard clears in {remaining}s]"), + ) + } else { + String::new() + }; + Line::from(vec![ + Span::styled(" status ", selected_style()), + Span::raw(format!(" {}{busy}{warning}{clipboard}", app.status())), + ]) } fn context_line(app: &App) -> Paragraph<'static> { @@ -991,7 +987,9 @@ mod tests { use super::*; use crate::app::Transition; use crate::sidebar::TestTreeNode; - use crate::viewer::test_support::{fixture_document, fixture_document_from_plaintext}; + use crate::viewer::test_support::{ + fixture_document, fixture_document_from_plaintext, fixture_grep, + }; fn render(width: u16, height: u16, app: &App) -> String { let backend = TestBackend::new(width, height); @@ -1365,26 +1363,60 @@ mod tests { } #[test] - fn viewer_masks_storage_sensitive_fields_and_renders_dynamic_unicode_fields() { + fn status_renders_the_active_clipboard_deadline() { + let mut app = App::new(); + app.open_test_document("email/personal", fixture_document("email/personal")); + let presentation = match app.dispatch(crate::action::Action::Copy) { + crate::app::AppEffect::CopyFocused { presentation, .. } => presentation, + effect => panic!("unexpected effect: {effect:?}"), + }; + let token = app.begin_request(); + app.apply_result(crate::app::AsyncResult { + token, + payload: Ok(crate::app::AsyncPayload::ClipboardStarted { + presentation, + deadline: std::time::Instant::now() + std::time::Duration::from_secs(45), + }), + }); + + let output = render(100, 20, &app); + assert!(output.contains("Secret copied")); + assert!(output.contains("clipboard clears in 45s")); + } + + #[test] + fn viewer_renders_storage_sensitive_fields_and_dynamic_unicode_fields() { let mut app = App::new(); app.open_test_document("unicode/咖啡", fixture_document("unicode/咖啡")); for width in [60, 100, 140] { let output = render(width, 20, &app); - assert!(output.contains("password: ••••••••")); + assert!(output.contains("password: pässwörd-猫")); assert!(output.contains("login:")); assert!(output.contains('用')); assert!(output.contains("@example.test")); - assert!(output.contains("notes: ••••••••")); - assert!(!output.contains("pässwörd-猫")); + assert!(output.contains("notes:")); } assert!(!format!("{app:?}").contains("pässwörd-猫")); } #[test] - fn authenticated_viewer_renders_ordinary_metadata_and_rehides_secrets() { + fn authenticated_viewer_renders_every_value_until_entry_close_or_relock() { let (_store, document) = fixture_document_from_plaintext( "metadata/account", - b"vault-secret\nautoType_enabled: true\nicon: Internet\nicon: Custom Icon\ntitle: Caf\xc3\xa9\ncustom: hidden-value\n", + concat!( + "vault-secret\n", + "autoType_enabled: true\n", + "icon: Internet\n", + "icon: Custom Icon\n", + "title: Café\n", + "custom: hidden-value\n", + "free-form note value\n", + "empty:\n", + "unicode: 密碼-猫\n", + "otp: otpauth://totp/IronStorage:alice?secret=JBSWY3DPEHPK3PXP&issuer=IronStorage\n", + "long: abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz\n", + ) + .as_bytes(), ); let mut app = App::new(); app.sidebar_mut().replace_test_tree(vec![TestTreeNode { @@ -1411,49 +1443,56 @@ mod tests { crate::app::ResultDisposition::Applied ); - let unlocked = render(140, 20, &app); - assert!(unlocked.contains("autoType_enabled: true")); - assert!(unlocked.contains("icon: Internet")); - assert!(unlocked.contains("icon: Custom Icon")); - assert!(unlocked.contains("title: Café")); - assert!(unlocked.contains("password: ••••••••")); - assert!(unlocked.contains("custom: ••••••••")); - assert!(!unlocked.contains("vault-secret")); - assert!(!unlocked.contains("hidden-value")); + let all_values = viewer_lines(app.viewer().expect("viewer"), None) + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + for expected in [ + "vault-secret", + "autoType_enabled: true", + "icon: Internet", + "icon: Custom Icon", + "title: Café", + "custom: hidden-value", + "free-form note value", + "empty: (empty)", + "unicode: 密碼-猫", + "JBSWY3DPEHPK3PXP", + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", + ] { + assert!(all_values.contains(expected), "missing {expected}"); + } - app.dispatch(crate::action::Action::Reveal); - assert!(render(140, 20, &app).contains("vault-secret")); + assert!(render(140, 30, &app).contains("vault-secret")); app.dispatch(crate::action::Action::FocusNext); - assert!(!render(140, 20, &app).contains("vault-secret")); + assert!(render(140, 30, &app).contains("vault-secret")); + app.dispatch(crate::action::Action::Help); + app.dispatch(crate::action::Action::Cancel); + assert!(render(140, 30, &app).contains("vault-secret")); - app.dispatch(crate::action::Action::FocusPrevious); - app.dispatch(crate::action::Action::Reveal); + app.dispatch(crate::action::Action::CloseEntry); + assert!(!render(140, 30, &app).contains("vault-secret")); + + app.open_test_document( + "metadata/account", + fixture_document_from_plaintext("metadata/account-locked", b"lock-secret\n").1, + ); app.forced_relock("Authentication expired"); let locked = render(140, 20, &app); - assert!(!locked.contains("vault-secret")); + assert!(!locked.contains("lock-secret")); assert!(!locked.contains("autoType_enabled: true")); } #[test] - fn reveal_is_explicit_and_focus_change_or_lock_removes_secret_from_rendering() { + fn focus_changes_keep_values_visible_and_lock_removes_them() { let mut app = App::new(); app.open_test_document("email/personal", fixture_document("email/personal")); - app.dispatch(crate::action::Action::Reveal); - let revealed = render(100, 20, &app); - assert!(revealed.contains("correct horse fixture")); + assert!(render(100, 20, &app).contains("correct horse fixture")); app.dispatch(crate::action::Action::FocusNext); - let hidden = render(100, 20, &app); - assert!(!hidden.contains("correct horse fixture")); + assert!(render(100, 20, &app).contains("correct horse fixture")); - app.dispatch(crate::action::Action::FocusPrevious); - app.dispatch(crate::action::Action::Reveal); - app.dispatch(crate::action::Action::Help); - app.dispatch(crate::action::Action::Cancel); - let after_overlay = render(100, 20, &app); - assert!(!after_overlay.contains("correct horse fixture")); - - app.dispatch(crate::action::Action::Reveal); app.forced_relock("test lock"); let locked = render(100, 20, &app); assert!(!locked.contains("correct horse fixture")); @@ -1461,7 +1500,7 @@ mod tests { } #[test] - fn otp_metadata_is_visible_while_the_secret_uri_stays_masked() { + fn otp_metadata_and_authenticated_uri_are_visible() { let mut app = App::new(); app.open_test_document("otp/totp", fixture_document("otp/totp")); app.dispatch(crate::action::Action::FocusNext); @@ -1470,11 +1509,11 @@ mod tests { assert!(output.contains("IronStorage")); assert!(output.contains("alice@example.test")); assert!(output.contains("period 30s")); - assert!(!output.contains("JBSWY3DPEHPK3PXP")); + assert!(output.contains("JBSWY3DPEHPK3PXP")); } #[test] - fn otp_code_qr_resize_and_lock_lifecycle_are_secret_safe() { + fn otp_code_qr_resize_and_lock_lifecycle_clear_after_lock() { let mut app = App::new(); let document = fixture_document("otp/totp"); let wrong_field = document.fields()[0].id(); @@ -1528,7 +1567,7 @@ mod tests { assert!(large.contains("TOTP code — 12s remaining")); assert!(large.contains("222 333")); assert!(!large.contains("code 123456")); - assert!(!large.contains("JBSWY3DPEHPK3PXP")); + assert!(large.contains("JBSWY3DPEHPK3PXP")); assert_eq!(app.viewer().expect("viewer").scroll(), 0); let full_bar = large.matches('█').count(); @@ -1576,7 +1615,8 @@ mod tests { assert!(refreshed.matches('█').count() > full_bar); let minimum = render(40, 8, &app); assert!(minimum.contains("654321 — 30s remaining")); - assert!(minimum.contains("password: ••••••••")); + assert!(minimum.contains("password:")); + assert!(!minimum.contains("••••••••")); assert_eq!(app.viewer().expect("viewer").scroll(), 0); let payload = ironstorage::repository::SecretBytes::new( @@ -1607,7 +1647,7 @@ mod tests { } #[test] - fn selected_viewer_spans_override_nested_colors_for_readable_contrast() { + fn selected_and_active_surfaces_use_one_high_contrast_style() { let mut app = App::new(); let document = fixture_document("otp/totp"); let otp_field = document @@ -1665,6 +1705,37 @@ mod tests { && span.style.add_modifier.contains(Modifier::BOLD) })); + app.sidebar_mut().replace_test_tree(vec![TestTreeNode { + path: "otp/totp".to_owned(), + name: "totp".to_owned(), + directory: false, + indicators: ironstorage::read::TreeNodeIndicators::default(), + children: vec![], + }]); + let sidebar_selected = &sidebar_lines(&app)[0]; + assert!(sidebar_selected.spans.iter().all(|span| { + span.style.fg == Some(SELECTED_FOREGROUND) + && span.style.bg == Some(SELECTED_BACKGROUND) + && span.style.add_modifier.contains(Modifier::BOLD) + })); + + let search = crate::search::GrepView::new(fixture_grep()); + let search_lines = grep_lines(&search); + let selected_search_lines = 1 + search.entries()[0].lines().len(); + assert!( + search_lines[..selected_search_lines] + .iter() + .flat_map(|line| &line.spans) + .all(|span| { + span.style.fg == Some(SELECTED_FOREGROUND) + && span.style.bg == Some(SELECTED_BACKGROUND) + && span.style.add_modifier.contains(Modifier::BOLD) + }) + ); + + let status = status_content(&app); + assert_eq!(status.spans[0].style, selected_style()); + let backend = TestBackend::new(60, 16); let mut terminal = Terminal::new(backend).expect("test terminal"); terminal @@ -1706,7 +1777,7 @@ mod tests { } #[test] - fn editor_keeps_sensitive_input_masked_until_explicit_reveal() { + fn editor_shows_sensitive_input_while_the_entry_is_unlocked() { let mut app = App::new(); app.open_test_document("email/personal", fixture_document("email/personal")); app.dispatch(crate::action::Action::EditEntry); @@ -1714,15 +1785,11 @@ mod tests { app.handle_editor_input(crossterm::event::KeyCode::Char('x')); for width in [60, 100, 140] { - let hidden = render(width, 20, &app); - assert!(hidden.contains("hidden input")); - assert!(!hidden.contains("correct horse fixturex")); + let visible = render(width, 20, &app); + assert!(visible.contains("correct horse fixturex")); + assert!(!visible.contains("••••••••")); } - app.handle_editor_input(crossterm::event::KeyCode::Esc); - app.dispatch(crate::action::Action::Reveal); - let revealed = render(100, 20, &app); - assert!(revealed.contains("correct horse fixturex")); assert!(!format!("{app:?}").contains("correct horse fixturex")); } diff --git a/apps/tui/src/viewer.rs b/apps/tui/src/viewer.rs index 5c870cd..343f95c 100644 --- a/apps/tui/src/viewer.rs +++ b/apps/tui/src/viewer.rs @@ -3,18 +3,17 @@ use std::{cell::Cell, fmt}; use ironstorage::{ - document::{DocumentError, EntryDocument, EntryField, EntryFieldId, EntrySensitivity}, + document::{DocumentError, EntryDocument, EntryField}, repository::SecretBytes, }; -/// Focus, masking, and scrolling state for an authenticated document. +/// Focus and scrolling state for an authenticated document. /// /// The document remains the source of field order, labels, kinds, sensitivity, /// and values. This type only tracks transient presentation choices. pub struct EntryViewer { document: EntryDocument, focused: usize, - revealed: Option, scroll: Cell, } @@ -23,7 +22,6 @@ impl EntryViewer { Self { document, focused: 0, - revealed: None, scroll: Cell::new(0), } } @@ -44,19 +42,13 @@ impl EntryViewer { self.document.fields().get(self.focused) } - pub fn is_revealed(&self, id: EntryFieldId) -> bool { - self.revealed == Some(id) - } - pub fn focus_next(&mut self) { - self.hide_revealed(); if !self.document.fields().is_empty() { self.focused = (self.focused + 1) % self.document.fields().len(); } } pub fn focus_previous(&mut self) { - self.hide_revealed(); if !self.document.fields().is_empty() { self.focused = self .focused @@ -65,21 +57,6 @@ impl EntryViewer { } } - pub fn reveal_focused(&mut self) -> bool { - let Some(field) = self.focused_field() else { - return false; - }; - if field.metadata().sensitivity() != EntrySensitivity::Sensitive { - return false; - } - self.revealed = Some(field.id()); - true - } - - pub fn hide_revealed(&mut self) -> bool { - self.revealed.take().is_some() - } - pub fn copy_focused(&self) -> Result { let field = self.focused_field().ok_or(DocumentError::InvalidIndex { index: self.focused, @@ -147,7 +124,6 @@ impl fmt::Debug for EntryViewer { .debug_struct("EntryViewer") .field("document", &self.document) .field("focused", &self.focused) - .field("revealed", &self.revealed.map(|_| "[REDACTED]")) .field("scroll", &self.scroll.get()) .finish() } @@ -158,8 +134,10 @@ pub(crate) mod test_support { use std::path::{Path, PathBuf}; use ironstorage::{ + command::GrepRequest, crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, document::{EntryDocument, EntryDocumentService}, + read::{GrepResults, VaultReader}, repository::{EntryPath, Repository, SecretBytes}, }; use tempfile::TempDir; @@ -183,6 +161,25 @@ pub(crate) mod test_support { fixture_document_from(&root.join("stores/basic"), entry) } + pub(crate) fn fixture_grep() -> GrepResults { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../crates/storage/tests/fixtures/compatibility"); + let repository = Repository::open(root.join("stores/basic")).expect("fixture repository"); + let keys = KeyStore::load(root.join("keys")).expect("fixture keys"); + VaultReader::new(&repository, &keys) + .grep( + &GrepRequest { + pattern: "alice".to_owned(), + ignore_case: false, + invert_match: false, + line_number: true, + fixed_strings: true, + }, + &mut FixtureSecrets, + ) + .expect("fixture grep") + } + pub(crate) fn fixture_document_from(store: &Path, entry: &str) -> EntryDocument { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); @@ -224,14 +221,9 @@ mod tests { use super::{test_support::fixture_document, *}; #[test] - fn focus_wraps_and_always_hides_a_revealed_secret() { + fn focus_wraps_without_changing_the_authenticated_document() { let mut viewer = EntryViewer::new(fixture_document("email/personal")); - assert!(viewer.reveal_focused()); - let password = viewer.focused_field().expect("password").id(); - assert!(viewer.is_revealed(password)); - viewer.focus_next(); - assert!(!viewer.is_revealed(password)); assert_eq!(viewer.focused_index(), Some(1)); viewer.focus_previous(); assert_eq!(viewer.focused_index(), Some(0)); @@ -247,10 +239,9 @@ mod tests { } #[test] - fn ordinary_fields_do_not_gain_reveal_state_and_scroll_saturates() { + fn scrolling_saturates() { let mut viewer = EntryViewer::new(fixture_document("unicode/咖啡")); viewer.focus_next(); - assert!(!viewer.reveal_focused()); viewer.scroll_down(usize::MAX); let end = viewer.scroll(); viewer.scroll_down(1); diff --git a/apps/tui/src/workflow.rs b/apps/tui/src/workflow.rs index 88954e9..360e457 100644 --- a/apps/tui/src/workflow.rs +++ b/apps/tui/src/workflow.rs @@ -909,7 +909,7 @@ fn yes_no(value: bool) -> &'static str { fn generated_presentation_label(presentation: GeneratedPresentation) -> &'static str { match presentation { - GeneratedPresentation::Terminal => "masked viewer", + GeneratedPresentation::Terminal => "unlocked entry viewer", GeneratedPresentation::Clipboard => "clipboard with timeout", GeneratedPresentation::QrCode => "terminal QR", }