Files
IronStorage/crates/storage/src/mutation.rs
2026-08-09 23:54:10 +00:00

810 lines
28 KiB
Rust

//! 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<String>,
changed_paths: Vec<std::path::PathBuf>,
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<String>) -> 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<EntryPath>,
}
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<MutationOutcome, MutationError> {
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<MutationOutcome, MutationError> {
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<MutationOutcome, MutationError> {
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<MutationOutcome, MutationError> {
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::<Vec<_>>();
let source_policies = policies
.iter()
.map(|policy| policy.source.clone())
.collect::<Vec<_>>();
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::<Result<Vec<_>, _>>()?
};
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::<Vec<_>>();
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<Vec<EntryState>, 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<Vec<PolicyState>, 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<Vec<DirectoryPath>, 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<u8>,
signature: Option<Vec<u8>>,
}
struct TransferEntry {
source: EntryState,
destination: EntryPath,
replacement: EncryptedEntry,
old_destination: Option<EncryptedEntry>,
}
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<RepositoryError> for MutationError {
fn from(error: RepositoryError) -> Self {
Self::Repository(error)
}
}
impl From<crate::recipient::RecipientPolicyError> for MutationError {
fn from(error: crate::recipient::RecipientPolicyError) -> Self {
Self::Recipient(error)
}
}
impl From<crate::crypto::CryptoError> for MutationError {
fn from(error: crate::crypto::CryptoError) -> Self {
Self::Crypto(error)
}
}