Files
IronStorage/crates/storage/tests/tree_mutations.rs
T
hugo 316a875d63
Android ARM verification / rust (push) Failing after 49m2s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 2m40s
Implement confirmed Android tree mutation workflows (#108)
2026-10-12 13:06:22 +02:00

891 lines
29 KiB
Rust

#![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, MutationSelection, TreeCommit, TreeCommitError,
TreeCommitter, TreeMutator,
},
repository::{DirectoryPath, EntryPath, Repository, SecretBytes},
write::OverwriteDecision,
};
use support::compatibility::{FixtureSet, TestResult};
#[test]
fn mobile_tree_plans_confirm_folders_and_preserve_pass_recipient_semantics() -> TestResult {
use ironstorage::mobile_mutation::{
MobileMutationAction as Action, MobileMutationRequest, MobileMutationService,
};
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 service = MobileMutationService::new(&repository);
let mut secrets = Secrets::all(&fixture);
let mut committer = Committer::default();
let original = repository.read_entry(&EntryPath::parse("email/personal")?)?;
let plan = service.prepare("email/", Action::Copy, false, false)?;
assert!(plan.is_directory());
assert_eq!(plan.entries(), 1);
assert!(
plan.destinations()
.iter()
.all(|destination| destination.path() != "email")
);
let mut request = MobileMutationRequest {
action: Action::Copy,
source: plan.source().into(),
revision: plan.revision().into(),
destination: Some("team".into()),
confirmed: false,
overwrite: false,
discard_editor: false,
};
assert!(
service
.perform(&request, false, &keys, &mut secrets, &mut committer)
.is_err()
);
assert!(committer.changes.is_empty());
assert!(!store.path().join("team/email").exists());
request.confirmed = true;
service.perform(&request, false, &keys, &mut secrets, &mut committer)?;
assert_eq!(
repository.read_entry(&EntryPath::parse("email/personal")?)?,
original
);
let copied = repository.read_entry(&EntryPath::parse("team/email/personal")?)?;
assert_ne!(copied, original);
assert!(keys.is_encrypted_for(
&copied,
&[keys.resolve(&fixture.key("bob")?.primary_fingerprint)?]
)?);
assert_eq!(
keys.decrypt(&copied, &mut secrets)?.expose(),
fixture.read("expected/basic/email/personal.txt")?
);
let plan = service.prepare("team/", Action::Move, false, false)?;
let policy = fs::read(store.path().join("team/.gpg-id"))?;
service.perform(
&MobileMutationRequest {
action: Action::Move,
source: plan.source().into(),
revision: plan.revision().into(),
destination: Some("archive".into()),
confirmed: true,
overwrite: false,
discard_editor: false,
},
false,
&keys,
&mut secrets,
&mut committer,
)?;
assert!(!store.path().join("team").exists());
assert_eq!(fs::read(store.path().join("archive/team/.gpg-id"))?, policy);
assert_eq!(
repository.read_entry(&EntryPath::parse("archive/team/email/personal")?)?,
copied
);
let plan = service.prepare("archive/team/", Action::Delete, false, false)?;
let mut request = MobileMutationRequest {
action: Action::Delete,
source: plan.source().into(),
revision: plan.revision().into(),
destination: None,
confirmed: false,
overwrite: false,
discard_editor: false,
};
assert!(
service
.perform(&request, false, &keys, &mut secrets, &mut committer)
.is_err()
);
assert!(store.path().join("archive/team").exists());
request.confirmed = true;
service.perform(&request, false, &keys, &mut secrets, &mut committer)?;
assert!(!store.path().join("archive/team").exists());
assert_eq!(
repository.read_entry(&EntryPath::parse("email/personal")?)?,
original
);
Ok(())
}
#[test]
fn mobile_overwrite_is_explicit_and_destination_replacement_invalidates_the_plan() -> TestResult {
use ironstorage::mobile_mutation::{
MobileMutationAction as Action, MobileMutationError, MobileMutationRequest,
MobileMutationService,
};
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let path = EntryPath::parse("team/personal")?;
let original = repository.read_entry(&EntryPath::parse("email/personal")?)?;
repository.write_entry(&path, &original)?;
let service = MobileMutationService::new(&repository);
let plan = service.prepare("email/personal", Action::Copy, false, false)?;
let target = plan
.destinations()
.iter()
.find(|target| target.path() == "team")
.unwrap();
assert!(target.requires_overwrite());
assert_eq!(target.collision_targets(), &["team/personal"]);
let mut request = MobileMutationRequest {
action: Action::Copy,
source: plan.source().into(),
revision: plan.revision().into(),
destination: Some("team".into()),
confirmed: true,
overwrite: false,
discard_editor: false,
};
let mut secrets = Secrets::all(&fixture);
let mut committer = Committer::default();
assert!(matches!(
service.perform(&request, false, &keys, &mut secrets, &mut committer),
Err(MobileMutationError::OverwriteRequired { .. })
));
repository.write_entry(&path, &original)?;
request.overwrite = true;
assert!(matches!(
service.perform(&request, false, &keys, &mut secrets, &mut committer),
Err(MobileMutationError::StaleEntry { .. })
));
assert_eq!(repository.read_entry(&path)?, original);
assert!(committer.changes.is_empty());
Ok(())
}
#[test]
fn mobile_rechecks_revisions_after_reencryption_and_rolls_back_failed_commits() -> TestResult {
use ironstorage::mobile_mutation::{
MobileMutationAction as Action, MobileMutationRequest, MobileMutationService,
};
struct ReplacingSecrets<'a> {
inner: Secrets,
repository: &'a Repository,
path: EntryPath,
}
impl SecretProvider for ReplacingSecrets<'_> {
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
self.repository
.write_entry(
&self.path,
&ironstorage::repository::EncryptedEntry::new(b"replacement".to_vec()),
)
.map_err(|_| SecretProviderError::Unavailable)?;
self.inner.secret_for(key)
}
}
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 service = MobileMutationService::new(&repository);
let original = repository.read_entry(&EntryPath::parse("email/personal")?)?;
let plan = service.prepare("email/personal", Action::Move, false, false)?;
let mut provider = ReplacingSecrets {
inner: Secrets::all(&fixture),
repository: &repository,
path: EntryPath::parse("email/personal")?,
};
let mut committer = Committer::default();
assert!(
service
.perform(
&MobileMutationRequest {
action: Action::Move,
source: plan.source().into(),
revision: plan.revision().into(),
destination: Some("team".into()),
confirmed: true,
overwrite: false,
discard_editor: false,
},
false,
&keys,
&mut provider,
&mut committer
)
.is_err()
);
assert_eq!(
repository
.read_entry(&EntryPath::parse("email/personal")?)?
.as_bytes(),
b"replacement"
);
assert!(!store.path().join("team/personal.gpg").exists());
assert!(committer.changes.is_empty());
repository.write_entry(&EntryPath::parse("email/personal")?, &original)?;
let plan = service.prepare("email/", Action::Move, false, false)?;
committer.fail = true;
assert!(
service
.perform(
&MobileMutationRequest {
action: Action::Move,
source: plan.source().into(),
revision: plan.revision().into(),
destination: Some("archive".into()),
confirmed: true,
overwrite: false,
discard_editor: false,
},
false,
&keys,
&mut Secrets::all(&fixture),
&mut committer
)
.is_err()
);
assert_eq!(
repository.read_entry(&EntryPath::parse("email/personal")?)?,
original
);
assert!(!store.path().join("archive/email").exists());
Ok(())
}
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(())
}
}
}
struct SabotagingCommitter(Box<dyn FnMut()>);
impl TreeCommitter for SabotagingCommitter {
fn commit(&mut self, _change: &TreeCommit) -> Result<(), TreeCommitError> {
(self.0)();
Err(TreeCommitError::new("simulated operation failure"))
}
}
#[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")?)?;
let outcome = mutator.copy(
&CopyRequest {
source: "email/personal".into(),
destination: "team/personal".into(),
force: false,
},
OverwriteDecision::Allow,
None,
&mut provider,
&mut committer,
)?;
assert_eq!(
outcome.selection(),
Some(&MutationSelection::Entry(EntryPath::parse(
"team/personal"
)?))
);
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."
);
assert_eq!(
committer.changes[0].changed_paths(),
&[Path::new("team/personal.gpg").to_owned()]
);
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);
let copied = mutator.copy(
&CopyRequest {
source: "email/personal".into(),
destination: "archive/".into(),
force: false,
},
OverwriteDecision::Allow,
None,
&mut provider,
&mut Committer::default(),
)?;
assert_eq!(
copied.selection(),
Some(&MutationSelection::Entry(EntryPath::parse(
"archive/personal"
)?))
);
assert!(store.path().join("archive/personal.gpg").is_file());
let copied_directory = mutator.copy(
&CopyRequest {
source: "team/".into(),
destination: "archive/".into(),
force: false,
},
OverwriteDecision::Allow,
None,
&mut provider,
&mut Committer::default(),
)?;
assert_eq!(
copied_directory.selection(),
Some(&MutationSelection::Directory(DirectoryPath::parse(
"archive/team"
)?))
);
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_prefers_an_entry_over_its_same_named_directory() -> 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);
mutator.copy(
&CopyRequest {
source: "email/personal".into(),
destination: "elsewhere".into(),
force: false,
},
OverwriteDecision::Allow,
None,
&mut provider,
&mut Committer::default(),
)?;
assert!(store.path().join("email/personal").is_dir());
assert_eq!(
keys.decrypt(
&repository.read_entry(&EntryPath::parse("elsewhere")?)?,
&mut provider,
)?
.expose(),
fixture.read("expected/basic/email/personal.txt")?
);
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."
);
assert_eq!(
committer.changes[0].changed_paths(),
&[
Path::new("archive/personal.gpg").to_owned(),
Path::new("email/personal.gpg").to_owned(),
]
);
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(())
}
#[test]
fn rollback_failures_from_sources_destinations_and_cleanup_retain_the_operation() -> 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 source_collision = store.path().join("email/personal.gpg");
let mut committer = SabotagingCommitter(Box::new(move || {
fs::create_dir(&source_collision).expect("block source restoration");
}));
let error = TreeMutator::new(&repository, &keys)
.move_tree(
&MoveRequest {
source: "email/personal".into(),
destination: "archive/personal".into(),
force: false,
},
OverwriteDecision::Allow,
None,
&mut provider,
&mut committer,
)
.expect_err("source restoration must fail");
assert_operation_and_rollback(error);
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 destination = store.path().join("archive/personal.gpg");
let mut committer = SabotagingCommitter(Box::new(move || {
fs::remove_file(&destination).expect("remove destination entry");
fs::create_dir(&destination).expect("block destination restoration");
}));
let error = TreeMutator::new(&repository, &keys)
.copy(
&CopyRequest {
source: "email/personal".into(),
destination: "archive/personal".into(),
force: false,
},
OverwriteDecision::Allow,
None,
&mut provider,
&mut committer,
)
.expect_err("destination restoration must fail");
assert_operation_and_rollback(error);
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 blocker = store.path().join("archive/blocker");
let mut committer = SabotagingCommitter(Box::new(move || {
fs::write(&blocker, b"concurrent file").expect("block directory cleanup");
}));
let error = TreeMutator::new(&repository, &keys)
.copy(
&CopyRequest {
source: "email/personal".into(),
destination: "archive/personal".into(),
force: false,
},
OverwriteDecision::Allow,
None,
&mut provider,
&mut committer,
)
.expect_err("created-directory cleanup must fail");
assert_operation_and_rollback(error);
Ok(())
}
fn assert_operation_and_rollback(error: MutationError) {
let MutationError::RollbackFailed {
operation,
rollback,
} = error
else {
panic!("operation and rollback errors must both be retained");
};
assert!(matches!(*operation, MutationError::Commit(_)));
assert!(matches!(
*rollback,
MutationError::Repository(_) | MutationError::TreeChanged { .. }
));
}
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)
}