//! Secret-aware presentation state for one storage-owned entry document. use std::{cell::Cell, fmt}; use ironstorage::{ document::{DocumentError, EntryDocument, EntryField}, repository::SecretBytes, }; /// Focus and scrolling state for an authenticated document. /// /// The document remains the source of field order, labels, kinds, sensitivity, /// and values. This type only tracks transient presentation choices. pub struct EntryViewer { document: EntryDocument, focused: usize, scroll: Cell, } impl EntryViewer { pub fn new(document: EntryDocument) -> Self { Self { document, focused: 0, scroll: Cell::new(0), } } pub fn document(&self) -> &EntryDocument { &self.document } pub fn into_document(self) -> EntryDocument { self.document } pub fn focused_index(&self) -> Option { (!self.document.fields().is_empty()).then_some(self.focused) } pub fn focused_field(&self) -> Option<&EntryField> { self.document.display_fields().get(self.focused).copied() } pub fn focus_next(&mut self) { if !self.document.fields().is_empty() { self.focused = (self.focused + 1) % self.document.fields().len(); } } pub fn focus_previous(&mut self) { if !self.document.fields().is_empty() { self.focused = self .focused .checked_sub(1) .unwrap_or(self.document.fields().len() - 1); } } pub fn copy_focused(&self) -> Result { let field = self.focused_field().ok_or(DocumentError::InvalidIndex { index: self.focused, })?; self.document.copy_field_value(field.id()) } pub fn scroll(&self) -> usize { self.scroll.get() } /// Keep the focused rendered row range inside the supplied viewport while /// retaining the current offset whenever it is already visible. pub fn ensure_focus_visible( &self, focused_start: usize, focused_height: usize, total_rows: usize, viewport_rows: usize, ) -> usize { if self.document.fields().is_empty() || total_rows == 0 { self.scroll.set(0); return 0; } if viewport_rows == 0 { let scroll = self.scroll.get().min(total_rows.saturating_sub(1)); self.scroll.set(scroll); return scroll; } let maximum = total_rows.saturating_sub(viewport_rows); let mut scroll = self.scroll.get().min(maximum); let focused_height = focused_height.max(1); let focused_end = focused_start.saturating_add(focused_height); let viewport_end = scroll.saturating_add(viewport_rows); if focused_height > viewport_rows { if focused_end <= scroll { scroll = focused_end.saturating_sub(1); } else if focused_start >= viewport_end { scroll = focused_start; } } else if focused_start < scroll { scroll = focused_start; } else { if focused_end > viewport_end { scroll = focused_end.saturating_sub(viewport_rows); } } scroll = scroll.min(maximum); self.scroll.set(scroll); scroll } pub fn scroll_down(&mut self, rows: usize) { self.scroll.set(self.scroll.get().saturating_add(rows)); } pub fn scroll_up(&mut self, rows: usize) { self.scroll.set(self.scroll.get().saturating_sub(rows)); } } impl fmt::Debug for EntryViewer { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("EntryViewer") .field("document", &self.document) .field("focused", &self.focused) .field("scroll", &self.scroll.get()) .finish() } } #[cfg(test)] pub(crate) mod test_support { use std::path::{Path, PathBuf}; use ironstorage::{ command::GrepRequest, crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, document::{EntryDocument, EntryDocumentService}, read::{GrepResults, VaultReader}, repository::{EntryPath, Repository, SecretBytes}, }; use tempfile::TempDir; struct FixtureSecrets; impl SecretProvider for FixtureSecrets { fn secret_for(&mut self, key: &KeyInfo) -> Result { let passphrase = match key.fingerprint().as_str() { "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30" => b"fixture-alice-passphrase".to_vec(), "B37027B56FC406BD3F6A622B2AC03492B992D06F" => b"fixture-bob-passphrase".to_vec(), _ => return Err(SecretProviderError::Unavailable), }; Ok(SecretBytes::new(passphrase)) } } pub(crate) fn fixture_document(entry: &str) -> EntryDocument { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); fixture_document_from(&root.join("stores/basic"), entry) } pub(crate) fn fixture_grep() -> GrepResults { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); let repository = Repository::open(root.join("stores/basic")).expect("fixture repository"); let keys = KeyStore::load(root.join("keys")).expect("fixture keys"); VaultReader::new(&repository, &keys) .grep( &GrepRequest { pattern: "alice".to_owned(), ignore_case: false, invert_match: false, line_number: true, fixed_strings: true, }, &mut FixtureSecrets, ) .expect("fixture grep") } pub(crate) fn fixture_document_from(store: &Path, entry: &str) -> EntryDocument { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); let repository = Repository::open(store).expect("fixture repository"); let keys = KeyStore::load(root.join("keys")).expect("fixture keys"); EntryDocumentService::new(&repository, &keys) .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)] mod tests { use super::{test_support::fixture_document, *}; #[test] fn focus_wraps_without_changing_the_authenticated_document() { let mut viewer = EntryViewer::new(fixture_document("email/personal")); viewer.focus_next(); assert_eq!(viewer.focused_index(), Some(1)); viewer.focus_previous(); assert_eq!(viewer.focused_index(), Some(0)); } #[test] fn copy_uses_only_the_focused_structured_value() { let viewer = EntryViewer::new(fixture_document("email/personal")); let copied = viewer.copy_focused().expect("copy value"); assert_eq!(copied.expose(), b"alice@example.test"); assert!(!format!("{viewer:?}").contains("correct horse fixture")); } #[test] fn scrolling_saturates() { let mut viewer = EntryViewer::new(fixture_document("unicode/咖啡")); viewer.focus_next(); viewer.scroll_down(usize::MAX); let end = viewer.scroll(); viewer.scroll_down(1); assert_eq!(viewer.scroll(), end); viewer.scroll_up(usize::MAX); assert_eq!(viewer.scroll(), 0); } #[test] fn focus_only_scrolls_when_it_crosses_a_viewport_edge() { let mut viewer = EntryViewer::new(fixture_document("email/personal")); assert_eq!(viewer.document().fields().len(), 3); assert_eq!(viewer.ensure_focus_visible(0, 1, 3, 3), 0); viewer.focus_next(); assert_eq!(viewer.ensure_focus_visible(1, 1, 3, 3), 0); viewer.focus_next(); assert_eq!(viewer.ensure_focus_visible(2, 1, 3, 3), 0); viewer.focus_next(); assert_eq!(viewer.ensure_focus_visible(0, 1, 3, 3), 0); viewer.focus_previous(); assert_eq!(viewer.focused_index(), Some(2)); assert_eq!(viewer.ensure_focus_visible(2, 1, 3, 2), 1); viewer.focus_previous(); assert_eq!(viewer.focused_index(), Some(1)); assert_eq!(viewer.ensure_focus_visible(1, 1, 3, 2), 1); viewer.focus_previous(); assert_eq!(viewer.focused_index(), Some(0)); assert_eq!(viewer.ensure_focus_visible(0, 1, 3, 2), 0); } #[test] fn viewport_resize_clamps_then_minimally_reveals_the_focus() { let mut viewer = EntryViewer::new(fixture_document("email/personal")); viewer.focus_next(); viewer.focus_next(); assert_eq!(viewer.ensure_focus_visible(2, 1, 3, 1), 2); assert_eq!(viewer.ensure_focus_visible(2, 1, 3, 2), 1); assert_eq!(viewer.ensure_focus_visible(2, 1, 3, 3), 0); assert_eq!(viewer.ensure_focus_visible(2, 1, 3, 1), 2); viewer.focus_previous(); assert_eq!(viewer.ensure_focus_visible(1, 1, 3, 1), 1); viewer.focus_previous(); assert_eq!(viewer.ensure_focus_visible(0, 1, 3, 1), 0); } #[test] fn wrapped_fields_use_rendered_row_ranges_for_minimal_scrolling() { let mut viewer = EntryViewer::new(fixture_document("email/personal")); viewer.focus_next(); viewer.focus_next(); assert_eq!(viewer.ensure_focus_visible(4, 2, 6, 4), 2); viewer.focus_previous(); assert_eq!(viewer.ensure_focus_visible(2, 2, 6, 4), 2); viewer.focus_previous(); assert_eq!(viewer.ensure_focus_visible(0, 2, 6, 4), 0); } #[test] fn a_field_taller_than_the_viewport_allows_needed_manual_scrolling() { let mut viewer = EntryViewer::new(fixture_document("email/personal")); viewer.focus_next(); assert_eq!(viewer.ensure_focus_visible(1, 5, 7, 3), 0); viewer.scroll_down(2); assert_eq!(viewer.ensure_focus_visible(1, 5, 7, 3), 2); viewer.scroll_down(usize::MAX); assert_eq!(viewer.ensure_focus_visible(1, 5, 7, 3), 4); } }