301 lines
10 KiB
Rust
301 lines
10 KiB
Rust
//! Secret-aware presentation state for one storage-owned entry document.
|
|
|
|
use std::{cell::Cell, fmt};
|
|
|
|
use ironstorage::{
|
|
document::{DocumentError, EntryDocument, EntryField, EntryFieldId, EntrySensitivity},
|
|
repository::SecretBytes,
|
|
};
|
|
|
|
/// Focus, masking, 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,
|
|
revealed: Option<EntryFieldId>,
|
|
scroll: Cell<usize>,
|
|
}
|
|
|
|
impl EntryViewer {
|
|
pub fn new(document: EntryDocument) -> Self {
|
|
Self {
|
|
document,
|
|
focused: 0,
|
|
revealed: None,
|
|
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<usize> {
|
|
(!self.document.fields().is_empty()).then_some(self.focused)
|
|
}
|
|
|
|
pub fn focused_field(&self) -> Option<&EntryField> {
|
|
self.document.fields().get(self.focused)
|
|
}
|
|
|
|
pub fn is_revealed(&self, id: EntryFieldId) -> bool {
|
|
self.revealed == Some(id)
|
|
}
|
|
|
|
pub fn focus_next(&mut self) {
|
|
self.hide_revealed();
|
|
if !self.document.fields().is_empty() {
|
|
self.focused = (self.focused + 1) % self.document.fields().len();
|
|
}
|
|
}
|
|
|
|
pub fn focus_previous(&mut self) {
|
|
self.hide_revealed();
|
|
if !self.document.fields().is_empty() {
|
|
self.focused = self
|
|
.focused
|
|
.checked_sub(1)
|
|
.unwrap_or(self.document.fields().len() - 1);
|
|
}
|
|
}
|
|
|
|
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> {
|
|
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("revealed", &self.revealed.map(|_| "[REDACTED]"))
|
|
.field("scroll", &self.scroll.get())
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod test_support {
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use ironstorage::{
|
|
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
|
document::{EntryDocument, EntryDocumentService},
|
|
repository::{Repository, SecretBytes},
|
|
};
|
|
|
|
struct FixtureSecrets;
|
|
|
|
impl SecretProvider for FixtureSecrets {
|
|
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
|
|
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_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")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{test_support::fixture_document, *};
|
|
|
|
#[test]
|
|
fn focus_wraps_and_always_hides_a_revealed_secret() {
|
|
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();
|
|
assert!(!viewer.is_revealed(password));
|
|
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 mut viewer = EntryViewer::new(fixture_document("email/personal"));
|
|
viewer.focus_next();
|
|
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 ordinary_fields_do_not_gain_reveal_state_and_scroll_saturates() {
|
|
let mut viewer = EntryViewer::new(fixture_document("unicode/咖啡"));
|
|
viewer.focus_next();
|
|
assert!(!viewer.reveal_focused());
|
|
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);
|
|
}
|
|
}
|