Fix unlocked TUI visibility and clipboard feedback
This commit is contained in:
@@ -27,8 +27,6 @@ pub enum Action {
|
|||||||
PreviousMatch,
|
PreviousMatch,
|
||||||
FocusNext,
|
FocusNext,
|
||||||
FocusPrevious,
|
FocusPrevious,
|
||||||
Reveal,
|
|
||||||
Hide,
|
|
||||||
Copy,
|
Copy,
|
||||||
ScrollDown,
|
ScrollDown,
|
||||||
ScrollUp,
|
ScrollUp,
|
||||||
@@ -374,20 +372,6 @@ pub static ACTIONS: &[ActionSpec] = &[
|
|||||||
bindings: keys!((KeyCode::BackTab, KeyModifiers::SHIFT, "S-Tab")),
|
bindings: keys!((KeyCode::BackTab, KeyModifiers::SHIFT, "S-Tab")),
|
||||||
modes: UNLOCKED,
|
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 {
|
ActionSpec {
|
||||||
action: Action::Copy,
|
action: Action::Copy,
|
||||||
label: "copy field",
|
label: "copy field",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Pure UI state machine. Storage behavior is represented only by typed results.
|
//! Pure UI state machine. Storage behavior is represented only by typed results.
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::{collections::BTreeSet, time::Instant};
|
||||||
|
|
||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
command::{
|
command::{
|
||||||
@@ -12,7 +12,7 @@ use ironstorage::{
|
|||||||
document::{DocumentError, EntryDocument, EntryFieldId},
|
document::{DocumentError, EntryDocument, EntryFieldId},
|
||||||
git::{GitConflict, GitProgressPhase, GitSnapshot},
|
git::{GitConflict, GitProgressPhase, GitSnapshot},
|
||||||
otp::OtpCodeValidity,
|
otp::OtpCodeValidity,
|
||||||
presentation::{ClipboardDisposition, QrMatrix},
|
presentation::{ClipboardDisposition, ClipboardError, QrMatrix},
|
||||||
read::{FindResults, GrepResults, TreeModel},
|
read::{FindResults, GrepResults, TreeModel},
|
||||||
repository::SecretBytes,
|
repository::SecretBytes,
|
||||||
write::WriteOutcome,
|
write::WriteOutcome,
|
||||||
@@ -64,6 +64,15 @@ pub struct RequestToken {
|
|||||||
generation: u64,
|
generation: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ClipboardPresentationId(pub(crate) u64);
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct ClipboardPresentation {
|
||||||
|
id: ClipboardPresentationId,
|
||||||
|
deadline: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct StartupData {
|
pub struct StartupData {
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
@@ -188,7 +197,14 @@ pub enum AsyncPayload {
|
|||||||
entry: String,
|
entry: String,
|
||||||
document: Box<EntryDocument>,
|
document: Box<EntryDocument>,
|
||||||
},
|
},
|
||||||
ClipboardFinished(ClipboardDisposition),
|
ClipboardStarted {
|
||||||
|
presentation: ClipboardPresentationId,
|
||||||
|
deadline: Instant,
|
||||||
|
},
|
||||||
|
ClipboardFinished {
|
||||||
|
presentation: ClipboardPresentationId,
|
||||||
|
result: Result<ClipboardDisposition, ClipboardError>,
|
||||||
|
},
|
||||||
GeneratedField {
|
GeneratedField {
|
||||||
target: EntryFieldId,
|
target: EntryFieldId,
|
||||||
password: SecretBytes,
|
password: SecretBytes,
|
||||||
@@ -273,7 +289,10 @@ pub enum AppEffect {
|
|||||||
None,
|
None,
|
||||||
RefreshTree,
|
RefreshTree,
|
||||||
AuthenticateEntry(String),
|
AuthenticateEntry(String),
|
||||||
CopyFocused(SecretBytes),
|
CopyFocused {
|
||||||
|
presentation: ClipboardPresentationId,
|
||||||
|
value: SecretBytes,
|
||||||
|
},
|
||||||
GenerateField(EntryFieldId),
|
GenerateField(EntryFieldId),
|
||||||
SaveDocument {
|
SaveDocument {
|
||||||
config: Box<Config>,
|
config: Box<Config>,
|
||||||
@@ -326,6 +345,8 @@ pub struct App {
|
|||||||
otp_pending: bool,
|
otp_pending: bool,
|
||||||
hotp_confirmation: Option<OtpUiRequest>,
|
hotp_confirmation: Option<OtpUiRequest>,
|
||||||
clipboard_request: Option<SecretBytes>,
|
clipboard_request: Option<SecretBytes>,
|
||||||
|
clipboard_presentation: Option<ClipboardPresentation>,
|
||||||
|
next_clipboard_presentation: u64,
|
||||||
remaining_lease: Option<std::time::Duration>,
|
remaining_lease: Option<std::time::Duration>,
|
||||||
terminal_size: (u16, u16),
|
terminal_size: (u16, u16),
|
||||||
ticks: u64,
|
ticks: u64,
|
||||||
@@ -372,6 +393,8 @@ impl App {
|
|||||||
otp_pending: false,
|
otp_pending: false,
|
||||||
hotp_confirmation: None,
|
hotp_confirmation: None,
|
||||||
clipboard_request: None,
|
clipboard_request: None,
|
||||||
|
clipboard_presentation: None,
|
||||||
|
next_clipboard_presentation: 0,
|
||||||
remaining_lease: None,
|
remaining_lease: None,
|
||||||
terminal_size: (0, 0),
|
terminal_size: (0, 0),
|
||||||
ticks: 0,
|
ticks: 0,
|
||||||
@@ -489,8 +512,23 @@ impl App {
|
|||||||
pub fn hotp_confirmation(&self) -> bool {
|
pub fn hotp_confirmation(&self) -> bool {
|
||||||
self.hotp_confirmation.is_some()
|
self.hotp_confirmation.is_some()
|
||||||
}
|
}
|
||||||
pub fn take_clipboard_request(&mut self) -> Option<SecretBytes> {
|
pub fn take_clipboard_effect(&mut self) -> Option<AppEffect> {
|
||||||
self.clipboard_request.take()
|
let value = self.clipboard_request.take()?;
|
||||||
|
Some(self.begin_clipboard_presentation(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clipboard_remaining_seconds(&self) -> Option<u64> {
|
||||||
|
self.clipboard_remaining_seconds_at(Instant::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clipboard_remaining_seconds_at(&self, now: Instant) -> Option<u64> {
|
||||||
|
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) {
|
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) {
|
if result.token.generation != self.generation || !self.pending.contains(&result.token.id) {
|
||||||
return ResultDisposition::Stale;
|
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);
|
self.pending.remove(&result.token.id);
|
||||||
}
|
}
|
||||||
match result.payload {
|
match result.payload {
|
||||||
@@ -645,18 +686,39 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.focus = PaneFocus::Main;
|
self.focus = PaneFocus::Main;
|
||||||
}
|
}
|
||||||
Ok(AsyncPayload::ClipboardFinished(disposition)) => {
|
Ok(AsyncPayload::ClipboardStarted {
|
||||||
if self.mode == Mode::Locked {
|
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;
|
return ResultDisposition::Applied;
|
||||||
}
|
}
|
||||||
self.status = match disposition {
|
self.clipboard_presentation = None;
|
||||||
ClipboardDisposition::RestoredPrevious => {
|
self.status = match result {
|
||||||
|
Ok(ClipboardDisposition::RestoredPrevious) => {
|
||||||
"Clipboard restored to its previous value".to_owned()
|
"Clipboard restored to its previous value".to_owned()
|
||||||
}
|
}
|
||||||
ClipboardDisposition::Cleared => "Clipboard secret cleared".to_owned(),
|
Ok(ClipboardDisposition::Cleared) => "Clipboard secret cleared".to_owned(),
|
||||||
ClipboardDisposition::PreservedNewer => {
|
Ok(ClipboardDisposition::PreservedNewer) => {
|
||||||
"Clipboard changed; the newer value was preserved".to_owned()
|
"Clipboard changed; the newer value was preserved".to_owned()
|
||||||
}
|
}
|
||||||
|
Err(error) => format!("Clipboard presentation failed: {error}"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Ok(AsyncPayload::GeneratedField { target, password }) => {
|
Ok(AsyncPayload::GeneratedField { target, password }) => {
|
||||||
@@ -1046,44 +1108,11 @@ impl App {
|
|||||||
PaneFocus::Main => PaneFocus::Sidebar,
|
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 => {
|
Action::Copy => {
|
||||||
if let Some(viewer) = self.viewer.as_ref() {
|
if let Some(viewer) = self.viewer.as_ref() {
|
||||||
match viewer.copy_focused() {
|
match viewer.copy_focused() {
|
||||||
Ok(value) => {
|
Ok(value) => {
|
||||||
self.status = self.config.as_ref().map_or_else(
|
return self.begin_clipboard_presentation(value);
|
||||||
|| "Copying focused field…".to_owned(),
|
|
||||||
|config| {
|
|
||||||
format!(
|
|
||||||
"Copied focused field; cleanup in {}s",
|
|
||||||
config.clipboard_timeout().duration().as_secs()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return AppEffect::CopyFocused(value);
|
|
||||||
}
|
}
|
||||||
Err(error) => self.status = error.to_string(),
|
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<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))) => {
|
||||||
@@ -1935,6 +1978,7 @@ impl App {
|
|||||||
self.qr_popup = None;
|
self.qr_popup = None;
|
||||||
self.uri_popup = None;
|
self.uri_popup = None;
|
||||||
self.clipboard_request = None;
|
self.clipboard_request = None;
|
||||||
|
self.clipboard_presentation = None;
|
||||||
self.authentication_pending = None;
|
self.authentication_pending = None;
|
||||||
self.selected_entry = None;
|
self.selected_entry = None;
|
||||||
self.viewer = None;
|
self.viewer = None;
|
||||||
@@ -1968,6 +2012,7 @@ impl App {
|
|||||||
self.qr_popup = None;
|
self.qr_popup = None;
|
||||||
self.uri_popup = None;
|
self.uri_popup = None;
|
||||||
self.clipboard_request = None;
|
self.clipboard_request = None;
|
||||||
|
self.clipboard_presentation = None;
|
||||||
self.remaining_lease = None;
|
self.remaining_lease = None;
|
||||||
self.status = if discarded_edit {
|
self.status = if discarded_edit {
|
||||||
format!("Locked: {reason}; unsaved edits were discarded")
|
format!("Locked: {reason}; unsaved edits were discarded")
|
||||||
@@ -2012,11 +2057,7 @@ impl App {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if matches!(destination, Mode::Dialog | Mode::Help | Mode::Command) {
|
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() {
|
if let Some(editor) = self.editor.as_mut() {
|
||||||
editor.hide_revealed();
|
|
||||||
editor.end_input();
|
editor.end_input();
|
||||||
}
|
}
|
||||||
self.suspended_mode = Some(current);
|
self.suspended_mode = Some(current);
|
||||||
@@ -2050,6 +2091,7 @@ impl App {
|
|||||||
self.qr_popup = None;
|
self.qr_popup = None;
|
||||||
self.uri_popup = None;
|
self.uri_popup = None;
|
||||||
self.clipboard_request = None;
|
self.clipboard_request = None;
|
||||||
|
self.clipboard_presentation = None;
|
||||||
self.status = "Locked".to_owned();
|
self.status = "Locked".to_owned();
|
||||||
} else if current == Mode::Locked {
|
} else if current == Mode::Locked {
|
||||||
self.status = "Authentication required".to_owned();
|
self.status = "Authentication required".to_owned();
|
||||||
@@ -2220,6 +2262,104 @@ mod tests {
|
|||||||
assert_eq!(app.focus(), PaneFocus::Main);
|
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]
|
#[test]
|
||||||
fn authentication_must_match_the_pending_entry_before_viewer_transition() {
|
fn authentication_must_match_the_pending_entry_before_viewer_transition() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ pub struct EntryEditor {
|
|||||||
buffer: SecretBytes,
|
buffer: SecretBytes,
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
input_active: bool,
|
input_active: bool,
|
||||||
revealed: bool,
|
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +37,6 @@ impl EntryEditor {
|
|||||||
buffer,
|
buffer,
|
||||||
cursor,
|
cursor,
|
||||||
input_active: false,
|
input_active: false,
|
||||||
revealed: false,
|
|
||||||
dirty: false,
|
dirty: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,27 +75,6 @@ impl EntryEditor {
|
|||||||
|
|
||||||
pub fn end_input(&mut self) {
|
pub fn end_input(&mut self) {
|
||||||
self.input_active = false;
|
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 {
|
pub fn is_dirty(&self) -> bool {
|
||||||
@@ -106,7 +83,6 @@ impl EntryEditor {
|
|||||||
|
|
||||||
pub fn focus_next(&mut self) -> Result<(), DocumentError> {
|
pub fn focus_next(&mut self) -> Result<(), DocumentError> {
|
||||||
self.flush()?;
|
self.flush()?;
|
||||||
self.hide_revealed();
|
|
||||||
if !self.document.fields().is_empty() {
|
if !self.document.fields().is_empty() {
|
||||||
self.focused = (self.focused + 1) % self.document.fields().len();
|
self.focused = (self.focused + 1) % self.document.fields().len();
|
||||||
self.load_focused();
|
self.load_focused();
|
||||||
@@ -116,7 +92,6 @@ impl EntryEditor {
|
|||||||
|
|
||||||
pub fn focus_previous(&mut self) -> Result<(), DocumentError> {
|
pub fn focus_previous(&mut self) -> Result<(), DocumentError> {
|
||||||
self.flush()?;
|
self.flush()?;
|
||||||
self.hide_revealed();
|
|
||||||
if !self.document.fields().is_empty() {
|
if !self.document.fields().is_empty() {
|
||||||
self.focused = self
|
self.focused = self
|
||||||
.focused
|
.focused
|
||||||
@@ -209,7 +184,6 @@ impl EntryEditor {
|
|||||||
{
|
{
|
||||||
self.load_focused();
|
self.load_focused();
|
||||||
}
|
}
|
||||||
self.revealed = false;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +293,6 @@ impl EntryEditor {
|
|||||||
|field| SecretBytes::new(field.contents().expose().to_vec()),
|
|field| SecretBytes::new(field.contents().expose().to_vec()),
|
||||||
);
|
);
|
||||||
self.cursor = self.buffer.expose().len();
|
self.cursor = self.buffer.expose().len();
|
||||||
self.revealed = false;
|
|
||||||
if self.focused_field().is_none() {
|
if self.focused_field().is_none() {
|
||||||
self.input_active = false;
|
self.input_active = false;
|
||||||
}
|
}
|
||||||
@@ -348,7 +321,6 @@ impl fmt::Debug for EntryEditor {
|
|||||||
.field("buffer", &"[REDACTED]")
|
.field("buffer", &"[REDACTED]")
|
||||||
.field("cursor", &self.cursor)
|
.field("cursor", &self.cursor)
|
||||||
.field("input_active", &self.input_active)
|
.field("input_active", &self.input_active)
|
||||||
.field("revealed", &self.revealed)
|
|
||||||
.field("dirty", &self.dirty)
|
.field("dirty", &self.dirty)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
@@ -386,15 +358,12 @@ mod tests {
|
|||||||
use crate::viewer::test_support::fixture_document;
|
use crate::viewer::test_support::fixture_document;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unicode_input_focus_and_masking_are_predictable() {
|
fn unicode_input_and_focus_are_predictable() {
|
||||||
let mut editor = EntryEditor::new(fixture_document("unicode/咖啡"));
|
let mut editor = EntryEditor::new(fixture_document("unicode/咖啡"));
|
||||||
assert!(editor.reveal_focused());
|
|
||||||
editor.begin_input();
|
editor.begin_input();
|
||||||
editor.move_cursor_end();
|
editor.move_cursor_end();
|
||||||
editor.insert_character('界');
|
editor.insert_character('界');
|
||||||
assert!(editor.is_revealed(editor.focused_field().expect("field").id()));
|
|
||||||
editor.focus_next().expect("focus next");
|
editor.focus_next().expect("focus next");
|
||||||
assert!(!editor.revealed);
|
|
||||||
editor.focus_previous().expect("focus previous");
|
editor.focus_previous().expect("focus previous");
|
||||||
assert!(
|
assert!(
|
||||||
editor
|
editor
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ pub mod workflow;
|
|||||||
use std::{
|
use std::{
|
||||||
io,
|
io,
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::mpsc::{self, Sender},
|
sync::mpsc::{self, Receiver, Sender},
|
||||||
time::Duration,
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crossterm::event::{self, Event, KeyEventKind};
|
use crossterm::event::{self, Event, KeyEventKind};
|
||||||
@@ -40,26 +40,41 @@ use crate::{
|
|||||||
|
|
||||||
const TICK_INTERVAL: Duration = Duration::from_millis(250);
|
const TICK_INTERVAL: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
|
struct ClipboardCancellation {
|
||||||
|
sender: Sender<()>,
|
||||||
|
finished: Receiver<Result<(), ironstorage::presentation::ClipboardError>>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct ClipboardCancellations(Vec<Sender<()>>);
|
struct ClipboardCancellations(Option<ClipboardCancellation>);
|
||||||
|
|
||||||
impl ClipboardCancellations {
|
impl ClipboardCancellations {
|
||||||
fn register(&mut self, cancellation: Sender<()>) {
|
fn replace(
|
||||||
self.0.push(cancellation);
|
&mut self,
|
||||||
|
cancellation: Sender<()>,
|
||||||
|
finished: Receiver<Result<(), ironstorage::presentation::ClipboardError>>,
|
||||||
|
) -> Option<Receiver<Result<(), ironstorage::presentation::ClipboardError>>> {
|
||||||
|
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) {
|
fn cancel_all(&mut self) {
|
||||||
for cancellation in self.0.drain(..) {
|
if let Some(cancellation) = self.0.take() {
|
||||||
let _ignored = cancellation.send(());
|
let _ignored = cancellation.sender.send(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for ClipboardCancellations {
|
impl Drop for ClipboardCancellations {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
for cancellation in self.0.drain(..) {
|
self.cancel_all();
|
||||||
let _ignored = cancellation.send(());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,10 +98,10 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
|||||||
for result in executor.drain() {
|
for result in executor.drain() {
|
||||||
app.apply_result(result);
|
app.apply_result(result);
|
||||||
}
|
}
|
||||||
if let Some(value) = app.take_clipboard_request() {
|
if let Some(effect) = app.take_clipboard_effect() {
|
||||||
apply_app_effect(
|
apply_app_effect(
|
||||||
&mut app,
|
&mut app,
|
||||||
AppEffect::CopyFocused(value),
|
effect,
|
||||||
&mut authentication,
|
&mut authentication,
|
||||||
&executor,
|
&executor,
|
||||||
&mut git_control,
|
&mut git_control,
|
||||||
@@ -108,7 +123,14 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
|||||||
if let Some(coordinator) = authentication.as_mut()
|
if let Some(coordinator) = authentication.as_mut()
|
||||||
&& let Some(event) = coordinator.completion()
|
&& 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)?;
|
let unix_seconds = current_unix_seconds().map_err(io::Error::other)?;
|
||||||
@@ -127,6 +149,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
|||||||
coordinator,
|
coordinator,
|
||||||
&executor,
|
&executor,
|
||||||
&mut git_control,
|
&mut git_control,
|
||||||
|
&mut clipboard_cancellations,
|
||||||
event,
|
event,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -145,6 +168,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
|||||||
coordinator,
|
coordinator,
|
||||||
&executor,
|
&executor,
|
||||||
&mut git_control,
|
&mut git_control,
|
||||||
|
&mut clipboard_cancellations,
|
||||||
event,
|
event,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -218,6 +242,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
|||||||
coordinator,
|
coordinator,
|
||||||
&executor,
|
&executor,
|
||||||
&mut git_control,
|
&mut git_control,
|
||||||
|
&mut clipboard_cancellations,
|
||||||
event,
|
event,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -378,36 +403,77 @@ fn apply_app_effect(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppEffect::CopyFocused(value) => {
|
AppEffect::CopyFocused {
|
||||||
if let Some(config) = app.config().cloned() {
|
presentation,
|
||||||
app.report_status(format!(
|
value,
|
||||||
"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();
|
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 || {
|
executor.submit(token, move || {
|
||||||
let mut clipboard = ironstorage::presentation::NativeClipboardManager::system(
|
// 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(),
|
config.clipboard_timeout(),
|
||||||
)
|
),
|
||||||
.map_err(|error| error.to_string())?;
|
presentation,
|
||||||
clipboard
|
value,
|
||||||
.copy_with(&value, |duration| {
|
started,
|
||||||
match cancellation.recv_timeout(duration) {
|
move |deadline| match cancellation
|
||||||
|
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
|
||||||
|
{
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||||
ironstorage::presentation::ClipboardWait::Elapsed
|
ironstorage::presentation::ClipboardWait::Elapsed
|
||||||
}
|
}
|
||||||
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => {
|
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||||
ironstorage::presentation::ClipboardWait::Cancelled
|
ironstorage::presentation::ClipboardWait::Cancelled
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(Ok(()), Ok(())) | (Ok(()), Err(mpsc::TryRecvError::Disconnected)) => {
|
||||||
|
AsyncPayload::ClipboardFinished {
|
||||||
|
presentation,
|
||||||
|
result: Err(ironstorage::presentation::ClipboardError::Cancelled),
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
.map(AsyncPayload::ClipboardFinished)
|
};
|
||||||
.map_err(|error| error.to_string())
|
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) => {
|
AppEffect::GenerateField(target) => {
|
||||||
let token = app.begin_request();
|
let token = app.begin_request();
|
||||||
executor.submit(token, move || {
|
executor.submit(token, move || {
|
||||||
@@ -493,6 +559,34 @@ fn apply_app_effect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_clipboard_presentation<B, S, W>(
|
||||||
|
clipboard: Result<
|
||||||
|
ironstorage::presentation::ClipboardManager<B>,
|
||||||
|
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 {
|
fn is_dispatchable_key_kind(kind: KeyEventKind) -> bool {
|
||||||
matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||||
}
|
}
|
||||||
@@ -948,6 +1042,7 @@ fn apply_authentication_event(
|
|||||||
coordinator: &AuthenticationCoordinator,
|
coordinator: &AuthenticationCoordinator,
|
||||||
executor: &AsyncExecutor,
|
executor: &AsyncExecutor,
|
||||||
git_control: &mut Option<ironstorage::git::GitOperationControl>,
|
git_control: &mut Option<ironstorage::git::GitOperationControl>,
|
||||||
|
clipboard_cancellations: &mut ClipboardCancellations,
|
||||||
event: AuthenticationEvent,
|
event: AuthenticationEvent,
|
||||||
) {
|
) {
|
||||||
match event {
|
match event {
|
||||||
@@ -1033,9 +1128,19 @@ fn apply_authentication_event(
|
|||||||
app.authentication_failed(message);
|
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(
|
fn load_document(
|
||||||
config: &ironstorage::config::Config,
|
config: &ironstorage::config::Config,
|
||||||
@@ -1403,7 +1508,11 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn press_and_terminal_repeat_events_dispatch_but_release_does_not() {
|
fn press_and_terminal_repeat_events_dispatch_but_release_does_not() {
|
||||||
@@ -1455,11 +1564,30 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn terminal_ownership_loss_cancels_presentations_and_forces_relock() {
|
fn terminal_ownership_loss_cancels_presentations_and_forces_relock() {
|
||||||
let mut app = App::new();
|
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 authentication = None;
|
||||||
let mut git_control = None;
|
let mut git_control = None;
|
||||||
let mut clipboard = ClipboardCancellations::default();
|
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();
|
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(
|
handle_terminal_ownership_lost(
|
||||||
&mut app,
|
&mut app,
|
||||||
@@ -1469,10 +1597,100 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(app.mode(), crate::app::Mode::Locked);
|
assert_eq!(app.mode(), crate::app::Mode::Locked);
|
||||||
|
assert!(!app.clipboard_pending());
|
||||||
assert!(app.status().contains("terminal ownership was lost"));
|
assert!(app.status().contains("terminal ownership was lost"));
|
||||||
cancelled
|
cancelled
|
||||||
.recv_timeout(std::time::Duration::from_secs(1))
|
.recv_timeout(std::time::Duration::from_secs(1))
|
||||||
.expect("clipboard cancellation");
|
.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<u8>);
|
||||||
|
|
||||||
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -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<Item = AsyncResult> + '_ {
|
pub fn drain(&self) -> impl Iterator<Item = AsyncResult> + '_ {
|
||||||
self.receiver.try_iter()
|
self.receiver.try_iter()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,9 +74,7 @@ pub fn draw_with_color_capability(frame: &mut Frame, app: &App, capability: Colo
|
|||||||
draw_inner(frame, app);
|
draw_inner(frame, app);
|
||||||
if capability == ColorCapability::Monochrome {
|
if capability == ColorCapability::Monochrome {
|
||||||
for cell in &mut frame.buffer_mut().content {
|
for cell in &mut frame.buffer_mut().content {
|
||||||
if (cell.fg == SELECTED_FOREGROUND && cell.bg == SELECTED_BACKGROUND)
|
if cell.fg == SELECTED_FOREGROUND && cell.bg == SELECTED_BACKGROUND {
|
||||||
|| (cell.fg == Color::White && cell.bg == Color::Blue)
|
|
||||||
{
|
|
||||||
cell.modifier.insert(Modifier::REVERSED);
|
cell.modifier.insert(Modifier::REVERSED);
|
||||||
}
|
}
|
||||||
cell.set_fg(Color::Reset).set_bg(Color::Reset);
|
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> {
|
fn pane_block(title: &'static str, focused: bool) -> Block<'static> {
|
||||||
let style = if focused {
|
let style = if focused {
|
||||||
Style::default()
|
selected_style()
|
||||||
.fg(Color::Cyan)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
@@ -563,11 +559,15 @@ fn pane_block(title: &'static str, focused: bool) -> Block<'static> {
|
|||||||
.border_style(style)
|
.border_style(style)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn selected_line<'a>(mut spans: Vec<Span<'a>>) -> Line<'a> {
|
fn selected_style() -> Style {
|
||||||
let style = Style::default()
|
Style::default()
|
||||||
.fg(SELECTED_FOREGROUND)
|
.fg(SELECTED_FOREGROUND)
|
||||||
.bg(SELECTED_BACKGROUND)
|
.bg(SELECTED_BACKGROUND)
|
||||||
.add_modifier(Modifier::BOLD);
|
.add_modifier(Modifier::BOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_line<'a>(mut spans: Vec<Span<'a>>) -> Line<'a> {
|
||||||
|
let style = selected_style();
|
||||||
for span in &mut spans {
|
for span in &mut spans {
|
||||||
span.style = style;
|
span.style = style;
|
||||||
}
|
}
|
||||||
@@ -600,21 +600,18 @@ fn sidebar_lines(app: &App) -> Vec<Line<'static>> {
|
|||||||
if row.indicators.has_conflict() {
|
if row.indicators.has_conflict() {
|
||||||
indicators.push_str(" !");
|
indicators.push_str(" !");
|
||||||
}
|
}
|
||||||
let style = if selected == Some(&row.id) {
|
let spans = vec![Span::raw(format!(
|
||||||
Style::default().bg(Color::Blue).fg(Color::White)
|
|
||||||
} else {
|
|
||||||
Style::default()
|
|
||||||
};
|
|
||||||
Line::styled(
|
|
||||||
format!(
|
|
||||||
"{}{} {}{}",
|
"{}{} {}{}",
|
||||||
" ".repeat(row.depth),
|
" ".repeat(row.depth),
|
||||||
marker,
|
marker,
|
||||||
row.name,
|
row.name,
|
||||||
indicators
|
indicators
|
||||||
),
|
))];
|
||||||
style,
|
if selected == Some(&row.id) {
|
||||||
)
|
selected_line(spans)
|
||||||
|
} else {
|
||||||
|
Line::from(spans)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -663,12 +660,7 @@ fn viewer_lines<'a>(
|
|||||||
},
|
},
|
||||||
str::to_owned,
|
str::to_owned,
|
||||||
);
|
);
|
||||||
let value = if metadata.sensitivity()
|
let value = if field.value().is_empty() {
|
||||||
== ironstorage::document::EntrySensitivity::Sensitive
|
|
||||||
&& !viewer.is_revealed(field.id())
|
|
||||||
{
|
|
||||||
Span::styled("••••••••", Style::default().fg(Color::DarkGray))
|
|
||||||
} else if field.value().is_empty() {
|
|
||||||
Span::styled("(empty)", Style::default().fg(Color::DarkGray))
|
Span::styled("(empty)", Style::default().fg(Color::DarkGray))
|
||||||
} else {
|
} else {
|
||||||
match std::str::from_utf8(field.value()) {
|
match std::str::from_utf8(field.value()) {
|
||||||
@@ -757,27 +749,13 @@ fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
|
|||||||
|| format!("{:?}", field.metadata().kind()),
|
|| format!("{:?}", field.metadata().kind()),
|
||||||
|name| format!("{:?} ({name})", 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(
|
let mut spans = vec![Span::styled(
|
||||||
format!("#{:02} {label}: ", index + 1),
|
format!("#{:02} {label}: ", index + 1),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Cyan)
|
.fg(Color::Cyan)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
)];
|
)];
|
||||||
if masked {
|
if let Ok(value) = std::str::from_utf8(contents) {
|
||||||
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 selected && editor.is_input_active() && value.is_char_boundary(editor.cursor()) {
|
if selected && editor.is_input_active() && value.is_char_boundary(editor.cursor()) {
|
||||||
let (before, after) = value.split_at(editor.cursor());
|
let (before, after) = value.split_at(editor.cursor());
|
||||||
spans.push(Span::raw(before));
|
spans.push(Span::raw(before));
|
||||||
@@ -813,16 +791,20 @@ fn grep_lines(view: &crate::search::GrepView) -> Vec<Line<'_>> {
|
|||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
for (index, entry) in view.entries().iter().enumerate() {
|
for (index, entry) in view.entries().iter().enumerate() {
|
||||||
let selected = view.selected_index() == Some(index);
|
let selected = view.selected_index() == Some(index);
|
||||||
lines.push(Line::styled(
|
let entry_line = vec![Span::raw(format!(
|
||||||
format!("{} {}", if selected { ">" } else { " " }, entry.path()),
|
"{} {}",
|
||||||
if selected {
|
if selected { ">" } else { " " },
|
||||||
Style::default().bg(Color::Blue).fg(Color::White)
|
entry.path()
|
||||||
|
))];
|
||||||
|
lines.push(if selected {
|
||||||
|
selected_line(entry_line)
|
||||||
} else {
|
} else {
|
||||||
|
Line::from(entry_line).style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Cyan)
|
.fg(Color::Cyan)
|
||||||
.add_modifier(Modifier::BOLD)
|
.add_modifier(Modifier::BOLD),
|
||||||
},
|
)
|
||||||
));
|
});
|
||||||
for matched in entry.lines() {
|
for matched in entry.lines() {
|
||||||
let contents = std::str::from_utf8(matched.contents().expose())
|
let contents = std::str::from_utf8(matched.contents().expose())
|
||||||
.unwrap_or("[non-UTF-8 matched line]");
|
.unwrap_or("[non-UTF-8 matched line]");
|
||||||
@@ -831,7 +813,12 @@ fn grep_lines(view: &crate::search::GrepView) -> Vec<Line<'_>> {
|
|||||||
} else {
|
} else {
|
||||||
String::new()
|
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
|
lines
|
||||||
@@ -932,6 +919,10 @@ fn mode_title(mode: Mode) -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn status_line(app: &App) -> Paragraph<'_> {
|
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 busy = if app.is_busy() { " [working]" } else { "" };
|
||||||
let warning = app
|
let warning = app
|
||||||
.remaining_lease()
|
.remaining_lease()
|
||||||
@@ -939,13 +930,18 @@ fn status_line(app: &App) -> Paragraph<'_> {
|
|||||||
.map_or_else(String::new, |remaining| {
|
.map_or_else(String::new, |remaining| {
|
||||||
format!(" [locks in {}s]", remaining.as_secs())
|
format!(" [locks in {}s]", remaining.as_secs())
|
||||||
});
|
});
|
||||||
Paragraph::new(Line::from(vec![
|
let clipboard = if app.clipboard_pending() {
|
||||||
Span::styled(
|
app.clipboard_remaining_seconds().map_or_else(
|
||||||
" status ",
|
|| " [copying to clipboard]".to_owned(),
|
||||||
Style::default().bg(Color::Blue).fg(Color::White),
|
|remaining| format!(" [clipboard clears in {remaining}s]"),
|
||||||
),
|
)
|
||||||
Span::raw(format!(" {}{busy}{warning}", app.status())),
|
} 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> {
|
fn context_line(app: &App) -> Paragraph<'static> {
|
||||||
@@ -991,7 +987,9 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::app::Transition;
|
use crate::app::Transition;
|
||||||
use crate::sidebar::TestTreeNode;
|
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 {
|
fn render(width: u16, height: u16, app: &App) -> String {
|
||||||
let backend = TestBackend::new(width, height);
|
let backend = TestBackend::new(width, height);
|
||||||
@@ -1365,26 +1363,60 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let mut app = App::new();
|
||||||
app.open_test_document("unicode/咖啡", fixture_document("unicode/咖啡"));
|
app.open_test_document("unicode/咖啡", fixture_document("unicode/咖啡"));
|
||||||
for width in [60, 100, 140] {
|
for width in [60, 100, 140] {
|
||||||
let output = render(width, 20, &app);
|
let output = render(width, 20, &app);
|
||||||
assert!(output.contains("password: ••••••••"));
|
assert!(output.contains("password: pässwörd-猫"));
|
||||||
assert!(output.contains("login:"));
|
assert!(output.contains("login:"));
|
||||||
assert!(output.contains('用'));
|
assert!(output.contains('用'));
|
||||||
assert!(output.contains("@example.test"));
|
assert!(output.contains("@example.test"));
|
||||||
assert!(output.contains("notes: ••••••••"));
|
assert!(output.contains("notes:"));
|
||||||
assert!(!output.contains("pässwörd-猫"));
|
|
||||||
}
|
}
|
||||||
assert!(!format!("{app:?}").contains("pässwörd-猫"));
|
assert!(!format!("{app:?}").contains("pässwörd-猫"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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(
|
let (_store, document) = fixture_document_from_plaintext(
|
||||||
"metadata/account",
|
"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();
|
let mut app = App::new();
|
||||||
app.sidebar_mut().replace_test_tree(vec![TestTreeNode {
|
app.sidebar_mut().replace_test_tree(vec![TestTreeNode {
|
||||||
@@ -1411,49 +1443,56 @@ mod tests {
|
|||||||
crate::app::ResultDisposition::Applied
|
crate::app::ResultDisposition::Applied
|
||||||
);
|
);
|
||||||
|
|
||||||
let unlocked = render(140, 20, &app);
|
let all_values = viewer_lines(app.viewer().expect("viewer"), None)
|
||||||
assert!(unlocked.contains("autoType_enabled: true"));
|
.iter()
|
||||||
assert!(unlocked.contains("icon: Internet"));
|
.flat_map(|line| line.spans.iter())
|
||||||
assert!(unlocked.contains("icon: Custom Icon"));
|
.map(|span| span.content.as_ref())
|
||||||
assert!(unlocked.contains("title: Café"));
|
.collect::<String>();
|
||||||
assert!(unlocked.contains("password: ••••••••"));
|
for expected in [
|
||||||
assert!(unlocked.contains("custom: ••••••••"));
|
"vault-secret",
|
||||||
assert!(!unlocked.contains("vault-secret"));
|
"autoType_enabled: true",
|
||||||
assert!(!unlocked.contains("hidden-value"));
|
"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, 30, &app).contains("vault-secret"));
|
||||||
assert!(render(140, 20, &app).contains("vault-secret"));
|
|
||||||
app.dispatch(crate::action::Action::FocusNext);
|
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::CloseEntry);
|
||||||
app.dispatch(crate::action::Action::Reveal);
|
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");
|
app.forced_relock("Authentication expired");
|
||||||
let locked = render(140, 20, &app);
|
let locked = render(140, 20, &app);
|
||||||
assert!(!locked.contains("vault-secret"));
|
assert!(!locked.contains("lock-secret"));
|
||||||
assert!(!locked.contains("autoType_enabled: true"));
|
assert!(!locked.contains("autoType_enabled: true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let mut app = App::new();
|
||||||
app.open_test_document("email/personal", fixture_document("email/personal"));
|
app.open_test_document("email/personal", fixture_document("email/personal"));
|
||||||
app.dispatch(crate::action::Action::Reveal);
|
assert!(render(100, 20, &app).contains("correct horse fixture"));
|
||||||
let revealed = render(100, 20, &app);
|
|
||||||
assert!(revealed.contains("correct horse fixture"));
|
|
||||||
|
|
||||||
app.dispatch(crate::action::Action::FocusNext);
|
app.dispatch(crate::action::Action::FocusNext);
|
||||||
let hidden = render(100, 20, &app);
|
assert!(render(100, 20, &app).contains("correct horse fixture"));
|
||||||
assert!(!hidden.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");
|
app.forced_relock("test lock");
|
||||||
let locked = render(100, 20, &app);
|
let locked = render(100, 20, &app);
|
||||||
assert!(!locked.contains("correct horse fixture"));
|
assert!(!locked.contains("correct horse fixture"));
|
||||||
@@ -1461,7 +1500,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let mut app = App::new();
|
||||||
app.open_test_document("otp/totp", fixture_document("otp/totp"));
|
app.open_test_document("otp/totp", fixture_document("otp/totp"));
|
||||||
app.dispatch(crate::action::Action::FocusNext);
|
app.dispatch(crate::action::Action::FocusNext);
|
||||||
@@ -1470,11 +1509,11 @@ mod tests {
|
|||||||
assert!(output.contains("IronStorage"));
|
assert!(output.contains("IronStorage"));
|
||||||
assert!(output.contains("alice@example.test"));
|
assert!(output.contains("alice@example.test"));
|
||||||
assert!(output.contains("period 30s"));
|
assert!(output.contains("period 30s"));
|
||||||
assert!(!output.contains("JBSWY3DPEHPK3PXP"));
|
assert!(output.contains("JBSWY3DPEHPK3PXP"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 mut app = App::new();
|
||||||
let document = fixture_document("otp/totp");
|
let document = fixture_document("otp/totp");
|
||||||
let wrong_field = document.fields()[0].id();
|
let wrong_field = document.fields()[0].id();
|
||||||
@@ -1528,7 +1567,7 @@ mod tests {
|
|||||||
assert!(large.contains("TOTP code — 12s remaining"));
|
assert!(large.contains("TOTP code — 12s remaining"));
|
||||||
assert!(large.contains("222 333"));
|
assert!(large.contains("222 333"));
|
||||||
assert!(!large.contains("code 123456"));
|
assert!(!large.contains("code 123456"));
|
||||||
assert!(!large.contains("JBSWY3DPEHPK3PXP"));
|
assert!(large.contains("JBSWY3DPEHPK3PXP"));
|
||||||
assert_eq!(app.viewer().expect("viewer").scroll(), 0);
|
assert_eq!(app.viewer().expect("viewer").scroll(), 0);
|
||||||
let full_bar = large.matches('█').count();
|
let full_bar = large.matches('█').count();
|
||||||
|
|
||||||
@@ -1576,7 +1615,8 @@ mod tests {
|
|||||||
assert!(refreshed.matches('█').count() > full_bar);
|
assert!(refreshed.matches('█').count() > full_bar);
|
||||||
let minimum = render(40, 8, &app);
|
let minimum = render(40, 8, &app);
|
||||||
assert!(minimum.contains("654321 — 30s remaining"));
|
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);
|
assert_eq!(app.viewer().expect("viewer").scroll(), 0);
|
||||||
|
|
||||||
let payload = ironstorage::repository::SecretBytes::new(
|
let payload = ironstorage::repository::SecretBytes::new(
|
||||||
@@ -1607,7 +1647,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 mut app = App::new();
|
||||||
let document = fixture_document("otp/totp");
|
let document = fixture_document("otp/totp");
|
||||||
let otp_field = document
|
let otp_field = document
|
||||||
@@ -1665,6 +1705,37 @@ mod tests {
|
|||||||
&& span.style.add_modifier.contains(Modifier::BOLD)
|
&& 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 backend = TestBackend::new(60, 16);
|
||||||
let mut terminal = Terminal::new(backend).expect("test terminal");
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||||
terminal
|
terminal
|
||||||
@@ -1706,7 +1777,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let mut app = App::new();
|
||||||
app.open_test_document("email/personal", fixture_document("email/personal"));
|
app.open_test_document("email/personal", fixture_document("email/personal"));
|
||||||
app.dispatch(crate::action::Action::EditEntry);
|
app.dispatch(crate::action::Action::EditEntry);
|
||||||
@@ -1714,15 +1785,11 @@ mod tests {
|
|||||||
app.handle_editor_input(crossterm::event::KeyCode::Char('x'));
|
app.handle_editor_input(crossterm::event::KeyCode::Char('x'));
|
||||||
|
|
||||||
for width in [60, 100, 140] {
|
for width in [60, 100, 140] {
|
||||||
let hidden = render(width, 20, &app);
|
let visible = render(width, 20, &app);
|
||||||
assert!(hidden.contains("hidden input"));
|
assert!(visible.contains("correct horse fixturex"));
|
||||||
assert!(!hidden.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"));
|
assert!(!format!("{app:?}").contains("correct horse fixturex"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,17 @@
|
|||||||
use std::{cell::Cell, fmt};
|
use std::{cell::Cell, fmt};
|
||||||
|
|
||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
document::{DocumentError, EntryDocument, EntryField, EntryFieldId, EntrySensitivity},
|
document::{DocumentError, EntryDocument, EntryField},
|
||||||
repository::SecretBytes,
|
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,
|
/// The document remains the source of field order, labels, kinds, sensitivity,
|
||||||
/// and values. This type only tracks transient presentation choices.
|
/// and values. This type only tracks transient presentation choices.
|
||||||
pub struct EntryViewer {
|
pub struct EntryViewer {
|
||||||
document: EntryDocument,
|
document: EntryDocument,
|
||||||
focused: usize,
|
focused: usize,
|
||||||
revealed: Option<EntryFieldId>,
|
|
||||||
scroll: Cell<usize>,
|
scroll: Cell<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +22,6 @@ impl EntryViewer {
|
|||||||
Self {
|
Self {
|
||||||
document,
|
document,
|
||||||
focused: 0,
|
focused: 0,
|
||||||
revealed: None,
|
|
||||||
scroll: Cell::new(0),
|
scroll: Cell::new(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,19 +42,13 @@ impl EntryViewer {
|
|||||||
self.document.fields().get(self.focused)
|
self.document.fields().get(self.focused)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_revealed(&self, id: EntryFieldId) -> bool {
|
|
||||||
self.revealed == Some(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn focus_next(&mut self) {
|
pub fn focus_next(&mut self) {
|
||||||
self.hide_revealed();
|
|
||||||
if !self.document.fields().is_empty() {
|
if !self.document.fields().is_empty() {
|
||||||
self.focused = (self.focused + 1) % self.document.fields().len();
|
self.focused = (self.focused + 1) % self.document.fields().len();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn focus_previous(&mut self) {
|
pub fn focus_previous(&mut self) {
|
||||||
self.hide_revealed();
|
|
||||||
if !self.document.fields().is_empty() {
|
if !self.document.fields().is_empty() {
|
||||||
self.focused = self
|
self.focused = self
|
||||||
.focused
|
.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<SecretBytes, DocumentError> {
|
pub fn copy_focused(&self) -> Result<SecretBytes, DocumentError> {
|
||||||
let field = self.focused_field().ok_or(DocumentError::InvalidIndex {
|
let field = self.focused_field().ok_or(DocumentError::InvalidIndex {
|
||||||
index: self.focused,
|
index: self.focused,
|
||||||
@@ -147,7 +124,6 @@ impl fmt::Debug for EntryViewer {
|
|||||||
.debug_struct("EntryViewer")
|
.debug_struct("EntryViewer")
|
||||||
.field("document", &self.document)
|
.field("document", &self.document)
|
||||||
.field("focused", &self.focused)
|
.field("focused", &self.focused)
|
||||||
.field("revealed", &self.revealed.map(|_| "[REDACTED]"))
|
|
||||||
.field("scroll", &self.scroll.get())
|
.field("scroll", &self.scroll.get())
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
@@ -158,8 +134,10 @@ pub(crate) mod test_support {
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
|
command::GrepRequest,
|
||||||
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||||
document::{EntryDocument, EntryDocumentService},
|
document::{EntryDocument, EntryDocumentService},
|
||||||
|
read::{GrepResults, VaultReader},
|
||||||
repository::{EntryPath, Repository, SecretBytes},
|
repository::{EntryPath, Repository, SecretBytes},
|
||||||
};
|
};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
@@ -183,6 +161,25 @@ pub(crate) mod test_support {
|
|||||||
fixture_document_from(&root.join("stores/basic"), entry)
|
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 {
|
pub(crate) fn fixture_document_from(store: &Path, entry: &str) -> EntryDocument {
|
||||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
.join("../../crates/storage/tests/fixtures/compatibility");
|
.join("../../crates/storage/tests/fixtures/compatibility");
|
||||||
@@ -224,14 +221,9 @@ mod tests {
|
|||||||
use super::{test_support::fixture_document, *};
|
use super::{test_support::fixture_document, *};
|
||||||
|
|
||||||
#[test]
|
#[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"));
|
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();
|
viewer.focus_next();
|
||||||
assert!(!viewer.is_revealed(password));
|
|
||||||
assert_eq!(viewer.focused_index(), Some(1));
|
assert_eq!(viewer.focused_index(), Some(1));
|
||||||
viewer.focus_previous();
|
viewer.focus_previous();
|
||||||
assert_eq!(viewer.focused_index(), Some(0));
|
assert_eq!(viewer.focused_index(), Some(0));
|
||||||
@@ -247,10 +239,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ordinary_fields_do_not_gain_reveal_state_and_scroll_saturates() {
|
fn scrolling_saturates() {
|
||||||
let mut viewer = EntryViewer::new(fixture_document("unicode/咖啡"));
|
let mut viewer = EntryViewer::new(fixture_document("unicode/咖啡"));
|
||||||
viewer.focus_next();
|
viewer.focus_next();
|
||||||
assert!(!viewer.reveal_focused());
|
|
||||||
viewer.scroll_down(usize::MAX);
|
viewer.scroll_down(usize::MAX);
|
||||||
let end = viewer.scroll();
|
let end = viewer.scroll();
|
||||||
viewer.scroll_down(1);
|
viewer.scroll_down(1);
|
||||||
|
|||||||
@@ -909,7 +909,7 @@ fn yes_no(value: bool) -> &'static str {
|
|||||||
|
|
||||||
fn generated_presentation_label(presentation: GeneratedPresentation) -> &'static str {
|
fn generated_presentation_label(presentation: GeneratedPresentation) -> &'static str {
|
||||||
match presentation {
|
match presentation {
|
||||||
GeneratedPresentation::Terminal => "masked viewer",
|
GeneratedPresentation::Terminal => "unlocked entry viewer",
|
||||||
GeneratedPresentation::Clipboard => "clipboard with timeout",
|
GeneratedPresentation::Clipboard => "clipboard with timeout",
|
||||||
GeneratedPresentation::QrCode => "terminal QR",
|
GeneratedPresentation::QrCode => "terminal QR",
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user