Fix unlocked TUI visibility and clipboard feedback
This commit is contained in:
@@ -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<Span<'a>>) -> 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<Span<'a>>) -> Line<'a> {
|
||||
let style = selected_style();
|
||||
for span in &mut spans {
|
||||
span.style = style;
|
||||
}
|
||||
@@ -600,21 +600,18 @@ fn sidebar_lines(app: &App) -> Vec<Line<'static>> {
|
||||
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<Line<'_>> {
|
||||
|| 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<Line<'_>> {
|
||||
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<Line<'_>> {
|
||||
} 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::<String>();
|
||||
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"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user