Fix TUI field visibility and contrast

This commit is contained in:
2026-08-10 17:51:08 +02:00
parent 44e1b86f47
commit 7c64be3a45
4 changed files with 125 additions and 21 deletions

View File

@@ -20,6 +20,8 @@ const MINIMUM_WIDTH: u16 = 40;
const MINIMUM_HEIGHT: u16 = 8; const MINIMUM_HEIGHT: u16 = 8;
const LARGE_OTP_HEIGHT: u16 = 8; const LARGE_OTP_HEIGHT: u16 = 8;
const LARGE_OTP_ROWS: usize = 5; const LARGE_OTP_ROWS: usize = 5;
const SELECTED_FOREGROUND: Color = Color::Black;
const SELECTED_BACKGROUND: Color = Color::White;
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ColorCapability { pub enum ColorCapability {
@@ -72,7 +74,9 @@ 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 == 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.modifier.insert(Modifier::REVERSED);
} }
cell.set_fg(Color::Reset).set_bg(Color::Reset); 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<Span<'a>>) -> Line<'a> { fn selected_line<'a>(mut spans: Vec<Span<'a>>) -> 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 { for span in &mut spans {
span.style = span.style.bg(Color::Blue).fg(Color::White); span.style = style;
} }
Line::from(spans).style(style) Line::from(spans).style(style)
} }
@@ -984,7 +991,7 @@ 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; use crate::viewer::test_support::{fixture_document, fixture_document_from_plaintext};
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);
@@ -1373,6 +1380,60 @@ mod tests {
assert!(!format!("{app:?}").contains("pässwörd-猫")); 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] #[test]
fn reveal_is_explicit_and_focus_change_or_lock_removes_secret_from_rendering() { fn reveal_is_explicit_and_focus_change_or_lock_removes_secret_from_rendering() {
let mut app = App::new(); let mut app = App::new();
@@ -1591,13 +1652,17 @@ mod tests {
.any(|span| span.content.contains("code 123456")) .any(|span| span.content.contains("code 123456"))
); );
assert!(selected.spans.iter().all(|span| { 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 = EntryEditor::new(fixture_document("email/personal"));
let editor_selected = &editor_lines(&editor)[0]; let editor_selected = &editor_lines(&editor)[0];
assert!(editor_selected.spans.iter().all(|span| { 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); let backend = TestBackend::new(60, 16);

View File

@@ -160,8 +160,9 @@ pub(crate) mod test_support {
use ironstorage::{ use ironstorage::{
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
document::{EntryDocument, EntryDocumentService}, document::{EntryDocument, EntryDocumentService},
repository::{Repository, SecretBytes}, repository::{EntryPath, Repository, SecretBytes},
}; };
use tempfile::TempDir;
struct FixtureSecrets; struct FixtureSecrets;
@@ -191,6 +192,31 @@ pub(crate) mod test_support {
.open(entry, &mut FixtureSecrets) .open(entry, &mut FixtureSecrets)
.expect("fixture document") .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)] #[cfg(test)]

View File

@@ -643,7 +643,7 @@ fn field_sensitivity(name: &str, kind: EntryFieldKind) -> EntrySensitivity {
return EntrySensitivity::Ordinary; return EntrySensitivity::Ordinary;
} }
match name.trim().to_ascii_lowercase().as_str() { match name.trim().to_ascii_lowercase().as_str() {
"title" | "site" | "host" => EntrySensitivity::Ordinary, "title" | "site" | "host" | "autotype_enabled" | "icon" => EntrySensitivity::Ordinary,
_ => EntrySensitivity::Sensitive, _ => EntrySensitivity::Sensitive,
} }
} }

View File

@@ -69,7 +69,7 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
let repository = Repository::open(store.path())?; let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?; let keys = KeyStore::load(fixture.path("keys"))?;
let mut secrets = FixtureSecrets::all(&fixture); 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)?; write_plaintext(&repository, &keys, "documents/complex", plaintext)?;
let service = EntryDocumentService::new(&repository, &keys); 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(), document.password().expect("password").value(),
"pässwörd".as_bytes() "pässwörd".as_bytes()
); );
assert_eq!(document.fields().len(), 10); assert_eq!(document.fields().len(), 12);
let kinds = document let kinds = document
.fields() .fields()
@@ -93,6 +93,8 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
EntryFieldKind::Username, EntryFieldKind::Username,
EntryFieldKind::Field, EntryFieldKind::Field,
EntryFieldKind::Field, EntryFieldKind::Field,
EntryFieldKind::Field,
EntryFieldKind::Field,
EntryFieldKind::OtpUri, EntryFieldKind::OtpUri,
EntryFieldKind::Blank, EntryFieldKind::Blank,
EntryFieldKind::Note, EntryFieldKind::Note,
@@ -106,18 +108,29 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
document.fields()[1].metadata().sensitivity(), document.fields()[1].metadata().sensitivity(),
EntrySensitivity::Ordinary EntrySensitivity::Ordinary
); );
assert_eq!(document.fields()[2].metadata().name(), Some("custom")); for (index, name, value) in [
assert_eq!(document.fields()[2].value(), b"one"); (2, "autoType_enabled", b"true".as_slice()),
assert_eq!(document.fields()[3].metadata().name(), Some("custom")); (3, "icon", b"Internet".as_slice()),
assert!(document.fields()[3].value().is_empty()); ] {
assert_ne!(document.fields()[2].id(), document.fields()[3].id()); assert_eq!(document.fields()[index].metadata().name(), Some(name));
assert_eq!(document.fields()[8].metadata().name(), Some("")); assert_eq!(document.fields()[index].value(), value);
assert_eq!(document.fields()[8].value(), "".as_bytes());
assert_eq!( assert_eq!(
document.fields()[4].metadata().sensitivity(), 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()[6].metadata().sensitivity(),
EntrySensitivity::Sensitive EntrySensitivity::Sensitive
); );
let otp = document.fields()[4] let otp = document.fields()[6]
.metadata() .metadata()
.otp() .otp()
.expect("validated OTP metadata"); .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.digits(), 6);
assert_eq!(otp.period(), Some(30)); assert_eq!(otp.period(), Some(30));
assert_eq!(otp.counter(), None); 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_eq!(copied.expose(), b"one");
assert!(!format!("{document:?}").contains("pässwörd")); assert!(!format!("{document:?}").contains("pässwörd"));
assert!(!format!("{:?}", document.fields()[4]).contains("JBSWY3DPEHPK3PXP")); assert!(!format!("{:?}", document.fields()[6]).contains("JBSWY3DPEHPK3PXP"));
Ok(()) Ok(())
} }