Implement the authenticated structured entry viewer

This commit is contained in:
Hermes Agent
2026-08-10 06:45:39 +00:00
parent 57a79b7d21
commit cf7a6216ac
8 changed files with 758 additions and 28 deletions

194
apps/tui/src/viewer.rs Normal file
View File

@@ -0,0 +1,194 @@
//! Secret-aware presentation state for one storage-owned entry document.
use std::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: usize,
}
impl EntryViewer {
pub fn new(document: EntryDocument) -> Self {
Self {
document,
focused: 0,
revealed: None,
scroll: 0,
}
}
pub fn 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();
self.scroll = self.focused;
}
}
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);
self.scroll = self.focused;
}
}
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
}
pub fn scroll_down(&mut self, rows: usize) {
self.scroll = self.scroll.saturating_add(rows).min(self.scroll_limit());
}
pub fn scroll_up(&mut self, rows: usize) {
self.scroll = self.scroll.saturating_sub(rows);
}
fn scroll_limit(&self) -> usize {
self.document
.fields()
.iter()
.map(|field| field.value().len().saturating_add(1))
.sum()
}
}
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)
.finish()
}
}
#[cfg(test)]
pub(crate) mod test_support {
use std::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");
let repository = Repository::open(root.join("stores/basic")).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);
}
}