//! 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, changed_paths: Vec, 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 changed_paths(&self) -> &[std::path::PathBuf] { &self.changed_paths } 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, changed_paths: entries .iter() .map(|entry| entry.source.encrypted_relative_path()) .chain(policies.iter().flat_map(|policy| { [ policy.directory.as_path().join(".gpg-id"), policy.directory.as_path().join(".gpg-id.sig"), ] })) .collect(), 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 mut changed_paths = entries .iter() .map(|entry| entry.destination.encrypted_relative_path()) .chain(policies.iter().flat_map(|policy| { [ policy.destination.as_path().join(".gpg-id"), policy.destination.as_path().join(".gpg-id.sig"), ] })) .collect::>(); if moving { changed_paths.extend( source_entries .iter() .map(|entry| entry.source.encrypted_relative_path()), ); changed_paths.extend(source_policies.iter().flat_map(|policy| { [ policy.directory.as_path().join(".gpg-id"), policy.directory.as_path().join(".gpg-id.sig"), ] })); } let change = TreeCommit { action, source: source.to_owned(), destination: Some(destination.to_owned()), changed_paths, 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) } }