Implement hierarchical recipient policies (#5)
This commit is contained in:
@@ -346,6 +346,47 @@ impl KeyStore {
|
||||
.map_err(|_| CryptoError::EncryptionFailed)
|
||||
}
|
||||
|
||||
/// Report whether every public-key session packet names exactly the requested primary
|
||||
/// certificates. Unknown, anonymous, symmetric, missing, or extra recipients do not match.
|
||||
pub fn is_encrypted_for(
|
||||
&self,
|
||||
ciphertext: &EncryptedEntry,
|
||||
recipients: &[KeyHandle],
|
||||
) -> Result<bool, CryptoError> {
|
||||
let message = Message::from_bytes(Cursor::new(ciphertext.as_bytes()))
|
||||
.map_err(|_| CryptoError::CorruptMessage)?;
|
||||
let Message::Encrypted { esk, .. } = message else {
|
||||
return Err(CryptoError::CorruptMessage);
|
||||
};
|
||||
let expected = recipients
|
||||
.iter()
|
||||
.map(|recipient| {
|
||||
self.material(recipient)?;
|
||||
Ok(recipient.0.clone())
|
||||
})
|
||||
.collect::<Result<BTreeSet<_>, CryptoError>>()?;
|
||||
if expected.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut actual = BTreeSet::new();
|
||||
for packet in esk {
|
||||
let Esk::PublicKeyEncryptedSessionKey(packet) = packet else {
|
||||
return Ok(false);
|
||||
};
|
||||
let matches = self
|
||||
.keys
|
||||
.iter()
|
||||
.filter(|(_, material)| packet_matches_public(&packet, &material.public))
|
||||
.map(|(fingerprint, _)| fingerprint)
|
||||
.collect::<Vec<_>>();
|
||||
if matches.len() != 1 {
|
||||
return Ok(false);
|
||||
}
|
||||
actual.insert((*matches[0]).clone());
|
||||
}
|
||||
Ok(actual == expected)
|
||||
}
|
||||
|
||||
/// Decrypt a pass entry with only the secret keys named by its PKESK packets.
|
||||
pub fn decrypt(
|
||||
&self,
|
||||
@@ -760,6 +801,17 @@ fn message_matches_secret(message: &Message<'_>, key: &SignedSecretKey) -> bool
|
||||
})
|
||||
}
|
||||
|
||||
fn packet_matches_public(
|
||||
packet: &pgp::packet::PublicKeyEncryptedSessionKey,
|
||||
key: &SignedPublicKey,
|
||||
) -> bool {
|
||||
packet.match_identity(&key.primary_key)
|
||||
|| key
|
||||
.public_subkeys
|
||||
.iter()
|
||||
.any(|subkey| packet.match_identity(&subkey.key))
|
||||
}
|
||||
|
||||
fn sign_data<K: SigningKey>(
|
||||
key: &K,
|
||||
password: &Password,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
pub mod command;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod recipient;
|
||||
pub mod repository;
|
||||
|
||||
/// Product name shared by the presentation adapters.
|
||||
|
||||
621
crates/storage/src/recipient.rs
Normal file
621
crates/storage/src/recipient.rs
Normal file
@@ -0,0 +1,621 @@
|
||||
//! Hierarchical `.gpg-id` policy and rollback-safe selective reencryption.
|
||||
|
||||
use std::{
|
||||
error::Error,
|
||||
fmt,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
command::InitRequest,
|
||||
crypto::{CryptoError, DetachedSignatureBytes, KeyHandle, KeyStore, SecretProvider},
|
||||
repository::{
|
||||
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, RepositorySnapshot,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SigningPolicy {
|
||||
signer: KeyHandle,
|
||||
trusted_signers: Vec<KeyHandle>,
|
||||
}
|
||||
|
||||
impl SigningPolicy {
|
||||
pub fn new(signer: KeyHandle, trusted_signers: Vec<KeyHandle>) -> Self {
|
||||
Self {
|
||||
signer,
|
||||
trusted_signers,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn signer(&self) -> &KeyHandle {
|
||||
&self.signer
|
||||
}
|
||||
|
||||
pub fn trusted_signers(&self) -> &[KeyHandle] {
|
||||
&self.trusted_signers
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PolicyAction {
|
||||
Set,
|
||||
Remove,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PolicyCommit {
|
||||
directory: DirectoryPath,
|
||||
action: PolicyAction,
|
||||
changed_entries: Vec<EntryPath>,
|
||||
policy_changed: bool,
|
||||
signature_changed: bool,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl PolicyCommit {
|
||||
pub fn directory(&self) -> &DirectoryPath {
|
||||
&self.directory
|
||||
}
|
||||
|
||||
pub fn action(&self) -> PolicyAction {
|
||||
self.action
|
||||
}
|
||||
|
||||
pub fn changed_entries(&self) -> &[EntryPath] {
|
||||
&self.changed_entries
|
||||
}
|
||||
|
||||
pub fn policy_changed(&self) -> bool {
|
||||
self.policy_changed
|
||||
}
|
||||
|
||||
pub fn signature_changed(&self) -> bool {
|
||||
self.signature_changed
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
pub fn changed_paths(&self) -> Vec<PathBuf> {
|
||||
let mut paths = self
|
||||
.changed_entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let mut path = entry.as_path().to_owned();
|
||||
let mut name = path
|
||||
.file_name()
|
||||
.expect("validated entry has a file name")
|
||||
.to_os_string();
|
||||
name.push(".gpg");
|
||||
path.set_file_name(name);
|
||||
path
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if self.policy_changed {
|
||||
paths.push(self.directory.as_path().join(".gpg-id"));
|
||||
}
|
||||
if self.signature_changed {
|
||||
paths.push(self.directory.as_path().join(".gpg-id.sig"));
|
||||
}
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PolicyCommitError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl PolicyCommitError {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PolicyCommitError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for PolicyCommitError {}
|
||||
|
||||
/// Boundary implemented by embedded Git support. Returning an error promises that the
|
||||
/// committer also restored any staging state it changed; repository bytes are restored here.
|
||||
pub trait PolicyCommitter {
|
||||
fn commit(&mut self, change: &PolicyCommit) -> Result<(), PolicyCommitError>;
|
||||
}
|
||||
|
||||
/// Use when the affected path is not inside a Git repository.
|
||||
#[derive(Default)]
|
||||
pub struct NoGitCommitter;
|
||||
|
||||
impl PolicyCommitter for NoGitCommitter {
|
||||
fn commit(&mut self, _change: &PolicyCommit) -> Result<(), PolicyCommitError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct EffectiveRecipients {
|
||||
policy_directory: DirectoryPath,
|
||||
recipients: Vec<KeyHandle>,
|
||||
}
|
||||
|
||||
impl EffectiveRecipients {
|
||||
pub fn policy_directory(&self) -> &DirectoryPath {
|
||||
&self.policy_directory
|
||||
}
|
||||
|
||||
pub fn recipients(&self) -> &[KeyHandle] {
|
||||
&self.recipients
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RecipientPolicyOutcome {
|
||||
directory: DirectoryPath,
|
||||
action: PolicyAction,
|
||||
recipients: Vec<KeyHandle>,
|
||||
reencrypted_entries: Vec<EntryPath>,
|
||||
committed: bool,
|
||||
}
|
||||
|
||||
impl RecipientPolicyOutcome {
|
||||
pub fn directory(&self) -> &DirectoryPath {
|
||||
&self.directory
|
||||
}
|
||||
|
||||
pub fn action(&self) -> PolicyAction {
|
||||
self.action
|
||||
}
|
||||
|
||||
pub fn recipients(&self) -> &[KeyHandle] {
|
||||
&self.recipients
|
||||
}
|
||||
|
||||
pub fn reencrypted_entries(&self) -> &[EntryPath] {
|
||||
&self.reencrypted_entries
|
||||
}
|
||||
|
||||
pub fn committed(&self) -> bool {
|
||||
self.committed
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RecipientPolicyManager<'a> {
|
||||
repository: &'a Repository,
|
||||
keys: &'a KeyStore,
|
||||
}
|
||||
|
||||
impl<'a> RecipientPolicyManager<'a> {
|
||||
pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self {
|
||||
Self { repository, keys }
|
||||
}
|
||||
|
||||
/// Resolve and, when configured, authenticate the nearest ancestor policy for an entry.
|
||||
pub fn resolve_for_entry(
|
||||
&self,
|
||||
entry: &EntryPath,
|
||||
signing: Option<&SigningPolicy>,
|
||||
) -> Result<EffectiveRecipients, RecipientPolicyError> {
|
||||
let snapshot = self.repository.snapshot()?;
|
||||
let policy = snapshot.recipient_policy(entry).ok_or_else(|| {
|
||||
RecipientPolicyError::MissingRecipientPolicy {
|
||||
entry: entry.clone(),
|
||||
}
|
||||
})?;
|
||||
let recipients = self.load_policy(policy.directory(), signing)?;
|
||||
Ok(EffectiveRecipients {
|
||||
policy_directory: policy.directory().clone(),
|
||||
recipients,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply `pass init`, including an empty single identity for policy removal.
|
||||
pub fn apply_init(
|
||||
&self,
|
||||
request: &InitRequest,
|
||||
signing: Option<&SigningPolicy>,
|
||||
provider: &mut impl SecretProvider,
|
||||
committer: &mut impl PolicyCommitter,
|
||||
) -> Result<RecipientPolicyOutcome, RecipientPolicyError> {
|
||||
let directory = DirectoryPath::parse(request.path.as_deref().unwrap_or_default())?;
|
||||
if request.key_identities.is_empty() {
|
||||
return Err(RecipientPolicyError::MissingIdentities);
|
||||
}
|
||||
let remove = request.key_identities.len() == 1 && request.key_identities[0].is_empty();
|
||||
let action = if remove {
|
||||
PolicyAction::Remove
|
||||
} else {
|
||||
PolicyAction::Set
|
||||
};
|
||||
let snapshot = self.repository.snapshot()?;
|
||||
let original_policy = self.repository.read_policy_file(&directory, false)?;
|
||||
if remove && original_policy.is_none() {
|
||||
return Err(RecipientPolicyError::PolicyNotFound {
|
||||
directory: directory.clone(),
|
||||
});
|
||||
}
|
||||
let original_signature = self.repository.read_policy_file(&directory, true)?;
|
||||
|
||||
let (replacement_policy, replacement_signature, requested_recipients) = if remove {
|
||||
(None, original_signature.clone(), Vec::new())
|
||||
} else {
|
||||
let contents = serialize_identities(&request.key_identities);
|
||||
let recipients = self.keys.resolve_recipients(&contents)?;
|
||||
let signature = if let Some(signing) = signing {
|
||||
let signature = self.keys.sign(&contents, signing.signer(), provider)?;
|
||||
self.keys
|
||||
.verify(&contents, &signature, signing.trusted_signers())?;
|
||||
Some(signature.into_bytes())
|
||||
} else {
|
||||
original_signature.clone()
|
||||
};
|
||||
(Some(contents), signature, recipients)
|
||||
};
|
||||
|
||||
let controlled_entries = snapshot
|
||||
.entries()
|
||||
.filter(|record| entry_is_controlled_by(record.path(), &directory, &snapshot))
|
||||
.collect::<Vec<_>>();
|
||||
let inherited = if remove && !controlled_entries.is_empty() {
|
||||
nearest_parent_policy(&snapshot, &directory)
|
||||
.map(|policy_directory| {
|
||||
self.load_policy(&policy_directory, signing)
|
||||
.map(|recipients| (policy_directory, recipients))
|
||||
})
|
||||
.transpose()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
for record in controlled_entries {
|
||||
let recipients = if remove {
|
||||
inherited
|
||||
.as_ref()
|
||||
.map(|(_, recipients)| recipients)
|
||||
.ok_or_else(|| RecipientPolicyError::MissingRecipientPolicy {
|
||||
entry: record.path().clone(),
|
||||
})?
|
||||
} else {
|
||||
&requested_recipients
|
||||
};
|
||||
let original = self.repository.read_entry(record.path())?;
|
||||
if self.keys.is_encrypted_for(&original, recipients)? {
|
||||
continue;
|
||||
}
|
||||
let plaintext = self.keys.decrypt(&original, provider)?;
|
||||
let replacement = self.keys.encrypt(plaintext, recipients)?;
|
||||
entries.push(EntryMutation {
|
||||
path: record.path().clone(),
|
||||
original,
|
||||
replacement,
|
||||
});
|
||||
}
|
||||
|
||||
let policy_changed = original_policy != replacement_policy;
|
||||
let signature_changed = original_signature != replacement_signature;
|
||||
let changed_entries = entries
|
||||
.iter()
|
||||
.map(|entry| entry.path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let change = PolicyCommit {
|
||||
directory: directory.clone(),
|
||||
action,
|
||||
changed_entries: changed_entries.clone(),
|
||||
policy_changed,
|
||||
signature_changed,
|
||||
message: commit_message(action, &directory, &request.key_identities),
|
||||
};
|
||||
if !policy_changed && !signature_changed && entries.is_empty() {
|
||||
return Ok(RecipientPolicyOutcome {
|
||||
directory,
|
||||
action,
|
||||
recipients: requested_recipients,
|
||||
reencrypted_entries: Vec::new(),
|
||||
committed: false,
|
||||
});
|
||||
}
|
||||
|
||||
let applied = self.apply_files(
|
||||
&directory,
|
||||
&entries,
|
||||
policy_changed
|
||||
.then_some(replacement_policy.as_deref())
|
||||
.flatten(),
|
||||
policy_changed && replacement_policy.is_none(),
|
||||
signature_changed
|
||||
.then_some(replacement_signature.as_deref())
|
||||
.flatten(),
|
||||
signature_changed && replacement_signature.is_none(),
|
||||
);
|
||||
if let Err(operation) = applied {
|
||||
return self.rollback_error(
|
||||
operation.into(),
|
||||
&directory,
|
||||
&entries,
|
||||
original_policy.as_deref(),
|
||||
original_signature.as_deref(),
|
||||
policy_changed,
|
||||
signature_changed,
|
||||
);
|
||||
}
|
||||
if remove && let Err(operation) = self.repository.cleanup_empty_directories(&directory) {
|
||||
return self.rollback_error(
|
||||
operation.into(),
|
||||
&directory,
|
||||
&entries,
|
||||
original_policy.as_deref(),
|
||||
original_signature.as_deref(),
|
||||
policy_changed,
|
||||
signature_changed,
|
||||
);
|
||||
}
|
||||
if let Err(error) = committer.commit(&change) {
|
||||
return self.rollback_error(
|
||||
RecipientPolicyError::Commit(error),
|
||||
&directory,
|
||||
&entries,
|
||||
original_policy.as_deref(),
|
||||
original_signature.as_deref(),
|
||||
policy_changed,
|
||||
signature_changed,
|
||||
);
|
||||
}
|
||||
Ok(RecipientPolicyOutcome {
|
||||
directory,
|
||||
action,
|
||||
recipients: if remove {
|
||||
inherited.map_or_else(Vec::new, |(_, recipients)| recipients)
|
||||
} else {
|
||||
requested_recipients
|
||||
},
|
||||
reencrypted_entries: changed_entries,
|
||||
committed: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_policy(
|
||||
&self,
|
||||
directory: &DirectoryPath,
|
||||
signing: Option<&SigningPolicy>,
|
||||
) -> Result<Vec<KeyHandle>, RecipientPolicyError> {
|
||||
let contents = self
|
||||
.repository
|
||||
.read_policy_file(directory, false)?
|
||||
.ok_or_else(|| RecipientPolicyError::PolicyNotFound {
|
||||
directory: directory.clone(),
|
||||
})?;
|
||||
if let Some(signing) = signing {
|
||||
let signature = self
|
||||
.repository
|
||||
.read_policy_file(directory, true)?
|
||||
.ok_or_else(|| RecipientPolicyError::MissingSignature {
|
||||
directory: directory.clone(),
|
||||
})?;
|
||||
self.keys.verify(
|
||||
&contents,
|
||||
&DetachedSignatureBytes::new(signature),
|
||||
signing.trusted_signers(),
|
||||
)?;
|
||||
}
|
||||
Ok(self.keys.resolve_recipients(&contents)?)
|
||||
}
|
||||
|
||||
fn apply_files(
|
||||
&self,
|
||||
directory: &DirectoryPath,
|
||||
entries: &[EntryMutation],
|
||||
policy: Option<&[u8]>,
|
||||
remove_policy: bool,
|
||||
signature: Option<&[u8]>,
|
||||
remove_signature: bool,
|
||||
) -> Result<(), RepositoryError> {
|
||||
for entry in entries {
|
||||
self.repository
|
||||
.write_entry(&entry.path, &entry.replacement)?;
|
||||
}
|
||||
if policy.is_some() || remove_policy {
|
||||
self.repository
|
||||
.replace_policy_file(directory, false, policy)?;
|
||||
}
|
||||
if signature.is_some() || remove_signature {
|
||||
self.repository
|
||||
.replace_policy_file(directory, true, signature)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rollback_error<T>(
|
||||
&self,
|
||||
operation: RecipientPolicyError,
|
||||
directory: &DirectoryPath,
|
||||
entries: &[EntryMutation],
|
||||
original_policy: Option<&[u8]>,
|
||||
original_signature: Option<&[u8]>,
|
||||
policy_changed: bool,
|
||||
signature_changed: bool,
|
||||
) -> Result<T, RecipientPolicyError> {
|
||||
let mut rollback = None;
|
||||
if signature_changed
|
||||
&& let Err(error) =
|
||||
self.repository
|
||||
.replace_policy_file(directory, true, original_signature)
|
||||
{
|
||||
rollback.get_or_insert(error);
|
||||
}
|
||||
if policy_changed
|
||||
&& let Err(error) =
|
||||
self.repository
|
||||
.replace_policy_file(directory, false, original_policy)
|
||||
{
|
||||
rollback.get_or_insert(error);
|
||||
}
|
||||
for entry in entries.iter().rev() {
|
||||
if let Err(error) = self.repository.write_entry(&entry.path, &entry.original) {
|
||||
rollback.get_or_insert(error);
|
||||
}
|
||||
}
|
||||
if original_policy.is_none() {
|
||||
let _ = self.repository.cleanup_empty_directories(directory);
|
||||
}
|
||||
match rollback {
|
||||
Some(rollback) => Err(RecipientPolicyError::RollbackFailed {
|
||||
operation: Box::new(operation),
|
||||
rollback,
|
||||
}),
|
||||
None => Err(operation),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EntryMutation {
|
||||
path: EntryPath,
|
||||
original: EncryptedEntry,
|
||||
replacement: EncryptedEntry,
|
||||
}
|
||||
|
||||
fn serialize_identities(identities: &[String]) -> Vec<u8> {
|
||||
let mut contents = identities.join("\n").into_bytes();
|
||||
contents.push(b'\n');
|
||||
contents
|
||||
}
|
||||
|
||||
fn entry_is_controlled_by(
|
||||
entry: &EntryPath,
|
||||
target: &DirectoryPath,
|
||||
snapshot: &RepositorySnapshot,
|
||||
) -> bool {
|
||||
let parent = entry.as_path().parent().unwrap_or_else(|| Path::new(""));
|
||||
if !parent.starts_with(target.as_path()) {
|
||||
return false;
|
||||
}
|
||||
!snapshot.recipient_policies().any(|policy| {
|
||||
policy.directory() != target
|
||||
&& policy.directory().as_path().starts_with(target.as_path())
|
||||
&& parent.starts_with(policy.directory().as_path())
|
||||
})
|
||||
}
|
||||
|
||||
fn nearest_parent_policy(
|
||||
snapshot: &RepositorySnapshot,
|
||||
directory: &DirectoryPath,
|
||||
) -> Option<DirectoryPath> {
|
||||
let mut current = directory.as_path().parent();
|
||||
while let Some(path) = current {
|
||||
let candidate = DirectoryPath::parse(path).expect("ancestor of validated directory");
|
||||
if snapshot.recipient_policy_at(&candidate).is_some() {
|
||||
return Some(candidate);
|
||||
}
|
||||
if path.as_os_str().is_empty() {
|
||||
break;
|
||||
}
|
||||
current = path.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn commit_message(
|
||||
action: PolicyAction,
|
||||
directory: &DirectoryPath,
|
||||
identities: &[String],
|
||||
) -> String {
|
||||
let suffix = if directory.as_path().as_os_str().is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({directory})")
|
||||
};
|
||||
match action {
|
||||
PolicyAction::Set => format!(
|
||||
"Set GPG id to {}{suffix} and selectively reencrypt password store.",
|
||||
identities.join(", ")
|
||||
),
|
||||
PolicyAction::Remove => {
|
||||
format!(
|
||||
"Deinitialize recipient policy{suffix} and selectively reencrypt password store."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RecipientPolicyError {
|
||||
Repository(RepositoryError),
|
||||
Crypto(CryptoError),
|
||||
MissingIdentities,
|
||||
PolicyNotFound {
|
||||
directory: DirectoryPath,
|
||||
},
|
||||
MissingRecipientPolicy {
|
||||
entry: EntryPath,
|
||||
},
|
||||
MissingSignature {
|
||||
directory: DirectoryPath,
|
||||
},
|
||||
Commit(PolicyCommitError),
|
||||
RollbackFailed {
|
||||
operation: Box<RecipientPolicyError>,
|
||||
rollback: RepositoryError,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for RecipientPolicyError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Repository(error) => error.fmt(formatter),
|
||||
Self::Crypto(error) => error.fmt(formatter),
|
||||
Self::MissingIdentities => formatter.write_str("at least one GPG identity is required"),
|
||||
Self::PolicyNotFound { directory } => {
|
||||
write!(formatter, "recipient policy does not exist at {directory}")
|
||||
}
|
||||
Self::MissingRecipientPolicy { entry } => {
|
||||
write!(formatter, "no recipient policy applies to entry {entry}")
|
||||
}
|
||||
Self::MissingSignature { directory } => {
|
||||
write!(
|
||||
formatter,
|
||||
"recipient policy at {directory} has no signature"
|
||||
)
|
||||
}
|
||||
Self::Commit(error) => write!(formatter, "cannot commit recipient policy: {error}"),
|
||||
Self::RollbackFailed {
|
||||
operation,
|
||||
rollback,
|
||||
} => write!(
|
||||
formatter,
|
||||
"recipient policy operation failed ({operation}) and rollback failed ({rollback})"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for RecipientPolicyError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
match self {
|
||||
Self::Repository(error) => Some(error),
|
||||
Self::Crypto(error) => Some(error),
|
||||
Self::Commit(error) => Some(error),
|
||||
Self::RollbackFailed { operation, .. } => Some(operation),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RepositoryError> for RecipientPolicyError {
|
||||
fn from(error: RepositoryError) -> Self {
|
||||
Self::Repository(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CryptoError> for RecipientPolicyError {
|
||||
fn from(error: CryptoError) -> Self {
|
||||
Self::Crypto(error)
|
||||
}
|
||||
}
|
||||
@@ -327,6 +327,10 @@ impl RepositorySnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recipient_policy_at(&self, directory: &DirectoryPath) -> Option<&RecipientPolicy> {
|
||||
self.recipients.get(directory)
|
||||
}
|
||||
|
||||
pub fn git_repository(&self, entry: &EntryPath) -> Option<&GitRepository> {
|
||||
let mut directory = entry.parent();
|
||||
loop {
|
||||
@@ -409,6 +413,95 @@ impl Repository {
|
||||
Ok(EncryptedEntry(bytes))
|
||||
}
|
||||
|
||||
pub(crate) fn read_policy_file(
|
||||
&self,
|
||||
directory: &DirectoryPath,
|
||||
signature: bool,
|
||||
) -> Result<Option<Vec<u8>>, RepositoryError> {
|
||||
let opened = match self.open_directory(&directory.0) {
|
||||
Ok(opened) => opened,
|
||||
Err(RepositoryError::NotFound { .. }) => return Ok(None),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let name = if signature {
|
||||
RECIPIENT_SIGNATURE_FILE
|
||||
} else {
|
||||
RECIPIENT_FILE
|
||||
};
|
||||
let path = directory.0.join(name);
|
||||
let Some(metadata) = child_metadata(&opened, name, &path)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
require_regular_file(metadata, &path)?;
|
||||
let mut file = opened
|
||||
.open(name)
|
||||
.map_err(|error| io_error("open recipient policy", &path, error))?;
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)
|
||||
.map_err(|error| io_error("read recipient policy", &path, error))?;
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
pub(crate) fn replace_policy_file(
|
||||
&self,
|
||||
directory: &DirectoryPath,
|
||||
signature: bool,
|
||||
contents: Option<&[u8]>,
|
||||
) -> Result<(), RepositoryError> {
|
||||
let name = if signature {
|
||||
RECIPIENT_SIGNATURE_FILE
|
||||
} else {
|
||||
RECIPIENT_FILE
|
||||
};
|
||||
let path = directory.0.join(name);
|
||||
let (opened, created) = if contents.is_some() {
|
||||
self.create_directory_path(directory)?
|
||||
} else {
|
||||
match self.open_directory(&directory.0) {
|
||||
Ok(opened) => (opened, Vec::new()),
|
||||
Err(RepositoryError::NotFound { .. }) => return Ok(()),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
};
|
||||
let result = (|| {
|
||||
if let Some(contents) = contents {
|
||||
if let Some(metadata) = child_metadata(&opened, name, &path)? {
|
||||
require_regular_file(metadata, &path)?;
|
||||
}
|
||||
let mut temporary = TempFile::new(&opened)
|
||||
.map_err(|error| io_error("create atomic recipient policy", &path, error))?;
|
||||
set_private_permissions(&temporary, &path)?;
|
||||
temporary
|
||||
.write_all(contents)
|
||||
.map_err(|error| io_error("write atomic recipient policy", &path, error))?;
|
||||
temporary
|
||||
.as_file()
|
||||
.sync_all()
|
||||
.map_err(|error| io_error("sync atomic recipient policy", &path, error))?;
|
||||
temporary
|
||||
.replace(name)
|
||||
.map_err(|error| io_error("replace recipient policy", &path, error))?;
|
||||
sync_directory(&opened, &directory.0)
|
||||
.map_err(|_| RepositoryError::DurabilityUncertain { path: path.clone() })
|
||||
} else {
|
||||
let Some(metadata) = child_metadata(&opened, name, &path)? else {
|
||||
return Ok(());
|
||||
};
|
||||
require_regular_file(metadata, &path)?;
|
||||
opened
|
||||
.remove_file(name)
|
||||
.map_err(|error| io_error("remove recipient policy", &path, error))?;
|
||||
sync_directory(&opened, &directory.0)
|
||||
.map_err(|_| RepositoryError::DurabilityUncertain { path: path.clone() })
|
||||
}
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.committed() => Err(error),
|
||||
Err(error) => self.rollback_created(created, error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically replace an encrypted entry and durably commit its containing directory.
|
||||
pub fn write_entry(
|
||||
&self,
|
||||
@@ -604,6 +697,54 @@ impl Repository {
|
||||
unreachable!("EntryPath has at least one component")
|
||||
}
|
||||
|
||||
fn create_directory_path(
|
||||
&self,
|
||||
path: &DirectoryPath,
|
||||
) -> Result<(Dir, Vec<PathBuf>), RepositoryError> {
|
||||
let mut directory = self
|
||||
.root
|
||||
.try_clone()
|
||||
.map_err(|error| io_error("clone repository root", Path::new("."), error))?;
|
||||
let mut relative = PathBuf::new();
|
||||
let mut created = Vec::new();
|
||||
for component in path.0.components() {
|
||||
let Component::Normal(name) = component else {
|
||||
unreachable!("DirectoryPath is validated")
|
||||
};
|
||||
if let Err(error) = reject_entry_directory_collision(&directory, name, &relative) {
|
||||
return self.rollback_created(created, error);
|
||||
}
|
||||
relative.push(name);
|
||||
match child_metadata(&directory, name, &relative) {
|
||||
Ok(Some(metadata)) => {
|
||||
if let Err(error) = require_directory(metadata, &relative) {
|
||||
return self.rollback_created(created, error);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
if let Err(error) = create_private_directory(&directory, name, &relative) {
|
||||
return self.rollback_created(created, error);
|
||||
}
|
||||
created.push(relative.clone());
|
||||
if let Err(error) = sync_directory(&directory, &relative) {
|
||||
return self.rollback_created(created, error);
|
||||
}
|
||||
}
|
||||
Err(error) => return self.rollback_created(created, error),
|
||||
}
|
||||
match directory.open_dir(name) {
|
||||
Ok(opened) => directory = opened,
|
||||
Err(error) => {
|
||||
return self.rollback_created(
|
||||
created,
|
||||
io_error("open recipient policy directory", &relative, error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((directory, created))
|
||||
}
|
||||
|
||||
fn rollback_created<T>(
|
||||
&self,
|
||||
mut created: Vec<PathBuf>,
|
||||
|
||||
409
crates/storage/tests/recipient_policy.rs
Normal file
409
crates/storage/tests/recipient_policy.rs
Normal file
@@ -0,0 +1,409 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod support;
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use ironstorage::{
|
||||
command::InitRequest,
|
||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
recipient::{
|
||||
NoGitCommitter, PolicyCommit, PolicyCommitError, PolicyCommitter, RecipientPolicyError,
|
||||
RecipientPolicyManager, SigningPolicy,
|
||||
},
|
||||
repository::{EntryPath, Repository, SecretBytes},
|
||||
};
|
||||
use support::compatibility::{FixtureSet, TestResult};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FixtureSecrets {
|
||||
values: BTreeMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl FixtureSecrets {
|
||||
fn all(fixture: &FixtureSet) -> Self {
|
||||
Self {
|
||||
values: fixture
|
||||
.generated
|
||||
.keys
|
||||
.iter()
|
||||
.map(|key| {
|
||||
(
|
||||
key.primary_fingerprint.clone(),
|
||||
key.passphrase.as_bytes().to_vec(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretProvider for FixtureSecrets {
|
||||
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
|
||||
self.values
|
||||
.get(key.fingerprint().as_str())
|
||||
.cloned()
|
||||
.map(SecretBytes::new)
|
||||
.ok_or(SecretProviderError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingCommitter {
|
||||
changes: Vec<PolicyCommit>,
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
impl PolicyCommitter for RecordingCommitter {
|
||||
fn commit(&mut self, change: &PolicyCommit) -> Result<(), PolicyCommitError> {
|
||||
self.changes.push(change.clone());
|
||||
if self.fail {
|
||||
Err(PolicyCommitError::new("simulated embedded Git failure"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_nearest_root_and_nested_recipient_policies() -> 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 manager = RecipientPolicyManager::new(&repository, &keys);
|
||||
let alice = fixture.key("alice")?;
|
||||
let bob = fixture.key("bob")?;
|
||||
|
||||
let root = manager.resolve_for_entry(&EntryPath::parse("email/personal")?, None)?;
|
||||
assert_eq!(root.policy_directory().as_path(), Path::new(""));
|
||||
assert_eq!(
|
||||
root.recipients()[0].fingerprint().as_str(),
|
||||
alice.primary_fingerprint
|
||||
);
|
||||
|
||||
let team = manager.resolve_for_entry(&EntryPath::parse("team/service")?, None)?;
|
||||
assert_eq!(team.policy_directory().as_path(), Path::new("team"));
|
||||
assert_eq!(
|
||||
team.recipients()[0].fingerprint().as_str(),
|
||||
bob.primary_fingerprint
|
||||
);
|
||||
|
||||
let shared = manager.resolve_for_entry(&EntryPath::parse("shared/multiple")?, None)?;
|
||||
assert_eq!(shared.policy_directory().as_path(), Path::new("shared"));
|
||||
assert_eq!(shared.recipients().len(), 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_init_reencrypts_only_entries_not_shielded_by_overrides() -> 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 manager = RecipientPolicyManager::new(&repository, &keys);
|
||||
let bob = fixture.key("bob")?;
|
||||
let mut provider = FixtureSecrets::all(&fixture);
|
||||
let mut committer = RecordingCommitter::default();
|
||||
|
||||
let outcome = manager.apply_init(
|
||||
&init(None, &[&bob.primary_fingerprint]),
|
||||
None,
|
||||
&mut provider,
|
||||
&mut committer,
|
||||
)?;
|
||||
assert_eq!(outcome.reencrypted_entries().len(), 4);
|
||||
assert_eq!(committer.changes.len(), 1);
|
||||
assert_eq!(
|
||||
committer.changes[0].changed_paths(),
|
||||
vec![
|
||||
PathBuf::from(".gpg-id"),
|
||||
PathBuf::from("email/personal.gpg"),
|
||||
PathBuf::from("otp/hotp.gpg"),
|
||||
PathBuf::from("otp/totp.gpg"),
|
||||
PathBuf::from("unicode/咖啡.gpg"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(store.path().join(".gpg-id"))?,
|
||||
format!("{}\n", bob.primary_fingerprint).as_bytes()
|
||||
);
|
||||
|
||||
let bob_recipient = [keys.resolve(&bob.primary_fingerprint)?];
|
||||
for path in ["email/personal", "otp/hotp", "otp/totp", "unicode/咖啡"] {
|
||||
assert!(keys.is_encrypted_for(
|
||||
&repository.read_entry(&EntryPath::parse(path)?)?,
|
||||
&bob_recipient
|
||||
)?);
|
||||
}
|
||||
let before_commits = committer.changes.len();
|
||||
let no_op = manager.apply_init(
|
||||
&init(None, &[&bob.primary_fingerprint]),
|
||||
None,
|
||||
&mut provider,
|
||||
&mut committer,
|
||||
)?;
|
||||
assert!(!no_op.committed());
|
||||
assert_eq!(committer.changes.len(), before_commits);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_multiple_recipients_and_removed_override_preserve_plaintext() -> 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 manager = RecipientPolicyManager::new(&repository, &keys);
|
||||
let alice = fixture.key("alice")?;
|
||||
let bob = fixture.key("bob")?;
|
||||
let path = EntryPath::parse("team/service")?;
|
||||
let expected = fixture.read("expected/basic/team/service.txt")?;
|
||||
let mut provider = FixtureSecrets::all(&fixture);
|
||||
let mut committer = RecordingCommitter::default();
|
||||
|
||||
let multiple = manager.apply_init(
|
||||
&init(
|
||||
Some("team"),
|
||||
&[&alice.primary_fingerprint, &bob.primary_fingerprint],
|
||||
),
|
||||
None,
|
||||
&mut provider,
|
||||
&mut committer,
|
||||
)?;
|
||||
assert_eq!(multiple.reencrypted_entries(), std::slice::from_ref(&path));
|
||||
assert_eq!(multiple.recipients().len(), 2);
|
||||
assert_eq!(
|
||||
fs::read(store.path().join("team/.gpg-id"))?,
|
||||
format!(
|
||||
"{}\n{}\n",
|
||||
alice.primary_fingerprint, bob.primary_fingerprint
|
||||
)
|
||||
.as_bytes()
|
||||
);
|
||||
let plaintext = keys.decrypt(&repository.read_entry(&path)?, &mut provider)?;
|
||||
assert_eq!(plaintext.expose(), expected);
|
||||
|
||||
let removed = manager.apply_init(
|
||||
&init(Some("team"), &[""]),
|
||||
None,
|
||||
&mut provider,
|
||||
&mut committer,
|
||||
)?;
|
||||
assert_eq!(removed.reencrypted_entries(), std::slice::from_ref(&path));
|
||||
assert!(!store.path().join("team/.gpg-id").exists());
|
||||
assert!(
|
||||
store.path().join("team/.gpg-id.sig").exists(),
|
||||
"upstream pass leaves an existing detached signature when an override is removed"
|
||||
);
|
||||
assert_eq!(
|
||||
removed.recipients()[0].fingerprint().as_str(),
|
||||
alice.primary_fingerprint
|
||||
);
|
||||
let plaintext = keys.decrypt(&repository.read_entry(&path)?, &mut provider)?;
|
||||
assert_eq!(plaintext.expose(), expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_and_authenticates_signed_recipient_policies() -> 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 manager = RecipientPolicyManager::new(&repository, &keys);
|
||||
let alice = fixture.key("alice")?;
|
||||
let bob = fixture.key("bob")?;
|
||||
let alice_handle = keys.resolve(&alice.primary_fingerprint)?;
|
||||
let bob_handle = keys.resolve(&bob.primary_fingerprint)?;
|
||||
let alice_policy = SigningPolicy::new(alice_handle.clone(), vec![alice_handle.clone()]);
|
||||
|
||||
let resolved =
|
||||
manager.resolve_for_entry(&EntryPath::parse("email/personal")?, Some(&alice_policy))?;
|
||||
assert_eq!(resolved.policy_directory().as_path(), Path::new(""));
|
||||
|
||||
let before = tree_bytes(store.path())?;
|
||||
let wrong_trust = SigningPolicy::new(alice_handle, vec![bob_handle]);
|
||||
let mut provider = FixtureSecrets::all(&fixture);
|
||||
let error = manager
|
||||
.apply_init(
|
||||
&init(None, &[&bob.primary_fingerprint]),
|
||||
Some(&wrong_trust),
|
||||
&mut provider,
|
||||
&mut NoGitCommitter,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
RecipientPolicyError::Crypto(CryptoError::InvalidSignature)
|
||||
));
|
||||
assert_eq!(tree_bytes(store.path())?, before);
|
||||
|
||||
let alice_handle = keys.resolve(&alice.primary_fingerprint)?;
|
||||
let signing = SigningPolicy::new(alice_handle.clone(), vec![alice_handle]);
|
||||
manager.apply_init(
|
||||
&init(None, &[&bob.primary_fingerprint]),
|
||||
Some(&signing),
|
||||
&mut provider,
|
||||
&mut NoGitCommitter,
|
||||
)?;
|
||||
assert!(store.path().join(".gpg-id.sig").is_file());
|
||||
manager.resolve_for_entry(&EntryPath::parse("email/personal")?, Some(&signing))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_recipients_and_commit_failure_leave_the_tree_byte_exact() -> 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 manager = RecipientPolicyManager::new(&repository, &keys);
|
||||
let bob = fixture.key("bob")?;
|
||||
let before = tree_bytes(store.path())?;
|
||||
let mut provider = FixtureSecrets::all(&fixture);
|
||||
let mut committer = RecordingCommitter::default();
|
||||
|
||||
assert!(matches!(
|
||||
manager.apply_init(
|
||||
&init(None, &["missing@ironstorage.invalid"]),
|
||||
None,
|
||||
&mut provider,
|
||||
&mut committer,
|
||||
),
|
||||
Err(RecipientPolicyError::Crypto(
|
||||
CryptoError::MissingIdentity { .. }
|
||||
))
|
||||
));
|
||||
assert_eq!(tree_bytes(store.path())?, before);
|
||||
assert!(committer.changes.is_empty());
|
||||
|
||||
assert!(matches!(
|
||||
manager.apply_init(&init(None, &[""]), None, &mut provider, &mut committer,),
|
||||
Err(RecipientPolicyError::MissingRecipientPolicy { .. })
|
||||
));
|
||||
assert_eq!(tree_bytes(store.path())?, before);
|
||||
|
||||
committer.fail = true;
|
||||
assert!(matches!(
|
||||
manager.apply_init(
|
||||
&init(None, &[&bob.primary_fingerprint]),
|
||||
None,
|
||||
&mut provider,
|
||||
&mut committer,
|
||||
),
|
||||
Err(RecipientPolicyError::Commit(_))
|
||||
));
|
||||
assert_eq!(tree_bytes(store.path())?, before);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_store_supports_root_and_path_init_and_removal() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let repository = Repository::open(temporary.path())?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let manager = RecipientPolicyManager::new(&repository, &keys);
|
||||
let alice = fixture.key("alice")?;
|
||||
let bob = fixture.key("bob")?;
|
||||
let mut provider = FixtureSecrets::all(&fixture);
|
||||
let mut committer = RecordingCommitter::default();
|
||||
|
||||
manager.apply_init(
|
||||
&init(
|
||||
None,
|
||||
&[&alice.primary_fingerprint, &bob.primary_fingerprint],
|
||||
),
|
||||
None,
|
||||
&mut provider,
|
||||
&mut committer,
|
||||
)?;
|
||||
manager.apply_init(
|
||||
&init(Some("clients/acme"), &[&bob.primary_fingerprint]),
|
||||
None,
|
||||
&mut provider,
|
||||
&mut committer,
|
||||
)?;
|
||||
assert!(temporary.path().join("clients/acme/.gpg-id").is_file());
|
||||
|
||||
manager.apply_init(
|
||||
&init(Some("clients/acme"), &[""]),
|
||||
None,
|
||||
&mut provider,
|
||||
&mut committer,
|
||||
)?;
|
||||
assert!(!temporary.path().join("clients").exists());
|
||||
assert!(matches!(
|
||||
manager.apply_init(
|
||||
&init(Some("clients/acme"), &[""]),
|
||||
None,
|
||||
&mut provider,
|
||||
&mut committer,
|
||||
),
|
||||
Err(RecipientPolicyError::PolicyNotFound { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_commit_removes_new_empty_policy_directories() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let repository = Repository::open(temporary.path())?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let manager = RecipientPolicyManager::new(&repository, &keys);
|
||||
let alice = fixture.key("alice")?;
|
||||
let mut provider = FixtureSecrets::all(&fixture);
|
||||
let mut committer = RecordingCommitter {
|
||||
fail: true,
|
||||
..RecordingCommitter::default()
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
manager.apply_init(
|
||||
&init(Some("new/deep"), &[&alice.primary_fingerprint]),
|
||||
None,
|
||||
&mut provider,
|
||||
&mut committer,
|
||||
),
|
||||
Err(RecipientPolicyError::Commit(_))
|
||||
));
|
||||
assert!(fs::read_dir(temporary.path())?.next().is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init(path: Option<&str>, identities: &[&str]) -> InitRequest {
|
||||
InitRequest {
|
||||
path: path.map(str::to_owned),
|
||||
key_identities: identities
|
||||
.iter()
|
||||
.map(|identity| (*identity).to_owned())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tree_bytes(root: &Path) -> TestResult<BTreeMap<PathBuf, Vec<u8>>> {
|
||||
fn visit(root: &Path, current: &Path, files: &mut BTreeMap<PathBuf, Vec<u8>>) -> TestResult {
|
||||
let mut entries = fs::read_dir(current)?.collect::<Result<Vec<_>, _>>()?;
|
||||
entries.sort_by_key(fs::DirEntry::file_name);
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
let kind = entry.file_type()?;
|
||||
if kind.is_dir() {
|
||||
visit(root, &path, files)?;
|
||||
} else if kind.is_file() {
|
||||
files.insert(path.strip_prefix(root)?.to_owned(), fs::read(path)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let mut files = BTreeMap::new();
|
||||
visit(root, root, &mut files)?;
|
||||
Ok(files)
|
||||
}
|
||||
Reference in New Issue
Block a user