From 410007c012f3f8e8dbca2bcbf2dad7e72b829898 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 9 Aug 2026 22:58:37 +0000 Subject: [PATCH] Implement rollback-safe tree mutations --- README.md | 3 + crates/storage/src/lib.rs | 1 + crates/storage/src/mutation.rs | 771 +++++++++++++++++++++++++ crates/storage/src/repository.rs | 44 ++ crates/storage/tests/tree_mutations.rs | 513 ++++++++++++++++ docs/tree-mutations.md | 36 ++ 6 files changed, 1368 insertions(+) create mode 100644 crates/storage/src/mutation.rs create mode 100644 crates/storage/tests/tree_mutations.rs create mode 100644 docs/tree-mutations.md diff --git a/README.md b/README.md index b053b2d..759537e 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ boundary are documented in [`docs/write-domains.md`](docs/write-domains.md). Unbiased password generation, character-set validation, in-place replacement, and presentation actions are documented in [`docs/password-generation.md`](docs/password-generation.md). +Rollback-safe remove, move, and copy transactions, destination rules, and +selective subtree reencryption are documented in +[`docs/tree-mutations.md`](docs/tree-mutations.md). ## Project layout diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index f22ae31..6cd2170 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -9,6 +9,7 @@ pub mod command; pub mod config; pub mod crypto; pub mod generate; +pub mod mutation; pub mod read; pub mod recipient; pub mod repository; diff --git a/crates/storage/src/mutation.rs b/crates/storage/src/mutation.rs new file mode 100644 index 0000000..3f02ed5 --- /dev/null +++ b/crates/storage/src/mutation.rs @@ -0,0 +1,771 @@ +//! Rollback-safe remove, move, and copy transactions. + +use std::{error::Error, fmt}; + +use crate::{ + command::{CopyRequest, MoveRequest, RemoveRequest}, + crypto::{KeyStore, SecretProvider}, + recipient::{RecipientPolicyManager, SigningPolicy}, + repository::{ + DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, ResolvedObject, + }, + write::OverwriteDecision, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MutationAction { + Remove, + Move, + Copy, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TreeCommit { + action: MutationAction, + source: String, + destination: Option, + message: String, +} + +impl TreeCommit { + pub fn action(&self) -> MutationAction { + self.action + } + pub fn source(&self) -> &str { + &self.source + } + pub fn destination(&self) -> Option<&str> { + self.destination.as_deref() + } + pub fn message(&self) -> &str { + &self.message + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TreeCommitError(String); + +impl TreeCommitError { + pub fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl fmt::Display for TreeCommitError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl Error for TreeCommitError {} + +/// Atomically records a completed tree mutation. +/// +/// An error must mean that no commit was created. `TreeMutator` restores the +/// filesystem transaction when an error is returned. +pub trait TreeCommitter { + fn commit(&mut self, change: &TreeCommit) -> Result<(), TreeCommitError>; +} + +#[derive(Default)] +pub struct NoGitTreeCommitter; + +impl TreeCommitter for NoGitTreeCommitter { + fn commit(&mut self, _change: &TreeCommit) -> Result<(), TreeCommitError> { + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MutationOutcome { + action: MutationAction, + entries: Vec, +} + +impl MutationOutcome { + pub fn action(&self) -> MutationAction { + self.action + } + pub fn entries(&self) -> &[EntryPath] { + &self.entries + } +} + +pub struct TreeMutator<'a> { + repository: &'a Repository, + keys: &'a KeyStore, +} + +impl<'a> TreeMutator<'a> { + pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self { + Self { repository, keys } + } + + pub fn remove( + &self, + request: &RemoveRequest, + decision: OverwriteDecision, + committer: &mut impl TreeCommitter, + ) -> Result { + let snapshot = self.repository.snapshot()?; + let resolved = snapshot.resolve(&request.entry)?; + if !request.force && decision == OverwriteDecision::Decline { + return Err(MutationError::Cancelled); + } + let (entries, policies, directories, cleanup, display) = match resolved { + ResolvedObject::Entry(record) => ( + vec![EntryState::source( + record.path().clone(), + self.repository.read_entry(record.path())?, + )], + Vec::new(), + Vec::new(), + record.path().parent_directory(), + record.path().to_string(), + ), + ResolvedObject::Directory(record) => { + if record.path().as_path().as_os_str().is_empty() { + return Err(MutationError::RootMutation); + } + if !request.recursive { + return Err(MutationError::RecursiveRequired); + } + self.reject_unmanaged(record.path())?; + ( + self.source_entries(record.path())?, + self.source_policies(record.path())?, + self.source_directories(record.path())?, + record.path().clone(), + format!("{}/", record.path()), + ) + } + }; + if let Err(operation) = self.remove_sources(&entries, &policies, &directories) { + self.restore_sources(&entries, &policies, &directories)?; + return Err(operation); + } + let change = TreeCommit { + action: MutationAction::Remove, + source: display.clone(), + destination: None, + message: format!("Remove {display} from store."), + }; + if let Err(error) = committer.commit(&change) { + self.restore_sources(&entries, &policies, &directories)?; + return Err(MutationError::Commit(error)); + } + self.repository.cleanup_empty_directories(&cleanup)?; + Ok(MutationOutcome { + action: MutationAction::Remove, + entries: entries.into_iter().map(|entry| entry.source).collect(), + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn copy( + &self, + request: &CopyRequest, + decision: OverwriteDecision, + signing: Option<&SigningPolicy>, + provider: &mut impl SecretProvider, + committer: &mut impl TreeCommitter, + ) -> Result { + self.transfer( + &request.source, + &request.destination, + request.force, + decision, + false, + signing, + provider, + committer, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn move_tree( + &self, + request: &MoveRequest, + decision: OverwriteDecision, + signing: Option<&SigningPolicy>, + provider: &mut impl SecretProvider, + committer: &mut impl TreeCommitter, + ) -> Result { + self.transfer( + &request.source, + &request.destination, + request.force, + decision, + true, + signing, + provider, + committer, + ) + } + + #[allow(clippy::too_many_arguments)] + fn transfer( + &self, + source: &str, + destination: &str, + force: bool, + decision: OverwriteDecision, + moving: bool, + signing: Option<&SigningPolicy>, + provider: &mut impl SecretProvider, + committer: &mut impl TreeCommitter, + ) -> Result { + let snapshot = self.repository.snapshot()?; + let resolved = snapshot.resolve(source)?; + let requested_destination = DirectoryPath::parse(destination)?; + let destination_is_directory = snapshot + .directories() + .any(|record| record.path() == &requested_destination); + if has_trailing_separator(destination) && !destination_is_directory { + return Err(MutationError::DestinationDirectoryMissing { + directory: requested_destination, + }); + } + let ( + source_entries, + source_policies, + source_directories, + source_root, + destination_root, + destination_entry, + ) = match resolved { + ResolvedObject::Entry(record) => ( + vec![EntryState::source( + record.path().clone(), + self.repository.read_entry(record.path())?, + )], + Vec::new(), + Vec::new(), + record.path().parent_directory(), + if destination_is_directory { + requested_destination.clone() + } else { + EntryPath::parse(destination)?.parent_directory() + }, + Some(if destination_is_directory { + let name = record + .path() + .as_path() + .file_name() + .expect("validated entry has a file name"); + EntryPath::parse(requested_destination.as_path().join(name))? + } else { + EntryPath::parse(destination)? + }), + ), + ResolvedObject::Directory(record) => { + if record.path().as_path().as_os_str().is_empty() { + return Err(MutationError::RootMutation); + } + let destination = if destination_is_directory { + let name = record + .path() + .as_path() + .file_name() + .expect("non-root directory has a file name"); + DirectoryPath::parse(requested_destination.as_path().join(name))? + } else { + requested_destination + }; + if destination == *record.path() { + return Err(MutationError::SameObject); + } + if destination.as_path().starts_with(record.path().as_path()) { + return Err(MutationError::DestinationInsideSource); + } + if snapshot + .entries() + .any(|entry| entry.path().as_path() == destination.as_path()) + { + return Err(MutationError::DestinationTypeCollision { + path: destination.as_path().to_owned(), + }); + } + self.reject_unmanaged(record.path())?; + ( + self.source_entries(record.path())?, + self.source_policies(record.path())?, + self.source_directories(record.path())?, + record.path().clone(), + destination, + None, + ) + } + }; + if destination_entry + .as_ref() + .is_some_and(|destination| destination == &source_entries[0].source) + { + return Err(MutationError::SameObject); + } + let mut entries = Vec::new(); + for source_entry in source_entries { + let destination_path = if let Some(destination) = &destination_entry { + destination.clone() + } else { + let relative = source_entry + .source + .as_path() + .strip_prefix(source_root.as_path()) + .expect("source prefix"); + EntryPath::parse(destination_root.as_path().join(relative))? + }; + if snapshot + .directories() + .any(|directory| directory.path().as_path() == destination_path.as_path()) + { + return Err(MutationError::DestinationTypeCollision { + path: destination_path.as_path().to_owned(), + }); + } + let old_destination = match self.repository.read_entry(&destination_path) { + Ok(ciphertext) => Some(ciphertext), + Err(RepositoryError::NotFound { .. }) => None, + Err(error) => return Err(error.into()), + }; + if old_destination.is_some() && !force && decision == OverwriteDecision::Decline { + return Err(MutationError::Cancelled); + } + let recipients = if destination_entry.is_none() + && snapshot + .recipient_policy(&source_entry.source) + .is_some_and(|policy| { + policy + .directory() + .as_path() + .starts_with(source_root.as_path()) + }) { + RecipientPolicyManager::new(self.repository, self.keys) + .resolve_for_entry(&source_entry.source, signing)? + } else { + RecipientPolicyManager::new(self.repository, self.keys) + .resolve_for_entry(&destination_path, signing)? + }; + let replacement = if self + .keys + .is_encrypted_for(&source_entry.ciphertext, recipients.recipients())? + { + source_entry.ciphertext.clone() + } else { + let plaintext = self.keys.decrypt(&source_entry.ciphertext, provider)?; + self.keys.encrypt(plaintext, recipients.recipients())? + }; + entries.push(TransferEntry { + source: source_entry, + destination: destination_path, + replacement, + old_destination, + }); + } + let mut policies = Vec::new(); + for policy in source_policies { + let relative = policy + .directory + .as_path() + .strip_prefix(source_root.as_path()) + .expect("policy prefix"); + let destination_directory = + DirectoryPath::parse(destination_root.as_path().join(relative))?; + let old_recipients = self + .repository + .read_policy_file(&destination_directory, false)?; + let old_signature = self + .repository + .read_policy_file(&destination_directory, true)?; + if old_recipients.is_some() || old_signature.is_some() { + return Err(MutationError::UnsafePolicyOverwrite { + directory: destination_directory, + }); + } + policies.push(TransferPolicy { + source: policy, + destination: destination_directory, + }); + } + + let source_entries = entries + .iter() + .map(|entry| entry.source.clone()) + .collect::>(); + let source_policies = policies + .iter() + .map(|policy| policy.source.clone()) + .collect::>(); + let destination_directories = if let Some(entry) = &destination_entry { + vec![entry.parent_directory()] + } else { + source_directories + .iter() + .map(|directory| { + let relative = directory + .as_path() + .strip_prefix(source_root.as_path()) + .expect("directory prefix"); + DirectoryPath::parse(destination_root.as_path().join(relative)) + }) + .collect::, _>>()? + }; + let mut created_directories = Vec::new(); + let applied = (|| -> Result<(), MutationError> { + for directory in &destination_directories { + created_directories.extend(self.repository.ensure_directory(directory)?); + } + for entry in &entries { + self.repository + .write_entry(&entry.destination, &entry.replacement)?; + } + for policy in &policies { + self.repository.replace_policy_file( + &policy.destination, + false, + Some(&policy.source.recipients), + )?; + if let Some(signature) = &policy.source.signature { + self.repository.replace_policy_file( + &policy.destination, + true, + Some(signature), + )?; + } + } + if moving { + self.remove_sources(&source_entries, &source_policies, &source_directories)?; + } + Ok(()) + })(); + if let Err(operation) = applied { + if moving { + self.restore_sources(&source_entries, &source_policies, &source_directories)?; + } + self.restore_destinations(&entries, &policies)?; + self.remove_created_directories(&created_directories)?; + return Err(operation); + } + let action = if moving { + MutationAction::Move + } else { + MutationAction::Copy + }; + let verb = if moving { "Rename" } else { "Copy" }; + let change = TreeCommit { + action, + source: source.to_owned(), + destination: Some(destination.to_owned()), + message: format!("{verb} {source} to {destination}."), + }; + if let Err(error) = committer.commit(&change) { + if moving { + self.restore_sources(&source_entries, &source_policies, &source_directories)?; + } + self.restore_destinations(&entries, &policies)?; + self.remove_created_directories(&created_directories)?; + return Err(MutationError::Commit(error)); + } + if moving { + self.repository.cleanup_empty_directories(&source_root)?; + } + Ok(MutationOutcome { + action, + entries: entries.into_iter().map(|entry| entry.destination).collect(), + }) + } + + fn source_entries(&self, root: &DirectoryPath) -> Result, MutationError> { + self.repository + .snapshot()? + .entries() + .filter(|entry| entry.path().as_path().starts_with(root.as_path())) + .map(|entry| { + Ok(EntryState::source( + entry.path().clone(), + self.repository.read_entry(entry.path())?, + )) + }) + .collect() + } + + fn source_policies(&self, root: &DirectoryPath) -> Result, MutationError> { + self.repository + .snapshot()? + .recipient_policies() + .filter(|policy| policy.directory().as_path().starts_with(root.as_path())) + .map(|policy| { + Ok(PolicyState { + directory: policy.directory().clone(), + recipients: self + .repository + .read_policy_file(policy.directory(), false)? + .expect("snapshot policy exists"), + signature: self.repository.read_policy_file(policy.directory(), true)?, + }) + }) + .collect() + } + + fn source_directories( + &self, + root: &DirectoryPath, + ) -> Result, MutationError> { + Ok(self + .repository + .snapshot()? + .directories() + .filter(|directory| directory.path().as_path().starts_with(root.as_path())) + .map(|directory| directory.path().clone()) + .collect()) + } + + fn reject_unmanaged(&self, root: &DirectoryPath) -> Result<(), MutationError> { + let snapshot = self.repository.snapshot()?; + if let Some(file) = snapshot + .auxiliary_files() + .find(|file| file.path().starts_with(root.as_path())) + { + return Err(MutationError::UnsupportedAuxiliary { + path: file.path().to_owned(), + }); + } + if let Some(repository) = snapshot + .git_repositories() + .find(|repository| repository.work_tree().as_path().starts_with(root.as_path())) + { + return Err(MutationError::UnsupportedGitRepository { + path: repository.git_directory().to_owned(), + }); + } + if let Some(path) = snapshot + .collisions() + .find(|path| path.starts_with(root.as_path())) + { + return Err(MutationError::SourceTypeCollision { + path: (*path).clone(), + }); + } + Ok(()) + } + + fn remove_sources( + &self, + entries: &[EntryState], + policies: &[PolicyState], + directories: &[DirectoryPath], + ) -> Result<(), MutationError> { + for entry in entries.iter().rev() { + self.repository.remove_entry(&entry.source)?; + } + for policy in policies.iter().rev() { + if policy.signature.is_some() { + self.repository + .replace_policy_file(&policy.directory, true, None)?; + } + self.repository + .replace_policy_file(&policy.directory, false, None)?; + } + for directory in directories.iter().rev() { + if !self.repository.remove_empty_directory(directory)? { + return Err(MutationError::TreeChanged { + path: directory.as_path().to_owned(), + }); + } + } + Ok(()) + } + + fn restore_sources( + &self, + entries: &[EntryState], + policies: &[PolicyState], + directories: &[DirectoryPath], + ) -> Result<(), MutationError> { + for directory in directories { + self.repository.ensure_directory(directory)?; + } + for policy in policies { + self.repository.replace_policy_file( + &policy.directory, + false, + Some(&policy.recipients), + )?; + if let Some(signature) = &policy.signature { + self.repository + .replace_policy_file(&policy.directory, true, Some(signature))?; + } + } + for entry in entries { + self.repository + .write_entry(&entry.source, &entry.ciphertext)?; + } + Ok(()) + } + + fn remove_created_directories( + &self, + directories: &[DirectoryPath], + ) -> Result<(), MutationError> { + for directory in directories.iter().rev() { + if !self.repository.remove_empty_directory(directory)? { + return Err(MutationError::TreeChanged { + path: directory.as_path().to_owned(), + }); + } + } + Ok(()) + } + + fn restore_destinations( + &self, + entries: &[TransferEntry], + policies: &[TransferPolicy], + ) -> Result<(), MutationError> { + for policy in policies.iter().rev() { + if policy.source.signature.is_some() { + self.repository + .replace_policy_file(&policy.destination, true, None)?; + } + self.repository + .replace_policy_file(&policy.destination, false, None)?; + } + for entry in entries.iter().rev() { + if let Some(original) = &entry.old_destination { + self.repository.write_entry(&entry.destination, original)?; + } else { + match self.repository.remove_entry(&entry.destination) { + Ok(_) | Err(RepositoryError::NotFound { .. }) => {} + Err(error) => return Err(error.into()), + } + } + } + Ok(()) + } +} + +#[derive(Clone)] +struct EntryState { + source: EntryPath, + ciphertext: EncryptedEntry, +} + +impl EntryState { + fn source(source: EntryPath, ciphertext: EncryptedEntry) -> Self { + Self { source, ciphertext } + } +} + +#[derive(Clone)] +struct PolicyState { + directory: DirectoryPath, + recipients: Vec, + signature: Option>, +} + +struct TransferEntry { + source: EntryState, + destination: EntryPath, + replacement: EncryptedEntry, + old_destination: Option, +} + +struct TransferPolicy { + source: PolicyState, + destination: DirectoryPath, +} + +#[derive(Debug)] +pub enum MutationError { + Repository(RepositoryError), + Recipient(crate::recipient::RecipientPolicyError), + Crypto(crate::crypto::CryptoError), + Cancelled, + RecursiveRequired, + RootMutation, + SameObject, + DestinationInsideSource, + DestinationDirectoryMissing { directory: DirectoryPath }, + SourceTypeCollision { path: std::path::PathBuf }, + DestinationTypeCollision { path: std::path::PathBuf }, + UnsafePolicyOverwrite { directory: DirectoryPath }, + UnsupportedAuxiliary { path: std::path::PathBuf }, + UnsupportedGitRepository { path: std::path::PathBuf }, + TreeChanged { path: std::path::PathBuf }, + Commit(TreeCommitError), +} + +impl fmt::Display for MutationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Repository(error) => error.fmt(formatter), + Self::Recipient(error) => error.fmt(formatter), + Self::Crypto(error) => error.fmt(formatter), + Self::Cancelled => formatter.write_str("tree mutation was cancelled"), + Self::RecursiveRequired => { + formatter.write_str("directory removal requires --recursive") + } + Self::RootMutation => formatter.write_str("the password-store root cannot be mutated"), + Self::SameObject => formatter.write_str("source and destination are the same object"), + Self::DestinationInsideSource => { + formatter.write_str("destination is inside source subtree") + } + Self::DestinationDirectoryMissing { directory } => write!( + formatter, + "trailing-slash destination directory does not exist: {directory}" + ), + Self::SourceTypeCollision { path } => write!( + formatter, + "source subtree contains an entry/directory collision: {}", + path.display() + ), + Self::DestinationTypeCollision { path } => write!( + formatter, + "directory destination collides with password entry: {}", + path.display() + ), + Self::UnsafePolicyOverwrite { directory } => write!( + formatter, + "destination recipient policy already exists at {directory}" + ), + Self::UnsupportedAuxiliary { path } => write!( + formatter, + "subtree contains unsupported auxiliary file {}", + path.display() + ), + Self::UnsupportedGitRepository { path } => write!( + formatter, + "subtree contains a nested Git repository at {}", + path.display() + ), + Self::TreeChanged { path } => write!( + formatter, + "password-store subtree changed during mutation: {}", + path.display() + ), + Self::Commit(error) => write!(formatter, "cannot commit tree mutation: {error}"), + } + } +} + +fn has_trailing_separator(path: &str) -> bool { + path.ends_with('/') || (cfg!(windows) && path.ends_with('\\')) +} + +impl Error for MutationError {} + +impl From for MutationError { + fn from(error: RepositoryError) -> Self { + Self::Repository(error) + } +} +impl From for MutationError { + fn from(error: crate::recipient::RecipientPolicyError) -> Self { + Self::Recipient(error) + } +} +impl From for MutationError { + fn from(error: crate::crypto::CryptoError) -> Self { + Self::Crypto(error) + } +} diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index f54001e..b9cded9 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -631,6 +631,50 @@ impl Repository { Ok(removed) } + pub(crate) fn ensure_directory( + &self, + path: &DirectoryPath, + ) -> Result, RepositoryError> { + let (_, created) = self.create_directory_path(path)?; + Ok(created.into_iter().map(DirectoryPath).collect()) + } + + pub(crate) fn remove_empty_directory( + &self, + directory: &DirectoryPath, + ) -> Result { + let Some(parent) = directory.parent() else { + return Ok(false); + }; + let parent_handle = self.open_directory(parent.as_path())?; + let name = directory + .as_path() + .file_name() + .expect("non-root directory has a file name"); + reject_entry_directory_collision(&parent_handle, name, parent.as_path())?; + let Some(metadata) = child_metadata(&parent_handle, name, directory.as_path())? else { + return Ok(false); + }; + require_directory(metadata, directory.as_path())?; + match parent_handle.remove_dir(name) { + Ok(()) => { + sync_directory(&parent_handle, parent.as_path()).map_err(|_| { + RepositoryError::DurabilityUncertain { + path: directory.as_path().to_owned(), + } + })?; + Ok(true) + } + Err(error) if error.kind() == io::ErrorKind::DirectoryNotEmpty => Ok(false), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(io_error( + "remove empty repository directory", + directory.as_path(), + error, + )), + } + } + fn open_entry_parent(&self, path: &EntryPath) -> Result<(Dir, OsString), RepositoryError> { let mut directory = self .root diff --git a/crates/storage/tests/tree_mutations.rs b/crates/storage/tests/tree_mutations.rs new file mode 100644 index 0000000..600841f --- /dev/null +++ b/crates/storage/tests/tree_mutations.rs @@ -0,0 +1,513 @@ +#![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, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator, + }, + repository::{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")?)?; + mutator.copy( + &CopyRequest { + source: "email/personal".into(), + destination: "team/personal".into(), + force: false, + }, + OverwriteDecision::Allow, + None, + &mut provider, + &mut committer, + )?; + 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." + ); + 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); + + mutator.copy( + &CopyRequest { + source: "email/personal".into(), + destination: "archive/".into(), + force: false, + }, + OverwriteDecision::Allow, + None, + &mut provider, + &mut Committer::default(), + )?; + assert!(store.path().join("archive/personal.gpg").is_file()); + + mutator.copy( + &CopyRequest { + source: "team/".into(), + destination: "archive/".into(), + force: false, + }, + OverwriteDecision::Allow, + None, + &mut provider, + &mut Committer::default(), + )?; + 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_rejects_ambiguous_sources_and_same_object_targets() -> 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); + + assert!(matches!( + mutator.copy( + &CopyRequest { + source: "email/personal".into(), + destination: "elsewhere".into(), + force: false, + }, + OverwriteDecision::Allow, + None, + &mut provider, + &mut Committer::default() + ), + Err(MutationError::Repository( + ironstorage::repository::RepositoryError::AmbiguousPath { .. } + )) + )); + 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." + ); + 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) +} diff --git a/docs/tree-mutations.md b/docs/tree-mutations.md new file mode 100644 index 0000000..d90f504 --- /dev/null +++ b/docs/tree-mutations.md @@ -0,0 +1,36 @@ +# Tree mutations + +`TreeMutator` owns the `pass rm`, `mv`, and `cp` filesystem semantics in +`crates/storage`. Callers provide typed command requests, a confirmation +decision, secret-key access when selective reencryption is needed, and a +`TreeCommitter`; they never inspect or mutate the password-store tree. + +Sources use the repository resolver. A trailing slash explicitly selects a +directory, while a logical path occupied by both `name.gpg` and `name/` is +otherwise rejected as ambiguous. An existing destination directory is a +container, so the source basename is appended. A destination ending in a slash +must already be a directory. Same-object transfers, moves into their own +subtree, entry/directory type collisions, symlinks, and subtrees containing +unknown auxiliary files are rejected before any write. + +Entry overwrites require either `--force` or an affirmative confirmation. +Directory removal requires the recursive flag. Whole-directory transfers copy +their `.gpg-id` and optional `.gpg-id.sig` boundaries; an existing destination +policy is rejected instead of partly merging policy trees. Entries whose +effective destination recipients differ are decrypted and reencrypted with the +embedded OpenPGP backend, while matching ciphertext is preserved byte for byte. + +Every destination entry and policy is atomically and durably written before a +move removes its source. The operation retains source and overwritten +destination bytes until the commit callback succeeds. A filesystem or commit +failure restores both sides and removes directories created by the failed +transaction. Successful moves and removals then prune empty source directories. +The commit callback must report failure only when it has not created a commit; +it receives the compatible intent `Remove ... from store.`, `Rename ... to +....`, or `Copy ... to ....`. + +Compatibility tests materialize the shared upstream-format fixtures and cover +entry and subtree mutations, recursive requirements, cancellation, forced +overwrite, directory destinations, ambiguity, nested signed recipient +boundaries, selective reencryption, source preservation, source cleanup, and +exact-tree rollback after a simulated commit failure.