Some checks failed
Dependency security audit / rustsec (push) Failing after 3s
648 lines
20 KiB
Rust
648 lines
20 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};
|
|
|
|
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)
|
|
}
|