#![forbid(unsafe_code)] mod support; use std::{collections::BTreeMap, fs}; use ironstorage::{ command::{EditRequest, InsertInput, InsertRequest}, crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, repository::{EntryPath, Repository, SecretBytes}, write::{ EntryCommit, EntryCommitError, EntryCommitter, InsertContent, OverwriteDecision, VaultWriter, 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 RecordingCommitter { changes: Vec, fail: bool, } impl EntryCommitter for RecordingCommitter { fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> { self.changes.push(change.clone()); if self.fail { Err(EntryCommitError::new("simulated Git failure")) } else { Ok(()) } } } #[test] fn insert_content_modes_validate_and_redact_input() -> TestResult { let hidden = InsertContent::hidden(b"secret".to_vec(), b"secret".to_vec())?; assert_eq!(hidden.expose(), b"secret"); assert!(!format!("{hidden:?}").contains("secret")); assert!(matches!( InsertContent::hidden(b"first".to_vec(), b"second".to_vec()), Err(WriteError::ConfirmationMismatch) )); assert!(matches!( InsertContent::echoed(Vec::new()), Err(WriteError::EmptySingleLine) )); assert!(matches!( InsertContent::echoed(b"two\nlines".to_vec()), Err(WriteError::InvalidSingleLine) )); assert!(InsertContent::multiline(Vec::new()).expose().is_empty()); Ok(()) } #[test] fn insert_hidden_echoed_and_multiline_use_inherited_recipients() -> 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 writer = VaultWriter::new(&repository, &keys); let mut committer = RecordingCommitter::default(); for (path, input, contents) in [ ( "new/hidden", InsertInput::HiddenConfirmed, InsertContent::hidden(b"hidden".to_vec(), b"hidden".to_vec())?, ), ( "new/echoed", InsertInput::EchoedLine, InsertContent::echoed(b"echoed".to_vec())?, ), ( "new/multiline", InsertInput::Multiline, InsertContent::multiline(b"first\nsecond\n".to_vec()), ), ] { writer.insert( &insert(path, false, input), contents, OverwriteDecision::Decline, None, &mut committer, )?; } assert_eq!(committer.changes.len(), 3); assert_eq!( committer.changes[2].message(), "Add given password for new/multiline to store." ); let mut provider = FixtureSecrets::all(&fixture); let plaintext = keys.decrypt( &repository.read_entry(&EntryPath::parse("new/multiline")?)?, &mut provider, )?; assert_eq!(plaintext.expose(), b"first\nsecond\n"); let alice = fixture.key("alice")?; assert!(keys.is_encrypted_for( &repository.read_entry(&EntryPath::parse("new/hidden")?)?, &[keys.resolve(&alice.primary_fingerprint)?] )?); Ok(()) } #[test] fn overwrite_decision_force_and_commit_failure_are_transactional() -> 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 writer = VaultWriter::new(&repository, &keys); let path = EntryPath::parse("email/personal")?; let original = repository.read_entry(&path)?; let mut committer = RecordingCommitter::default(); assert!(matches!( writer.insert( &insert("email/personal", false, InsertInput::EchoedLine), InsertContent::echoed(b"declined".to_vec())?, OverwriteDecision::Decline, None, &mut committer, ), Err(WriteError::Cancelled) )); assert_eq!(repository.read_entry(&path)?, original); writer.insert( &insert("email/personal", true, InsertInput::EchoedLine), InsertContent::echoed(b"forced".to_vec())?, OverwriteDecision::Decline, None, &mut committer, )?; let forced = repository.read_entry(&path)?; assert_ne!(forced, original); committer.fail = true; assert!(matches!( writer.insert( &insert("email/personal", true, InsertInput::EchoedLine), InsertContent::echoed(b"rollback".to_vec())?, OverwriteDecision::Allow, None, &mut committer, ), Err(WriteError::Commit(_)) )); assert_eq!(repository.read_entry(&path)?, forced); assert!(matches!( writer.insert( &insert("temporary/deep/entry", false, InsertInput::Multiline), InsertContent::multiline(b"new".to_vec()), OverwriteDecision::Allow, None, &mut committer, ), Err(WriteError::Commit(_)) )); assert!(!store.path().join("temporary").exists()); Ok(()) } #[test] fn edit_session_handles_replacement_unchanged_and_commit_failure() -> 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 writer = VaultWriter::new(&repository, &keys); let request = EditRequest { entry: "email/personal".to_owned(), }; let path = EntryPath::parse("email/personal")?; let mut provider = FixtureSecrets::all(&fixture); let mut committer = RecordingCommitter::default(); let unchanged = writer.begin_edit(&request, &mut provider)?; let same = SecretBytes::new(unchanged.plaintext().expose().to_vec()); assert!(matches!( writer.finish_edit(unchanged, same, "fixture-editor", None, &mut committer), Err(WriteError::Unchanged) )); assert!(committer.changes.is_empty()); let session = writer.begin_edit(&request, &mut provider)?; writer.finish_edit( session, SecretBytes::new(b"edited\nlogin: replacement\n".to_vec()), "fixture-editor", None, &mut committer, )?; assert_eq!( committer.changes[0].message(), "Edit password for email/personal using fixture-editor." ); let edited = repository.read_entry(&path)?; let plaintext = keys.decrypt(&edited, &mut provider)?; assert_eq!(plaintext.expose(), b"edited\nlogin: replacement\n"); let session = writer.begin_edit(&request, &mut provider)?; committer.fail = true; assert!(matches!( writer.finish_edit( session, SecretBytes::new(b"must roll back".to_vec()), "fixture-editor", None, &mut committer, ), Err(WriteError::Commit(_)) )); assert_eq!(repository.read_entry(&path)?, edited); Ok(()) } #[test] fn edit_detects_concurrent_ciphertext_change_before_writing() -> 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 writer = VaultWriter::new(&repository, &keys); let request = EditRequest { entry: "email/personal".to_owned(), }; let path = EntryPath::parse("email/personal")?; let mut provider = FixtureSecrets::all(&fixture); let session = writer.begin_edit(&request, &mut provider)?; let mut committer = RecordingCommitter::default(); writer.insert( &insert("email/personal", true, InsertInput::EchoedLine), InsertContent::echoed(b"concurrent".to_vec())?, OverwriteDecision::Allow, None, &mut committer, )?; let concurrent = repository.read_entry(&path)?; assert!(matches!( writer.finish_edit( session, SecretBytes::new(b"stale editor".to_vec()), "fixture-editor", None, &mut committer, ), Err(WriteError::ConcurrentModification { .. }) )); assert_eq!(repository.read_entry(&path)?, concurrent); Ok(()) } #[test] fn edit_session_can_create_a_new_entry_and_detect_one_appearing_concurrently() -> 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 writer = VaultWriter::new(&repository, &keys); let request = EditRequest { entry: "new/from-editor".to_owned(), }; let mut provider = FixtureSecrets::all(&fixture); let session = writer.begin_edit(&request, &mut provider)?; assert!(session.plaintext().expose().is_empty()); let mut committer = RecordingCommitter::default(); writer.finish_edit( session, SecretBytes::new(b"created by editor\n".to_vec()), "fixture-editor", None, &mut committer, )?; assert!(store.path().join("new/from-editor.gpg").is_file()); let concurrent_request = EditRequest { entry: "new/concurrent-editor".to_owned(), }; let session = writer.begin_edit(&concurrent_request, &mut provider)?; writer.insert( &insert("new/concurrent-editor", false, InsertInput::EchoedLine), InsertContent::echoed(b"appeared".to_vec())?, OverwriteDecision::Allow, None, &mut committer, )?; assert!(matches!( writer.finish_edit( session, SecretBytes::new(b"stale".to_vec()), "fixture-editor", None, &mut committer, ), Err(WriteError::ConcurrentModification { .. }) )); Ok(()) } #[test] fn invalid_recipient_policy_fails_before_entry_creation() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; fs::create_dir(store.path().join("invalid"))?; fs::write( store.path().join("invalid/.gpg-id"), b"missing@ironstorage.invalid\n", )?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let writer = VaultWriter::new(&repository, &keys); assert!(matches!( writer.insert( &insert("invalid/entry", false, InsertInput::Multiline), InsertContent::multiline(b"secret".to_vec()), OverwriteDecision::Allow, None, &mut RecordingCommitter::default(), ), Err(WriteError::RecipientPolicy( ironstorage::recipient::RecipientPolicyError::Crypto( CryptoError::MissingIdentity { .. } ) )) )); assert!(!store.path().join("invalid/entry.gpg").exists()); Ok(()) } fn insert(entry: &str, force: bool, input: InsertInput) -> InsertRequest { InsertRequest { entry: entry.to_owned(), input, force, } }