Implement rollback-safe tree mutations

This commit is contained in:
Hermes Agent
2026-08-09 22:58:37 +00:00
parent 834df46818
commit 410007c012
6 changed files with 1368 additions and 0 deletions

View File

@@ -41,6 +41,9 @@ boundary are documented in [`docs/write-domains.md`](docs/write-domains.md).
Unbiased password generation, character-set validation, in-place replacement, Unbiased password generation, character-set validation, in-place replacement,
and presentation actions are documented in and presentation actions are documented in
[`docs/password-generation.md`](docs/password-generation.md). [`docs/password-generation.md`](docs/password-generation.md).
Rollback-safe remove, move, and copy transactions, destination rules, and
selective subtree reencryption are documented in
[`docs/tree-mutations.md`](docs/tree-mutations.md).
## Project layout ## Project layout

View File

@@ -9,6 +9,7 @@ pub mod command;
pub mod config; pub mod config;
pub mod crypto; pub mod crypto;
pub mod generate; pub mod generate;
pub mod mutation;
pub mod read; pub mod read;
pub mod recipient; pub mod recipient;
pub mod repository; pub mod repository;

View 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)
}
}

View File

@@ -631,6 +631,50 @@ impl Repository {
Ok(removed) 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> { fn open_entry_parent(&self, path: &EntryPath) -> Result<(Dir, OsString), RepositoryError> {
let mut directory = self let mut directory = self
.root .root

View 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)
}

36
docs/tree-mutations.md Normal file
View File

@@ -0,0 +1,36 @@
# Tree mutations
`TreeMutator` owns the `pass rm`, `mv`, and `cp` filesystem semantics in
`crates/storage`. Callers provide typed command requests, a confirmation
decision, secret-key access when selective reencryption is needed, and a
`TreeCommitter`; they never inspect or mutate the password-store tree.
Sources use the repository resolver. A trailing slash explicitly selects a
directory, while a logical path occupied by both `name.gpg` and `name/` is
otherwise rejected as ambiguous. An existing destination directory is a
container, so the source basename is appended. A destination ending in a slash
must already be a directory. Same-object transfers, moves into their own
subtree, entry/directory type collisions, symlinks, and subtrees containing
unknown auxiliary files are rejected before any write.
Entry overwrites require either `--force` or an affirmative confirmation.
Directory removal requires the recursive flag. Whole-directory transfers copy
their `.gpg-id` and optional `.gpg-id.sig` boundaries; an existing destination
policy is rejected instead of partly merging policy trees. Entries whose
effective destination recipients differ are decrypted and reencrypted with the
embedded OpenPGP backend, while matching ciphertext is preserved byte for byte.
Every destination entry and policy is atomically and durably written before a
move removes its source. The operation retains source and overwritten
destination bytes until the commit callback succeeds. A filesystem or commit
failure restores both sides and removes directories created by the failed
transaction. Successful moves and removals then prune empty source directories.
The commit callback must report failure only when it has not created a commit;
it receives the compatible intent `Remove ... from store.`, `Rename ... to
....`, or `Copy ... to ....`.
Compatibility tests materialize the shared upstream-format fixtures and cover
entry and subtree mutations, recursive requirements, cancellation, forced
overwrite, directory destinations, ambiguity, nested signed recipient
boundaries, selective reencryption, source preservation, source cleanup, and
exact-tree rollback after a simulated commit failure.