From 7c64be3a45c84e3291e539c667a25ab2db9de28d Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 10 Aug 2026 17:51:08 +0200 Subject: [PATCH] Fix TUI field visibility and contrast --- apps/tui/src/ui.rs | 77 +++++++++++++++++++++++-- apps/tui/src/viewer.rs | 28 ++++++++- crates/storage/src/document.rs | 2 +- crates/storage/tests/entry_documents.rs | 39 ++++++++----- 4 files changed, 125 insertions(+), 21 deletions(-) diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index 7a2bf0b..a35fa36 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -20,6 +20,8 @@ const MINIMUM_WIDTH: u16 = 40; const MINIMUM_HEIGHT: u16 = 8; const LARGE_OTP_HEIGHT: u16 = 8; const LARGE_OTP_ROWS: usize = 5; +const SELECTED_FOREGROUND: Color = Color::Black; +const SELECTED_BACKGROUND: Color = Color::White; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ColorCapability { @@ -72,7 +74,9 @@ 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 == Color::White && cell.bg == Color::Blue { + if (cell.fg == SELECTED_FOREGROUND && cell.bg == SELECTED_BACKGROUND) + || (cell.fg == Color::White && cell.bg == Color::Blue) + { cell.modifier.insert(Modifier::REVERSED); } cell.set_fg(Color::Reset).set_bg(Color::Reset); @@ -560,9 +564,12 @@ fn pane_block(title: &'static str, focused: bool) -> Block<'static> { } fn selected_line<'a>(mut spans: Vec>) -> Line<'a> { - let style = Style::default().bg(Color::Blue).fg(Color::White); + let style = Style::default() + .fg(SELECTED_FOREGROUND) + .bg(SELECTED_BACKGROUND) + .add_modifier(Modifier::BOLD); for span in &mut spans { - span.style = span.style.bg(Color::Blue).fg(Color::White); + span.style = style; } Line::from(spans).style(style) } @@ -984,7 +991,7 @@ mod tests { use super::*; use crate::app::Transition; use crate::sidebar::TestTreeNode; - use crate::viewer::test_support::fixture_document; + use crate::viewer::test_support::{fixture_document, fixture_document_from_plaintext}; fn render(width: u16, height: u16, app: &App) -> String { let backend = TestBackend::new(width, height); @@ -1373,6 +1380,60 @@ mod tests { assert!(!format!("{app:?}").contains("pässwörd-猫")); } + #[test] + fn authenticated_viewer_renders_ordinary_metadata_and_rehides_secrets() { + 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", + ); + let mut app = App::new(); + app.sidebar_mut().replace_test_tree(vec![TestTreeNode { + path: "metadata/account".to_owned(), + name: "account".to_owned(), + directory: false, + indicators: ironstorage::read::TreeNodeIndicators::default(), + children: vec![], + }]); + assert!(matches!( + app.dispatch(crate::action::Action::Activate), + crate::app::AppEffect::AuthenticateEntry(entry) if entry == "metadata/account" + )); + assert!(app.authentication_granted("metadata/account".to_owned())); + let token = app.begin_request(); + assert_eq!( + app.apply_result(crate::app::AsyncResult { + token, + payload: Ok(crate::app::AsyncPayload::DocumentLoaded { + entry: "metadata/account".to_owned(), + document: Box::new(document), + }), + }), + 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")); + + app.dispatch(crate::action::Action::Reveal); + assert!(render(140, 20, &app).contains("vault-secret")); + app.dispatch(crate::action::Action::FocusNext); + assert!(!render(140, 20, &app).contains("vault-secret")); + + app.dispatch(crate::action::Action::FocusPrevious); + app.dispatch(crate::action::Action::Reveal); + app.forced_relock("Authentication expired"); + let locked = render(140, 20, &app); + assert!(!locked.contains("vault-secret")); + assert!(!locked.contains("autoType_enabled: true")); + } + #[test] fn reveal_is_explicit_and_focus_change_or_lock_removes_secret_from_rendering() { let mut app = App::new(); @@ -1591,13 +1652,17 @@ mod tests { .any(|span| span.content.contains("code 123456")) ); assert!(selected.spans.iter().all(|span| { - span.style.fg == Some(Color::White) && span.style.bg == Some(Color::Blue) + span.style.fg == Some(SELECTED_FOREGROUND) + && span.style.bg == Some(SELECTED_BACKGROUND) + && span.style.add_modifier.contains(Modifier::BOLD) })); let editor = EntryEditor::new(fixture_document("email/personal")); let editor_selected = &editor_lines(&editor)[0]; assert!(editor_selected.spans.iter().all(|span| { - span.style.fg == Some(Color::White) && span.style.bg == Some(Color::Blue) + span.style.fg == Some(SELECTED_FOREGROUND) + && span.style.bg == Some(SELECTED_BACKGROUND) + && span.style.add_modifier.contains(Modifier::BOLD) })); let backend = TestBackend::new(60, 16); diff --git a/apps/tui/src/viewer.rs b/apps/tui/src/viewer.rs index de702ec..5c870cd 100644 --- a/apps/tui/src/viewer.rs +++ b/apps/tui/src/viewer.rs @@ -160,8 +160,9 @@ pub(crate) mod test_support { use ironstorage::{ crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, document::{EntryDocument, EntryDocumentService}, - repository::{Repository, SecretBytes}, + repository::{EntryPath, Repository, SecretBytes}, }; + use tempfile::TempDir; struct FixtureSecrets; @@ -191,6 +192,31 @@ pub(crate) mod test_support { .open(entry, &mut FixtureSecrets) .expect("fixture document") } + + pub(crate) fn fixture_document_from_plaintext( + entry: &str, + plaintext: &[u8], + ) -> (TempDir, EntryDocument) { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../crates/storage/tests/fixtures/compatibility"); + let store = tempfile::tempdir().expect("temporary fixture store"); + let repository = Repository::open(store.path()).expect("fixture repository"); + let keys = KeyStore::load(root.join("keys")).expect("fixture keys"); + let recipients = keys + .resolve_recipients(b"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\n") + .expect("fixture recipient"); + let ciphertext = keys + .encrypt(SecretBytes::new(plaintext.to_vec()), &recipients) + .expect("fixture encryption"); + let path = EntryPath::parse(entry).expect("fixture entry path"); + repository + .write_entry(&path, &ciphertext) + .expect("fixture entry write"); + let document = EntryDocumentService::new(&repository, &keys) + .open(entry, &mut FixtureSecrets) + .expect("fixture document"); + (store, document) + } } #[cfg(test)] diff --git a/crates/storage/src/document.rs b/crates/storage/src/document.rs index 3dc22fd..a3c7cde 100644 --- a/crates/storage/src/document.rs +++ b/crates/storage/src/document.rs @@ -643,7 +643,7 @@ fn field_sensitivity(name: &str, kind: EntryFieldKind) -> EntrySensitivity { return EntrySensitivity::Ordinary; } match name.trim().to_ascii_lowercase().as_str() { - "title" | "site" | "host" => EntrySensitivity::Ordinary, + "title" | "site" | "host" | "autotype_enabled" | "icon" => EntrySensitivity::Ordinary, _ => EntrySensitivity::Sensitive, } } diff --git a/crates/storage/tests/entry_documents.rs b/crates/storage/tests/entry_documents.rs index 46442fc..3b43070 100644 --- a/crates/storage/tests/entry_documents.rs +++ b/crates/storage/tests/entry_documents.rs @@ -69,7 +69,7 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult { let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let mut secrets = FixtureSecrets::all(&fixture); - let plaintext = b"p\xc3\xa4ssw\xc3\xb6rd\r\nusername: alice\r\ncustom: one\r\ncustom: \r\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\r\n\r\nfirst note\r\nsecond note\r\n\xe9\x8d\xb5: \xe5\x80\xbc\r\nunrecognized line"; + let plaintext = b"p\xc3\xa4ssw\xc3\xb6rd\r\nusername: alice\r\nautoType_enabled: true\r\nicon: Internet\r\ncustom: one\r\ncustom: \r\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\r\n\r\nfirst note\r\nsecond note\r\n\xe9\x8d\xb5: \xe5\x80\xbc\r\nunrecognized line"; write_plaintext(&repository, &keys, "documents/complex", plaintext)?; let service = EntryDocumentService::new(&repository, &keys); @@ -79,7 +79,7 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult { document.password().expect("password").value(), "pässwörd".as_bytes() ); - assert_eq!(document.fields().len(), 10); + assert_eq!(document.fields().len(), 12); let kinds = document .fields() @@ -93,6 +93,8 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult { EntryFieldKind::Username, EntryFieldKind::Field, EntryFieldKind::Field, + EntryFieldKind::Field, + EntryFieldKind::Field, EntryFieldKind::OtpUri, EntryFieldKind::Blank, EntryFieldKind::Note, @@ -106,18 +108,29 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult { document.fields()[1].metadata().sensitivity(), EntrySensitivity::Ordinary ); - assert_eq!(document.fields()[2].metadata().name(), Some("custom")); - assert_eq!(document.fields()[2].value(), b"one"); - assert_eq!(document.fields()[3].metadata().name(), Some("custom")); - assert!(document.fields()[3].value().is_empty()); - assert_ne!(document.fields()[2].id(), document.fields()[3].id()); - assert_eq!(document.fields()[8].metadata().name(), Some("鍵")); - assert_eq!(document.fields()[8].value(), "值".as_bytes()); + for (index, name, value) in [ + (2, "autoType_enabled", b"true".as_slice()), + (3, "icon", b"Internet".as_slice()), + ] { + assert_eq!(document.fields()[index].metadata().name(), Some(name)); + assert_eq!(document.fields()[index].value(), value); + assert_eq!( + document.fields()[index].metadata().sensitivity(), + EntrySensitivity::Ordinary + ); + } + assert_eq!(document.fields()[4].metadata().name(), Some("custom")); + assert_eq!(document.fields()[4].value(), b"one"); + assert_eq!(document.fields()[5].metadata().name(), Some("custom")); + assert!(document.fields()[5].value().is_empty()); + assert_ne!(document.fields()[4].id(), document.fields()[5].id()); + assert_eq!(document.fields()[10].metadata().name(), Some("鍵")); + assert_eq!(document.fields()[10].value(), "值".as_bytes()); assert_eq!( - document.fields()[4].metadata().sensitivity(), + document.fields()[6].metadata().sensitivity(), EntrySensitivity::Sensitive ); - let otp = document.fields()[4] + let otp = document.fields()[6] .metadata() .otp() .expect("validated OTP metadata"); @@ -128,10 +141,10 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult { assert_eq!(otp.digits(), 6); assert_eq!(otp.period(), Some(30)); assert_eq!(otp.counter(), None); - let copied = document.copy_field_value(document.fields()[2].id())?; + let copied = document.copy_field_value(document.fields()[4].id())?; assert_eq!(copied.expose(), b"one"); assert!(!format!("{document:?}").contains("pässwörd")); - assert!(!format!("{:?}", document.fields()[4]).contains("JBSWY3DPEHPK3PXP")); + assert!(!format!("{:?}", document.fields()[6]).contains("JBSWY3DPEHPK3PXP")); Ok(()) }