#![forbid(unsafe_code)] mod support; use std::collections::{BTreeMap, BTreeSet}; use ironstorage::{ crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, document::{ DocumentError, EntryDocumentService, EntryFieldDraft, EntryFieldKind, EntrySensitivity, }, 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\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(), 10); let kinds = document .fields() .iter() .map(|field| field.metadata().kind()) .collect::>(); assert_eq!( kinds, [ EntryFieldKind::Password, EntryFieldKind::Username, EntryFieldKind::Field, EntryFieldKind::Field, EntryFieldKind::OtpUri, EntryFieldKind::Blank, EntryFieldKind::Note, EntryFieldKind::Note, EntryFieldKind::Field, EntryFieldKind::Note, ] ); assert_eq!(document.fields()[1].metadata().name(), Some("username")); assert_eq!( document.fields()[1].metadata().sensitivity(), EntrySensitivity::Ordinary ); assert_eq!(document.fields()[2].metadata().name(), Some("custom")); assert_eq!(document.fields()[2].value(), b"one"); assert_eq!(document.fields()[3].metadata().name(), Some("custom")); assert!(document.fields()[3].value().is_empty()); assert_ne!(document.fields()[2].id(), document.fields()[3].id()); assert_eq!(document.fields()[8].metadata().name(), Some("鍵")); assert_eq!(document.fields()[8].value(), "值".as_bytes()); assert_eq!( document.fields()[4].metadata().sensitivity(), EntrySensitivity::Sensitive ); let otp = document.fields()[4] .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()[2].id())?; assert_eq!(copied.expose(), b"one"); assert!(!format!("{document:?}").contains("pässwörd")); assert!(!format!("{:?}", document.fields()[4]).contains("JBSWY3DPEHPK3PXP")); 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::Blank); 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 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)?) }