#![forbid(unsafe_code)] mod support; use std::{collections::BTreeMap, fs, path::Path}; use ironstorage::{ command::{CopyRequest, MoveRequest, RemoveRequest}, crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, mutation::{ MutationAction, MutationError, MutationSelection, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator, }, repository::{DirectoryPath, EntryPath, Repository, SecretBytes}, write::OverwriteDecision, }; use support::compatibility::{FixtureSet, TestResult}; struct Secrets(BTreeMap>); impl Secrets { 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 Secrets { 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 TreeCommitter for Committer { fn commit(&mut self, change: &TreeCommit) -> Result<(), TreeCommitError> { self.changes.push(change.clone()); if self.fail { Err(TreeCommitError::new("simulated")) } else { Ok(()) } } } #[test] fn remove_entry_supports_confirmation_force_and_empty_cleanup() -> 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 committer = Committer::default(); let mutator = TreeMutator::new(&repository, &keys); let request = remove("email/personal", false, false); assert!(matches!( mutator.remove(&request, OverwriteDecision::Decline, &mut committer), Err(MutationError::Cancelled) )); assert!(store.path().join("email/personal.gpg").is_file()); let outcome = mutator.remove(&request, OverwriteDecision::Allow, &mut committer)?; assert_eq!(outcome.action(), MutationAction::Remove); assert!(!store.path().join("email").exists()); assert_eq!( committer.changes[0].message(), "Remove email/personal from store." ); let forced = remove("unicode/咖啡", false, true); mutator.remove(&forced, OverwriteDecision::Decline, &mut committer)?; assert!(!store.path().join("unicode").exists()); Ok(()) } #[test] fn recursive_remove_deletes_entries_and_nested_policy_boundary() -> 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 mutator = TreeMutator::new(&repository, &keys); let mut committer = Committer::default(); assert!(matches!( mutator.remove( &remove("team/", false, false), OverwriteDecision::Allow, &mut committer ), Err(MutationError::RecursiveRequired) )); let outcome = mutator.remove( &remove("team/", true, false), OverwriteDecision::Allow, &mut committer, )?; assert_eq!(outcome.entries(), &[EntryPath::parse("team/service")?]); assert!(!store.path().join("team").exists()); assert_eq!(committer.changes[0].message(), "Remove team/ from store."); Ok(()) } #[test] fn copy_preserves_source_and_reencrypts_for_destination_policy() -> 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 provider = Secrets::all(&fixture); let mut committer = Committer::default(); let mutator = TreeMutator::new(&repository, &keys); let source = repository.read_entry(&EntryPath::parse("email/personal")?)?; let outcome = mutator.copy( &CopyRequest { source: "email/personal".into(), destination: "team/personal".into(), force: false, }, OverwriteDecision::Allow, None, &mut provider, &mut committer, )?; assert_eq!( outcome.selection(), Some(&MutationSelection::Entry(EntryPath::parse( "team/personal" )?)) ); assert_eq!( repository.read_entry(&EntryPath::parse("email/personal")?)?, source ); let bob = fixture.key("bob")?; let destination = repository.read_entry(&EntryPath::parse("team/personal")?)?; assert!(keys.is_encrypted_for(&destination, &[keys.resolve(&bob.primary_fingerprint)?])?); let plaintext = keys.decrypt(&destination, &mut provider)?; assert_eq!( plaintext.expose(), fixture.read("expected/basic/email/personal.txt")? ); assert_eq!( committer.changes[0].message(), "Copy email/personal to team/personal." ); assert_eq!( committer.changes[0].changed_paths(), &[Path::new("team/personal.gpg").to_owned()] ); Ok(()) } #[test] fn existing_and_trailing_slash_destinations_have_directory_semantics() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; fs::create_dir(store.path().join("archive"))?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let mut provider = Secrets::all(&fixture); let mutator = TreeMutator::new(&repository, &keys); let copied = mutator.copy( &CopyRequest { source: "email/personal".into(), destination: "archive/".into(), force: false, }, OverwriteDecision::Allow, None, &mut provider, &mut Committer::default(), )?; assert_eq!( copied.selection(), Some(&MutationSelection::Entry(EntryPath::parse( "archive/personal" )?)) ); assert!(store.path().join("archive/personal.gpg").is_file()); let copied_directory = mutator.copy( &CopyRequest { source: "team/".into(), destination: "archive/".into(), force: false, }, OverwriteDecision::Allow, None, &mut provider, &mut Committer::default(), )?; assert_eq!( copied_directory.selection(), Some(&MutationSelection::Directory(DirectoryPath::parse( "archive/team" )?)) ); assert!(store.path().join("archive/team/service.gpg").is_file()); assert!(store.path().join("team/service.gpg").is_file()); assert!(matches!( mutator.copy( &CopyRequest { source: "email/personal".into(), destination: "missing/".into(), force: false, }, OverwriteDecision::Allow, None, &mut provider, &mut Committer::default() ), Err(MutationError::DestinationDirectoryMissing { .. }) )); assert!(!store.path().join("missing").exists()); Ok(()) } #[test] fn empty_subdirectories_are_preserved_and_moved_as_part_of_the_tree() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; fs::create_dir_all(store.path().join("holding/empty/nested"))?; fs::create_dir(store.path().join("archive"))?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let mut provider = Secrets::all(&fixture); let mutator = TreeMutator::new(&repository, &keys); mutator.copy( &CopyRequest { source: "holding/empty/".into(), destination: "archive/".into(), force: false, }, OverwriteDecision::Allow, None, &mut provider, &mut Committer::default(), )?; assert!(store.path().join("holding/empty/nested").is_dir()); assert!(store.path().join("archive/empty/nested").is_dir()); mutator.move_tree( &MoveRequest { source: "holding/empty/".into(), destination: "relocated".into(), force: false, }, OverwriteDecision::Allow, None, &mut provider, &mut Committer::default(), )?; assert!(!store.path().join("holding").exists()); assert!(store.path().join("relocated/nested").is_dir()); Ok(()) } #[test] fn entry_collisions_require_confirmation_unless_forced() -> 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 provider = Secrets::all(&fixture); let mutator = TreeMutator::new(&repository, &keys); let destination = EntryPath::parse("otp/totp")?; let original = repository.read_entry(&destination)?; let request = CopyRequest { source: "email/personal".into(), destination: "otp/totp".into(), force: false, }; assert!(matches!( mutator.copy( &request, OverwriteDecision::Decline, None, &mut provider, &mut Committer::default() ), Err(MutationError::Cancelled) )); assert_eq!(repository.read_entry(&destination)?, original); mutator.copy( &CopyRequest { force: true, ..request }, OverwriteDecision::Decline, None, &mut provider, &mut Committer::default(), )?; let replaced = keys.decrypt(&repository.read_entry(&destination)?, &mut provider)?; assert_eq!( replaced.expose(), fixture.read("expected/basic/email/personal.txt")? ); Ok(()) } #[test] fn mutation_prefers_an_entry_over_its_same_named_directory() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; fs::create_dir(store.path().join("email/personal"))?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let mut provider = Secrets::all(&fixture); let mutator = TreeMutator::new(&repository, &keys); mutator.copy( &CopyRequest { source: "email/personal".into(), destination: "elsewhere".into(), force: false, }, OverwriteDecision::Allow, None, &mut provider, &mut Committer::default(), )?; assert!(store.path().join("email/personal").is_dir()); assert_eq!( keys.decrypt( &repository.read_entry(&EntryPath::parse("elsewhere")?)?, &mut provider, )? .expose(), fixture.read("expected/basic/email/personal.txt")? ); assert!(matches!( mutator.copy( &CopyRequest { source: "team/service".into(), destination: "team/service".into(), force: true, }, OverwriteDecision::Allow, None, &mut provider, &mut Committer::default() ), Err(MutationError::SameObject) )); Ok(()) } #[test] fn move_writes_durable_destination_before_removing_source() -> 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 provider = Secrets::all(&fixture); let mut committer = Committer::default(); let mutator = TreeMutator::new(&repository, &keys); mutator.move_tree( &MoveRequest { source: "email/personal".into(), destination: "archive/personal".into(), force: false, }, OverwriteDecision::Allow, None, &mut provider, &mut committer, )?; assert!(!store.path().join("email").exists()); assert!(store.path().join("archive/personal.gpg").is_file()); assert_eq!( committer.changes[0].message(), "Rename email/personal to archive/personal." ); assert_eq!( committer.changes[0].changed_paths(), &[ Path::new("archive/personal.gpg").to_owned(), Path::new("email/personal.gpg").to_owned(), ] ); Ok(()) } #[test] fn directory_copy_preserves_entries_and_recipient_files() -> 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 provider = Secrets::all(&fixture); let mutator = TreeMutator::new(&repository, &keys); mutator.copy( &CopyRequest { source: "team/".into(), destination: "archive/team".into(), force: false, }, OverwriteDecision::Allow, None, &mut provider, &mut Committer::default(), )?; assert!(store.path().join("team/service.gpg").is_file()); assert_eq!( fs::read(store.path().join("archive/team/.gpg-id"))?, fs::read(store.path().join("team/.gpg-id"))? ); assert_eq!( fs::read(store.path().join("archive/team/.gpg-id.sig"))?, fs::read(store.path().join("team/.gpg-id.sig"))? ); assert!(store.path().join("archive/team/service.gpg").is_file()); Ok(()) } #[test] fn collisions_auxiliary_files_and_commit_failure_never_leave_partial_trees() -> 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 provider = Secrets::all(&fixture); let mutator = TreeMutator::new(&repository, &keys); let before = tree_bytes(store.path())?; let mut failing = Committer { fail: true, ..Committer::default() }; assert!(matches!( mutator.move_tree( &MoveRequest { source: "email/personal".into(), destination: "archive/personal".into(), force: false }, OverwriteDecision::Allow, None, &mut provider, &mut failing ), Err(MutationError::Commit(_)) )); assert_eq!(tree_bytes(store.path())?, before); assert!(matches!( mutator.copy( &CopyRequest { source: "email/personal".into(), destination: "otp/totp".into(), force: true, }, OverwriteDecision::Decline, None, &mut provider, &mut failing ), Err(MutationError::Commit(_)) )); assert_eq!(tree_bytes(store.path())?, before); assert!(matches!( mutator.move_tree( &MoveRequest { source: "team/".into(), destination: "archive/team".into(), force: false, }, OverwriteDecision::Allow, None, &mut provider, &mut failing ), Err(MutationError::Commit(_)) )); assert_eq!(tree_bytes(store.path())?, before); fs::write(store.path().join("team/notes.txt"), b"auxiliary")?; assert!(matches!( mutator.copy( &CopyRequest { source: "team/".into(), destination: "archive/team".into(), force: false }, OverwriteDecision::Allow, None, &mut provider, &mut Committer::default() ), Err(MutationError::UnsupportedAuxiliary { .. }) )); assert!(!store.path().join("archive").exists()); Ok(()) } fn remove(entry: &str, recursive: bool, force: bool) -> RemoveRequest { RemoveRequest { entry: entry.into(), recursive, force, } } fn tree_bytes(root: &Path) -> TestResult>> { fn visit( root: &Path, current: &Path, output: &mut BTreeMap>, ) -> TestResult { for entry in fs::read_dir(current)? { let entry = entry?; if entry.file_type()?.is_dir() { visit(root, &entry.path(), output)?; } else { output.insert( entry.path().strip_prefix(root)?.into(), fs::read(entry.path())?, ); } } Ok(()) } let mut output = BTreeMap::new(); visit(root, root, &mut output)?; Ok(output) }