#![forbid(unsafe_code)] mod support; use std::collections::{BTreeMap, BTreeSet}; use ironstorage::{ crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, document::{ DocumentError, EntryDocumentService, EntryFieldDraft, EntryFieldKind, EntrySensitivity, }, mobile_entry::{ MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind, MobileEntryEditorInput, MobileEntryPage, MobileEntrySectionKind, field_value, }, recipient::RecipientPolicyManager, repository::{EntryPath, Repository, SecretBytes}, write::{EntryCommit, EntryCommitError, EntryCommitter, WriteError}, }; use support::compatibility::{FixtureSet, TestResult}; struct FixtureSecrets(BTreeMap>); impl FixtureSecrets { fn all(fixture: &FixtureSet) -> Self { Self( fixture .generated .keys .iter() .map(|key| { ( key.primary_fingerprint.clone(), key.passphrase.as_bytes().to_vec(), ) }) .collect(), ) } } impl SecretProvider for FixtureSecrets { fn secret_for(&mut self, key: &KeyInfo) -> Result { self.0 .get(key.fingerprint().as_str()) .cloned() .map(SecretBytes::new) .ok_or(SecretProviderError::Unavailable) } } #[derive(Default)] struct Committer { changes: Vec, fail: bool, } impl EntryCommitter for Committer { fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> { self.changes.push(change.clone()); if self.fail { Err(EntryCommitError::new("simulated commit failure")) } else { Ok(()) } } } #[test] fn complex_documents_round_trip_with_storage_owned_metadata() -> 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); 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)?; let service = EntryDocumentService::new(&repository, &keys); let document = service.open("documents/complex", &mut secrets)?; assert_eq!(document.serialize().expose(), plaintext); assert_eq!( document.password().expect("password").value(), "pässwörd".as_bytes() ); assert_eq!(document.fields().len(), 9); let kinds = document .fields() .iter() .map(|field| field.metadata().kind()) .collect::>(); assert_eq!( kinds, [ EntryFieldKind::Password, EntryFieldKind::Username, EntryFieldKind::Field, EntryFieldKind::Field, EntryFieldKind::Field, EntryFieldKind::Field, EntryFieldKind::OtpUri, EntryFieldKind::Note, EntryFieldKind::Field, ] ); assert_eq!(document.fields()[1].metadata().name(), Some("username")); assert_eq!( document.fields()[1].metadata().sensitivity(), EntrySensitivity::Ordinary ); for (index, name, value) in [ (2, "autoType_enabled", b"true".as_slice()), (3, "icon", b"Internet".as_slice()), ] { assert_eq!(document.fields()[index].metadata().name(), Some(name)); assert_eq!(document.fields()[index].value(), value); assert_eq!( 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()[8].metadata().name(), Some("鍵")); assert_eq!( document.fields()[8].value(), "值\r\nunrecognized line".as_bytes() ); assert_eq!( document.fields()[7].value(), b"\r\nfirst note\r\nsecond note" ); assert_eq!( 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::>(), [EntryFieldKind::Password, EntryFieldKind::OtpUri] ); let otp = document.fields()[6] .metadata() .otp() .expect("validated OTP metadata"); assert_eq!(otp.kind(), ironstorage::otp::OtpKind::Totp); assert_eq!(otp.issuer(), Some("Example")); assert_eq!(otp.account(), "alice"); assert_eq!(otp.algorithm(), ironstorage::otp::OtpAlgorithm::Sha1); assert_eq!(otp.digits(), 6); assert_eq!(otp.period(), Some(30)); 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::>(); 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::>(); 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::>(); 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 pin_fields_are_sensitive_case_insensitively() -> 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/pins", b"password\nPIN: 1234\npin: 5678\nPiN: 9012\n", )?; let document = EntryDocumentService::new(&repository, &keys).open("documents/pins", &mut secrets)?; let pins = &document.fields()[1..]; assert!(pins.iter().all(|field| { field.metadata().kind() == EntryFieldKind::Field && field.metadata().sensitivity() == EntrySensitivity::Sensitive })); let page = MobileEntryPage::from_document(&document); let projected = page .sections() .iter() .flat_map(|section| section.fields()) .filter(|field| field.id() != 0) .collect::>(); assert_eq!(projected.len(), 3); assert!(projected.iter().all(|field| { field.sensitive() && field.value().is_none() && field.masked_value() == "Hidden" && !field.selectable() })); assert_eq!(field_value(&document, pins[0].id().value())?, "1234"); let editor = MobileEntryDraft::new(document, false).page(); assert!( editor.fields()[1..] .iter() .all(|field| field.sensitive() && field.masked_value() == "Hidden") ); Ok(()) } #[test] fn named_multiline_fields_are_one_lossless_logical_field() -> 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); let plaintext = b"password\ncomments: Recovery codes:\none\ntwo\nurl: https://example.test\n"; write_plaintext(&repository, &keys, "documents/multiline", plaintext)?; let service = EntryDocumentService::new(&repository, &keys); let mut document = service.open("documents/multiline", &mut secrets)?; assert_eq!(document.serialize().expose(), plaintext); assert_eq!(document.fields().len(), 3); let comments = &document.fields()[1]; assert_eq!(comments.metadata().name(), Some("comments")); assert_eq!(comments.value(), b"Recovery codes:\none\ntwo"); assert_eq!( document.copy_field_value(comments.id())?.expose(), b"Recovery codes:\none\ntwo" ); document.replace_field_value(comments.id(), b"New heading\nalpha\nbeta".to_vec())?; assert_eq!( document.serialize().expose(), b"password\ncomments: New heading\n alpha\n beta\nurl: https://example.test\n" ); Ok(()) } #[test] fn only_whitespace_free_names_end_multiline_fields() -> 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); 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, "documents/comments", plaintext.as_bytes(), )?; let document = EntryDocumentService::new(&repository, &keys).open("documents/comments", &mut secrets)?; assert_eq!(document.serialize().expose(), plaintext.as_bytes()); 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" .as_bytes() ); assert_eq!( document.fields()[2].metadata().name(), 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) )); write_plaintext( &repository, &keys, "documents/plain-note", b"password\n\nordinary note\ncontinued", )?; let note = EntryDocumentService::new(&repository, &keys).open("documents/plain-note", &mut secrets)?; 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( &repository, &keys, "documents/unordered", unordered.as_bytes(), )?; let mut unordered_document = EntryDocumentService::new(&repository, &keys).open("documents/unordered", &mut secrets)?; assert_eq!( unordered_document.serialize().expose(), unordered.as_bytes() ); assert_eq!( unordered_document .display_fields() .into_iter() .map(|field| field.metadata().name().unwrap_or("note")) .collect::>(), [ "login", "password", "url", "otp", "note", "note", "comments", "alpha", "zeta" ] ); let comments = unordered_document .fields() .iter() .find(|field| field.metadata().name() == Some("comments")) .expect("comments") .id(); unordered_document .replace_field_value(comments, b"heading\nline: still a comment\n\nend".to_vec())?; assert_eq!( unordered_document.serialize().expose(), b"password\nzeta: last\nnote: named\ncomments: heading\n line: still a comment\n \n end\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\n ordinary note\nurl: https://example.test\nlogin: alice\nalpha: first\n" ); let normalized = unordered_document.serialize(); write_plaintext( &repository, &keys, "documents/normalized", normalized.expose(), )?; let normalized_document = EntryDocumentService::new(&repository, &keys).open("documents/normalized", &mut secrets)?; assert_eq!( normalized_document .fields() .iter() .find(|field| field.metadata().name() == Some("comments")) .expect("normalized comments") .value(), b"heading\nline: still a comment\n\nend" ); assert_eq!( normalized_document .fields() .iter() .find(|field| field.metadata().kind() == EntryFieldKind::Note) .expect("normalized note") .value(), b"ordinary note" ); Ok(()) } #[test] fn pass_otp_only_entries_keep_typed_metadata_in_the_first_line() -> 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/otp-only", b"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\n", )?; let document = EntryDocumentService::new(&repository, &keys).open("documents/otp-only", &mut secrets)?; assert_eq!(document.fields().len(), 1); assert_eq!( document.fields()[0].metadata().kind(), EntryFieldKind::OtpUri ); assert_eq!( document.fields()[0] .metadata() .otp() .and_then(|otp| otp.issuer()), Some("Example") ); Ok(()) } #[test] fn field_ids_survive_updates_removal_and_reordering() -> 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/editable", b"password\nusername: alice\nnote: one\nnote: two\n", )?; let service = EntryDocumentService::new(&repository, &keys); let mut document = service.open("documents/editable", &mut secrets)?; let password_id = document.fields()[0].id(); let username_id = document.fields()[1].id(); let note_one_id = document.fields()[2].id(); let note_two_id = document.fields()[3].id(); let token = document.conflict_token(); document.update( username_id, EntryFieldDraft::field("username", b"bob".to_vec())?, )?; let email_id = document.add( 2, EntryFieldDraft::field("email", "bob@例.test".as_bytes().to_vec())?, )?; document.reorder(note_two_id, 2)?; let removed = document.remove(note_one_id)?; assert_eq!(removed.id(), note_one_id); assert_eq!(document.conflict_token(), token); assert_eq!(document.fields()[0].id(), password_id); assert_eq!( document.field(username_id).expect("username").value(), b"bob" ); assert_eq!( document.field(email_id).expect("email").metadata().kind(), EntryFieldKind::Email ); assert_eq!(document.fields()[2].id(), note_two_id); assert_eq!( document.serialize().expose(), "password\nusername: bob\nnote: two\nemail: bob@例.test\n".as_bytes() ); let mut empty = service.open("documents/missing", &mut secrets)?; assert!(empty.fields().is_empty()); assert!(empty.password().is_none()); empty.add(0, EntryFieldDraft::line(b"password".to_vec())?)?; empty.add(1, EntryFieldDraft::blank())?; assert_eq!(empty.fields().len(), 2); assert_eq!(empty.fields()[1].metadata().kind(), EntryFieldKind::Note); assert_eq!(empty.serialize().expose(), b"password\n "); assert!(matches!( EntryFieldDraft::line(b"two\nlines".to_vec()), Err(DocumentError::LineBreak) )); assert!(matches!( EntryFieldDraft::field(" invalid ", Vec::new()), Err(DocumentError::InvalidFieldName) )); assert!(matches!( EntryFieldDraft::otp_uri(b"otpauth://totp/missing-secret".to_vec()), Err(DocumentError::InvalidOtpUri) )); Ok(()) } #[test] fn atomic_save_creates_updates_and_rejects_stale_documents() -> 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); let service = EntryDocumentService::new(&repository, &keys); let mut committer = Committer::default(); let mut created = service.open("documents/new", &mut secrets)?; created.add(0, EntryFieldDraft::line(b"new password".to_vec())?)?; created.add(1, EntryFieldDraft::field("username", b"alice".to_vec())?)?; service.save(created, None, &mut committer)?; assert_eq!(committer.changes.len(), 1); assert_eq!( decrypt(&repository, &keys, "documents/new", &mut secrets)?.expose(), b"new password\nusername: alice" ); let mut first = service.open("documents/new", &mut secrets)?; let mut stale = service.open("documents/new", &mut secrets)?; assert_eq!(first.conflict_token(), stale.conflict_token()); let first_password = first.password().expect("password").id(); first.update(first_password, EntryFieldDraft::line(b"winner".to_vec())?)?; service.save(first, None, &mut committer)?; let winner = repository.read_entry(&EntryPath::parse("documents/new")?)?; let stale_password = stale.password().expect("password").id(); stale.update(stale_password, EntryFieldDraft::line(b"stale".to_vec())?)?; assert!(matches!( service.save_recoverable(&stale, None, &mut committer), Err(DocumentError::Write( WriteError::ConcurrentModification { .. } )) )); assert_eq!(stale.serialize().expose(), b"stale\nusername: alice"); assert_eq!( repository.read_entry(&EntryPath::parse("documents/new")?)?, winner ); let mut rollback = service.open("documents/new", &mut secrets)?; let password = rollback.password().expect("password").id(); rollback.update(password, EntryFieldDraft::line(b"must roll back".to_vec())?)?; committer.fail = true; assert!(matches!( service.save_recoverable(&rollback, None, &mut committer), Err(DocumentError::Write(WriteError::Commit(_))) )); assert!( rollback .serialize() .expose() .starts_with(b"must roll back\n") ); assert_eq!( repository.read_entry(&EntryPath::parse("documents/new")?)?, winner ); committer.fail = false; std::fs::write( store.path().join(".gpg-id"), b"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\n", )?; let retained = rollback.serialize(); assert!( service .save_recoverable(&rollback, None, &mut committer) .is_err() ); assert_eq!(rollback.serialize().expose(), retained.expose()); assert_eq!( repository.read_entry(&EntryPath::parse("documents/new")?)?, winner ); Ok(()) } #[test] fn mobile_editor_creates_edits_generates_and_preserves_dynamic_fields() -> 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/mobile-editor", b"old password\nlogin: alice\ncomments: first\nsecond\nnote: repeated one\nnote: repeated two\nbinary: \xff\n", )?; let service = EntryDocumentService::new(&repository, &keys); let document = service.open("documents/mobile-editor", &mut secrets)?; let mut draft = MobileEntryDraft::new(document, false); let page = draft.page(); assert!(!page.dirty()); assert!(!page.creating()); assert_eq!(page.default_password_length(), 25); assert_eq!(page.fields().len(), 6); assert!(page.fields()[2].multiline()); assert_eq!(page.fields()[2].value(), Some("first\nsecond")); assert!(page.fields()[5].value().is_none()); let password = page.fields()[0].id(); assert!(matches!( draft.remove(password), Err(MobileEntryEditorError::ProtectedPasswordField) )); assert!(matches!( draft.reorder(password, 1), Err(MobileEntryEditorError::ProtectedPasswordField) )); assert!(draft.generate_password(Some(0), false).is_err()); let repeated_one = page.fields()[3].id(); let inputs = page .fields() .iter() .map(|field| { let (name, value) = if field.label() == "Login" { (Some("username".to_owned()), Some("bob".to_owned())) } else if field.label() == "Comments" { ( field.name().map(str::to_owned), Some("heading\nline: preserved\n\nend".to_owned()), ) } else { ( field.name().map(str::to_owned), field.value().map(str::to_owned), ) }; MobileEntryEditorInput::new(field.id(), name, value) }) .collect(); draft.apply(inputs)?; draft.add( MobileEntryEditorFieldKind::Field, Some("tags".to_owned()), "work, mobile".to_owned(), )?; draft.add( MobileEntryEditorFieldKind::Note, None, "open note\nsecond line".to_owned(), )?; draft.remove(repeated_one)?; let open_note = draft.page().fields().last().expect("open note").id(); draft.reorder(open_note, 2)?; draft.generate_password(Some(32), true)?; let edited = draft.page(); assert!(edited.dirty()); let password = edited.fields().first().expect("password").value().unwrap(); assert_eq!(password.len(), 32); assert!( password .chars() .all(|character| character.is_ascii_alphanumeric()) ); assert_eq!(edited.fields()[2].value(), Some("open note\nsecond line")); let mut committer = Committer::default(); service.save_recoverable(draft.document(), None, &mut committer)?; let reopened = service.open("documents/mobile-editor", &mut secrets)?; assert_eq!( reopened .fields() .iter() .filter(|field| field.metadata().name() == Some("note")) .count(), 1 ); assert!( reopened.fields().iter().any(|field| { field.metadata().name() == Some("username") && field.value() == b"bob" }) ); assert!(reopened.fields().iter().any(|field| { field.metadata().name() == Some("comments") && field.value() == b"heading\nline: preserved\n\nend" })); assert!(reopened.fields().iter().any(|field| { field.metadata().name() == Some("tags") && field.value() == b"work, mobile" })); assert!( reopened .fields() .iter() .any(|field| { field.metadata().name() == Some("binary") && field.value() == b"\xff" }) ); let mut created = service.open("documents/cancelled-mobile", &mut secrets)?; created.add(0, EntryFieldDraft::line(Vec::new())?)?; let created = MobileEntryDraft::new(created, true); assert!(created.page().creating()); assert!(created.page().dirty()); drop(created); assert!(matches!( repository.read_entry(&EntryPath::parse("documents/cancelled-mobile")?), Err(ironstorage::repository::RepositoryError::NotFound { .. }) )); Ok(()) } #[test] fn every_compatibility_entry_round_trips_as_a_document() -> TestResult { let fixture = FixtureSet::load()?; let stores = fixture .generated .entries .iter() .map(|entry| entry.store.as_str()) .collect::>(); for store_id in stores { let store = fixture.materialize_store(store_id)?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let mut secrets = FixtureSecrets::all(&fixture); let service = EntryDocumentService::new(&repository, &keys); for entry in fixture .generated .entries .iter() .filter(|entry| entry.store == store_id) { let logical = entry .path .strip_suffix(".gpg") .expect("fixture entry suffix"); let document = service.open(logical, &mut secrets)?; assert_eq!( document.serialize().expose(), fixture.read( std::path::Path::new("expected") .join(store_id) .join(&entry.plaintext), )?, "document round trip for {}/{}", store_id, entry.path ); } } Ok(()) } fn write_plaintext( repository: &Repository, keys: &KeyStore, path: &str, plaintext: &[u8], ) -> TestResult { let path = EntryPath::parse(path)?; let recipients = RecipientPolicyManager::new(repository, keys).resolve_for_entry(&path, None)?; let encrypted = keys.encrypt( SecretBytes::new(plaintext.to_vec()), recipients.recipients(), )?; repository.write_entry(&path, &encrypted)?; Ok(()) } fn decrypt( repository: &Repository, keys: &KeyStore, path: &str, secrets: &mut impl SecretProvider, ) -> Result> { Ok(keys.decrypt(&repository.read_entry(&EntryPath::parse(path)?)?, secrets)?) }