Implement rollback-safe tree mutations
This commit is contained in:
@@ -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;
|
||||
|
||||
771
crates/storage/src/mutation.rs
Normal file
771
crates/storage/src/mutation.rs
Normal file
@@ -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<String>,
|
||||
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<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,
|
||||
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 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<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)
|
||||
}
|
||||
}
|
||||
@@ -631,6 +631,50 @@ impl Repository {
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_directory(
|
||||
&self,
|
||||
path: &DirectoryPath,
|
||||
) -> Result<Vec<DirectoryPath>, 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<bool, RepositoryError> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user