Implement hierarchical recipient policies (#5)

This commit is contained in:
Hermes Agent
2026-08-09 22:12:43 +00:00
parent 08bc244a4a
commit 34ce731716
7 changed files with 1284 additions and 0 deletions

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