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
|
||||
|
||||
513
crates/storage/tests/tree_mutations.rs
Normal file
513
crates/storage/tests/tree_mutations.rs
Normal file
@@ -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<String, Vec<u8>>);
|
||||
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<SecretBytes, SecretProviderError> {
|
||||
self.0
|
||||
.get(key.fingerprint().as_str())
|
||||
.cloned()
|
||||
.map(SecretBytes::new)
|
||||
.ok_or(SecretProviderError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Committer {
|
||||
changes: Vec<TreeCommit>,
|
||||
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<BTreeMap<std::path::PathBuf, Vec<u8>>> {
|
||||
fn visit(
|
||||
root: &Path,
|
||||
current: &Path,
|
||||
output: &mut BTreeMap<std::path::PathBuf, Vec<u8>>,
|
||||
) -> 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)
|
||||
}
|
||||
Reference in New Issue
Block a user