Implement structured iPhone entry viewer

This commit is contained in:
2026-08-11 19:59:18 +02:00
parent 873db91204
commit 900e62e523
9 changed files with 1728 additions and 92 deletions

View File

@@ -9,6 +9,7 @@ use ironstorage::{
document::{
DocumentError, EntryDocumentService, EntryFieldDraft, EntryFieldKind, EntrySensitivity,
},
mobile_entry::{MobileEntryPage, MobileEntrySectionKind, field_value},
recipient::RecipientPolicyManager,
repository::{EntryPath, Repository, SecretBytes},
write::{EntryCommit, EntryCommitError, EntryCommitter, WriteError},
@@ -134,6 +135,15 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
document.fields()[6].metadata().sensitivity(),
EntrySensitivity::Sensitive
);
assert_eq!(
document
.fields()
.iter()
.filter(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive)
.map(|field| field.metadata().kind())
.collect::<Vec<_>>(),
[EntryFieldKind::Password, EntryFieldKind::OtpUri]
);
let otp = document.fields()[6]
.metadata()
.otp()
@@ -147,11 +157,123 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
assert_eq!(otp.counter(), None);
let copied = document.copy_field_value(document.fields()[4].id())?;
assert_eq!(copied.expose(), b"one");
let mobile = MobileEntryPage::from_document(&document);
let mobile_fields = mobile
.sections()
.iter()
.flat_map(|section| section.fields())
.collect::<Vec<_>>();
assert_eq!(mobile.title(), "complex");
assert_eq!(mobile_fields.len(), document.fields().len());
assert_eq!(mobile_fields[0].label(), "Password");
assert!(mobile_fields[0].sensitive());
assert_eq!(mobile_fields[0].value(), None);
assert_eq!(mobile_fields[1].value(), Some("alice"));
let mobile_custom = mobile_fields
.iter()
.filter(|field| field.label() == "Custom")
.copied()
.collect::<Vec<_>>();
assert_eq!(mobile_custom.len(), 2);
assert_ne!(mobile_custom[0].id(), mobile_custom[1].id());
assert_eq!(mobile_custom[0].value(), Some("one"));
assert_eq!(mobile_custom[1].value(), Some(""));
let mobile_otp = mobile_fields
.iter()
.find(|field| field.system_image() == "timer")
.expect("OTP field");
assert!(mobile_otp.sensitive());
assert_eq!(mobile_otp.value(), None);
assert!(mobile_otp.detail().is_some_and(|detail| {
detail.contains("TOTP") && detail.contains("Example") && detail.contains("alice")
}));
let mobile_note = mobile_fields
.iter()
.find(|field| field.system_image() == "note.text")
.expect("multiline note");
assert!(!mobile_note.sensitive());
assert!(mobile_note.multiline());
assert!(mobile_note.selectable());
assert_eq!(mobile_note.value(), Some("\r\nfirst note\r\nsecond note"));
assert_eq!(
field_value(&document, document.fields()[4].id().value())?,
"one"
);
assert!(!format!("{document:?}").contains("pässwörd"));
assert!(!format!("{:?}", document.fields()[6]).contains("JBSWY3DPEHPK3PXP"));
Ok(())
}
#[test]
fn mobile_projection_preserves_partially_understood_fields_without_exposing_them() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let mut secrets = FixtureSecrets::all(&fixture);
write_plaintext(
&repository,
&keys,
"documents/diagnostics",
b"password\notpauth://totp/broken\ncustom: \xff\n",
)?;
let document = EntryDocumentService::new(&repository, &keys)
.open("documents/diagnostics", &mut secrets)?;
let page = MobileEntryPage::from_document(&document);
let fields = page
.sections()
.iter()
.flat_map(|section| section.fields())
.collect::<Vec<_>>();
assert_eq!(fields.len(), 3);
assert_eq!(
fields
.iter()
.filter(|field| field.diagnostic().is_some())
.count(),
2
);
assert!(fields.iter().all(|field| field.value().is_none()));
assert!(field_value(&document, document.fields()[2].id().value()).is_err());
assert_eq!(
document.serialize().expose(),
b"password\notpauth://totp/broken\ncustom: \xff\n"
);
Ok(())
}
#[test]
fn mobile_projection_marks_an_empty_first_line_without_a_reveal_control() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let mut secrets = FixtureSecrets::all(&fixture);
write_plaintext(
&repository,
&keys,
"documents/empty-password",
b"\ncomments: ordinary note\n",
)?;
let document = EntryDocumentService::new(&repository, &keys)
.open("documents/empty-password", &mut secrets)?;
let password = document.password().expect("empty password field");
assert_eq!(password.metadata().sensitivity(), EntrySensitivity::Empty);
let page = MobileEntryPage::from_document(&document);
let password = page.sections()[0].fields().first().expect("password row");
assert_eq!(password.label(), "Password");
assert_eq!(password.value(), None);
assert_eq!(password.masked_value(), "Empty");
assert!(!password.sensitive());
assert_eq!(
document.serialize().expose(),
b"\ncomments: ordinary note\n"
);
Ok(())
}
#[test]
fn named_multiline_fields_are_one_lossless_logical_field() -> TestResult {
let fixture = FixtureSet::load()?;
@@ -189,7 +311,7 @@ fn only_whitespace_free_names_end_multiline_fields() -> TestResult {
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let mut secrets = FixtureSecrets::all(&fixture);
let plaintext = "password\ncomments: PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n\nautotype_enabled: True\nicon: 0\n";
let plaintext = "password\ncomments: PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n\nautotype_enabled: True\nicon: 0\ntags: travel, train\n";
write_plaintext(
&repository,
&keys,
@@ -200,8 +322,12 @@ fn only_whitespace_free_names_end_multiline_fields() -> TestResult {
let document =
EntryDocumentService::new(&repository, &keys).open("documents/comments", &mut secrets)?;
assert_eq!(document.serialize().expose(), plaintext.as_bytes());
assert_eq!(document.fields().len(), 4);
assert_eq!(document.fields().len(), 5);
assert_eq!(document.fields()[1].metadata().name(), Some("comments"));
assert_eq!(
document.fields()[1].metadata().sensitivity(),
EntrySensitivity::Ordinary
);
assert_eq!(
document.fields()[1].value(),
"PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n"
@@ -212,6 +338,25 @@ fn only_whitespace_free_names_end_multiline_fields() -> TestResult {
Some("autotype_enabled")
);
assert_eq!(document.fields()[3].metadata().name(), Some("icon"));
assert_eq!(document.fields()[4].metadata().name(), Some("tags"));
assert_eq!(
document.fields()[4].metadata().sensitivity(),
EntrySensitivity::Ordinary
);
let mobile = MobileEntryPage::from_document(&document);
let comments = mobile
.sections()
.iter()
.find(|section| section.kind() == MobileEntrySectionKind::Notes)
.and_then(|section| section.fields().first())
.expect("multiline comments section");
assert_eq!(comments.label(), "Comments");
assert!(!comments.sensitive());
assert!(comments.multiline());
assert_eq!(
comments.value(),
std::str::from_utf8(document.fields()[1].value()).ok()
);
assert!(matches!(
EntryFieldDraft::field("www.bahn.de Login", Vec::new()),
Err(DocumentError::InvalidFieldName)
@@ -228,6 +373,10 @@ fn only_whitespace_free_names_end_multiline_fields() -> TestResult {
assert_eq!(note.password().expect("password").value(), b"password");
assert_eq!(note.fields().len(), 2);
assert_eq!(note.fields()[1].value(), b"\nordinary note\ncontinued");
assert_eq!(
note.fields()[1].metadata().sensitivity(),
EntrySensitivity::Ordinary
);
let unordered = "password\nzeta: last\nnote: named\ncomments: heading\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\nordinary note\nurl: https://example.test\nlogin: alice\nalpha: first\n";
write_plaintext(