use std::cell::Cell;
use std::fs::File;
use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[cfg(feature = "indexed-storage")]
use std::sync::Mutex;
use fs2::FileExt;
use log::warn;
use auths_core::storage::keychain::IdentityDID;
use auths_verifier::core::{Attestation, VerifiedAttestation};
use auths_verifier::types::{CanonicalDid, DeviceDID};
use git2::{Oid, Repository, Signature, Tree};
use auths_id::keri::event::Event;
use auths_id::keri::state::KeyState;
use auths_id::keri::validate::{ValidationError, verify_event_crypto, verify_event_said};
use auths_verifier::keri::Prefix;
use super::paths;
use super::vfs::{OsVfs, Vfs};
use auths_id::ports::registry::{RegistryBackend, RegistryError};
use auths_verifier::clock::{ClockProvider, SystemClock};
fn from_git2(e: git2::Error) -> RegistryError {
match e.code() {
git2::ErrorCode::NotFound => RegistryError::NotFound {
entity_type: "git object".into(),
id: e.message().to_string(),
},
git2::ErrorCode::Locked => {
RegistryError::ConcurrentModification(format!("Git lock conflict: {}", e.message()))
}
_ => RegistryError::storage(e),
}
}
use super::tree_ops::{TreeMutator, TreeNavigator};
#[cfg(feature = "indexed-storage")]
use auths_id::storage::registry::org_member::{MemberFilter, MemberView};
use auths_id::storage::registry::org_member::{
MemberInvalidReason, OrgMemberEntry, expected_org_issuer,
};
use auths_id::storage::registry::schemas::{
CachedStateJson, RegistryMetadata, SCHEMA_VERSION, TipInfo,
};
use auths_id::storage::registry::shard::{
device_path, identity_path, org_path, path_parts, sanitize_did, unsanitize_did,
};
pub const REGISTRY_REF: &str = "refs/auths/registry";
struct AdvisoryLock {
file: File,
}
impl AdvisoryLock {
fn acquire(repo_path: &Path) -> Result<Self, RegistryError> {
let lock_path = repo_path.join("registry.lock");
let file = File::create(&lock_path)?;
file.lock_exclusive()?;
Ok(Self { file })
}
}
impl Drop for AdvisoryLock {
fn drop(&mut self) {
let _ = self.file.unlock();
}
}
use super::config::{RegistryConfig, TenantMetadata, TenantStatus};
#[derive(Clone)]
pub struct GitRegistryBackend {
repo_path: PathBuf,
tenant_id: Option<String>,
clock: Arc<dyn ClockProvider>,
vfs: Arc<dyn Vfs>,
#[cfg(feature = "indexed-storage")]
index: Option<Arc<Mutex<auths_index::AttestationIndex>>>,
}
impl GitRegistryBackend {
pub fn from_config_unchecked(config: RegistryConfig) -> Self {
let repo_path = config.resolve_repo_path();
Self {
#[cfg(feature = "indexed-storage")]
index: Self::open_index_if_present(&repo_path),
tenant_id: config.tenant_id.map(String::from),
clock: Arc::new(SystemClock),
vfs: Arc::new(OsVfs),
repo_path,
}
}
pub fn open_existing(config: RegistryConfig) -> Result<Self, RegistryError> {
let backend = Self::from_config_unchecked(config);
let repo = backend.open_repo()?;
repo.find_reference(REGISTRY_REF)
.map_err(|_| RegistryError::NotFound {
entity_type: "registry".into(),
id: format!("{} (tenant may be unprovisioned)", REGISTRY_REF),
})?;
Ok(backend)
}
#[cfg(feature = "indexed-storage")]
fn open_index_if_present(
repo_path: &Path,
) -> Option<Arc<Mutex<auths_index::AttestationIndex>>> {
let index_path = repo_path.join(".auths-index.db");
match auths_index::AttestationIndex::open_or_create(&index_path) {
Ok(idx) => Some(Arc::new(Mutex::new(idx))),
Err(e) => {
log::warn!(
"Failed to open index at {:?}: {} — index disabled for this backend",
index_path,
e
);
None
}
}
}
pub fn repo_path(&self) -> &Path {
&self.repo_path
}
pub fn tenant_id(&self) -> Option<&str> {
self.tenant_id.as_deref()
}
pub fn init_if_needed(&self) -> Result<bool, RegistryError> {
std::fs::create_dir_all(&self.repo_path)?;
match Repository::open(&self.repo_path) {
Ok(repo) => {
if repo.find_reference(REGISTRY_REF).is_ok() {
return Ok(false); }
self.write_initial_ref(&repo)?;
}
Err(_) => {
let repo = match Repository::init(&self.repo_path) {
Ok(r) => r,
Err(_) => {
let mut attempts: u32 = 0;
loop {
match Repository::open(&self.repo_path) {
Ok(_) => return Ok(false), Err(_) if attempts < 40 => {
std::thread::sleep(std::time::Duration::from_millis(5));
attempts += 1;
}
Err(e) => return Err(from_git2(e)),
}
}
}
};
if repo.find_reference(REGISTRY_REF).is_ok() {
return Ok(false); }
self.write_initial_ref(&repo)?;
}
}
self.write_tenant_metadata()?;
Ok(true)
}
fn write_initial_ref(&self, repo: &Repository) -> Result<(), RegistryError> {
let metadata = RegistryMetadata::empty();
let metadata_json = serde_json::to_vec_pretty(&metadata)?;
let mut mutator = TreeMutator::new();
mutator.write_blob(&paths::versioned("metadata.json"), metadata_json);
let tree_oid = mutator.build_tree(repo, None)?;
self.create_commit(repo, tree_oid, None, "Initialize registry")?;
Ok(())
}
fn write_tenant_metadata(&self) -> Result<(), RegistryError> {
let tenant_id = match &self.tenant_id {
Some(id) => id.clone(),
None => return Ok(()), };
let metadata = TenantMetadata {
version: 1,
tenant_id,
created_at: self.clock.now(),
status: TenantStatus::Active,
plan: None,
};
let json = serde_json::to_string_pretty(&metadata).map_err(RegistryError::from)?;
let final_path = self.repo_path.join("tenant.json");
self.vfs.atomic_write(&final_path, json.as_bytes())?;
Ok(())
}
pub fn load_tenant_metadata(&self) -> Result<TenantMetadata, RegistryError> {
let path = self.repo_path.join("tenant.json");
let bytes = std::fs::read(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
RegistryError::NotFound {
entity_type: "tenant.json".into(),
id: path.display().to_string(),
}
} else {
RegistryError::Io(e)
}
})?;
let metadata = serde_json::from_slice(&bytes)?;
Ok(metadata)
}
fn open_repo(&self) -> Result<Repository, RegistryError> {
Repository::open(&self.repo_path).map_err(from_git2)
}
fn current_tree<'a>(&self, repo: &'a Repository) -> Result<Tree<'a>, RegistryError> {
let reference = repo.find_reference(REGISTRY_REF).map_err(from_git2)?;
let commit = reference.peel_to_commit().map_err(from_git2)?;
commit.tree().map_err(from_git2)
}
fn current_commit_and_tree<'a>(
&self,
repo: &'a Repository,
) -> Result<(git2::Commit<'a>, Tree<'a>), RegistryError> {
let reference = repo.find_reference(REGISTRY_REF).map_err(from_git2)?;
let commit = reference.peel_to_commit().map_err(from_git2)?;
let tree = commit.tree().map_err(from_git2)?;
Ok((commit, tree))
}
fn create_commit(
&self,
repo: &Repository,
tree_oid: Oid,
parent: Option<&git2::Commit>,
message: &str,
) -> Result<Oid, RegistryError> {
let _lock = AdvisoryLock::acquire(&self.repo_path)?;
self.create_commit_unlocked(repo, tree_oid, parent, message)
}
#[allow(clippy::disallowed_methods)]
fn create_commit_unlocked(
&self,
repo: &Repository,
tree_oid: Oid,
parent: Option<&git2::Commit>,
message: &str,
) -> Result<Oid, RegistryError> {
let sig = self.get_signature(repo, chrono::Utc::now())?;
let tree = repo.find_tree(tree_oid).map_err(from_git2)?;
let parents: Vec<&git2::Commit> = parent.into_iter().collect();
let commit_oid = repo
.commit(None, &sig, &sig, message, &tree, &parents)
.map_err(from_git2)?;
match parent {
Some(expected_parent) => {
let expected_oid = expected_parent.id();
let mut current_ref = repo.find_reference(REGISTRY_REF).map_err(from_git2)?;
let current_oid = current_ref
.target()
.ok_or_else(|| RegistryError::Internal("Ref is symbolic".into()))?;
if current_oid != expected_oid {
warn!(
"CAS failed on {}: expected OID {}, found {}",
REGISTRY_REF, expected_oid, current_oid
);
return Err(RegistryError::ConcurrentModification(format!(
"Registry ref changed: expected {}, found {}",
expected_oid, current_oid
)));
}
current_ref
.set_target(commit_oid, message)
.map_err(from_git2)?;
}
None => {
repo.reference(REGISTRY_REF, commit_oid, false, message)
.map_err(from_git2)?;
}
}
Ok(commit_oid)
}
fn get_signature(
&self,
repo: &Repository,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Signature<'static>, RegistryError> {
repo.signature()
.or_else(|_| {
Signature::new(
"authly",
"authly@localhost",
&git2::Time::new(now.timestamp(), 0),
)
})
.map_err(from_git2)
}
fn compute_state_after_event(
&self,
current_state: Option<&KeyState>,
event: &Event,
) -> Result<KeyState, RegistryError> {
match event {
Event::Icp(icp) => {
let threshold = icp.kt.parse::<u64>().unwrap_or(1);
let next_threshold = icp.nt.parse::<u64>().unwrap_or(1);
Ok(KeyState::from_inception(
icp.i.clone(),
icp.k.clone(),
icp.n.clone(),
threshold,
next_threshold,
icp.d.clone(),
))
}
Event::Rot(rot) => {
let mut state = current_state.cloned().ok_or_else(|| {
RegistryError::Internal("Rotation without prior state".into())
})?;
let seq = event.sequence().value();
let threshold = rot.kt.parse::<u64>().unwrap_or(1);
let next_threshold = rot.nt.parse::<u64>().unwrap_or(1);
state.apply_rotation(
rot.k.clone(),
rot.n.clone(),
threshold,
next_threshold,
seq,
rot.d.clone(),
);
Ok(state)
}
Event::Ixn(ixn) => {
let mut state = current_state.cloned().ok_or_else(|| {
RegistryError::Internal("Interaction without prior state".into())
})?;
let seq = event.sequence().value();
state.apply_interaction(seq, ixn.d.clone());
Ok(state)
}
}
}
fn update_metadata(
&self,
mutator: &mut TreeMutator,
navigator: &TreeNavigator,
identity_delta: i64,
device_delta: i64,
member_delta: i64,
) -> Result<(), RegistryError> {
let current_meta = match navigator.read_blob_path(&paths::versioned("metadata.json")) {
Ok(bytes) => serde_json::from_slice::<RegistryMetadata>(&bytes)
.map_err(|e| RegistryError::Internal(format!("Corrupt metadata.json: {}", e)))?,
Err(RegistryError::NotFound { .. }) => RegistryMetadata::empty(),
Err(e) => return Err(e),
};
let new_meta = RegistryMetadata {
version: SCHEMA_VERSION,
identity_count: (current_meta.identity_count as i64 + identity_delta).max(0) as u64,
device_count: (current_meta.device_count as i64 + device_delta).max(0) as u64,
member_count: (current_meta.member_count as i64 + member_delta).max(0) as u64,
updated_at: self.clock.now(),
};
mutator.write_blob(
&paths::versioned("metadata.json"),
serde_json::to_vec_pretty(&new_meta)?,
);
Ok(())
}
#[allow(dead_code)] fn visit_orgs<F>(&self, mut visitor: F) -> Result<(), RegistryError>
where
F: FnMut(&str) -> ControlFlow<()>,
{
let repo = self.open_repo()?;
let tree = self.current_tree(&repo)?;
let navigator = TreeNavigator::new(&repo, tree);
let orgs_base = paths::versioned("orgs");
let orgs_path = path_parts(&orgs_base);
if !navigator.exists(&orgs_path) {
return Ok(());
}
let captured_error: Cell<Option<RegistryError>> = Cell::new(None);
navigator.visit_dir(&orgs_path, |s1| {
let s1_path = paths::child(&orgs_base, s1);
let s1_parts = path_parts(&s1_path);
if let Err(e) = navigator.visit_dir(&s1_parts, |s2| {
let s2_path = paths::child(&s1_path, s2);
let s2_parts = path_parts(&s2_path);
if let Err(e) = navigator.visit_dir(&s2_parts, |org| visitor(org)) {
captured_error.set(Some(e));
return ControlFlow::Break(());
}
ControlFlow::Continue(())
}) {
captured_error.set(Some(e));
return ControlFlow::Break(());
}
ControlFlow::Continue(())
})?;
if let Some(e) = captured_error.take() {
return Err(e);
}
Ok(())
}
pub fn batch_append_events(&self, events: &[(Prefix, Event)]) -> Result<(), RegistryError> {
if events.is_empty() {
return Ok(());
}
let _lock = AdvisoryLock::acquire(&self.repo_path)?;
let repo = self.open_repo()?;
let (parent, base_tree) = self.current_commit_and_tree(&repo)?;
let navigator = TreeNavigator::new(&repo, base_tree.clone());
let mut mutator = TreeMutator::new();
let mut state_overlay: std::collections::HashMap<String, KeyState> =
std::collections::HashMap::new();
let mut tip_overlay: std::collections::HashMap<String, TipInfo> =
std::collections::HashMap::new();
let mut identity_delta: i64 = 0;
for (i, (prefix, event)) in events.iter().enumerate() {
let result = self.validate_and_stage_event(
prefix,
event,
&navigator,
&mut mutator,
&mut state_overlay,
&mut tip_overlay,
&mut identity_delta,
);
if let Err(e) = result {
return Err(RegistryError::BatchValidationFailed {
index: i,
source: Box::new(e),
});
}
}
self.update_metadata(&mut mutator, &navigator, identity_delta, 0, 0)?;
let new_tree_oid = mutator.build_tree(&repo, Some(&base_tree))?;
self.create_commit_unlocked(
&repo,
new_tree_oid,
Some(&parent),
&format!("batch: {} events", events.len()),
)?;
#[cfg(feature = "indexed-storage")]
for (prefix_str, state) in &state_overlay {
if let Some(index) = &self.index {
let indexed = auths_index::IndexedIdentity {
prefix: auths_verifier::keri::Prefix::new_unchecked(prefix_str.clone()),
current_keys: state.current_keys.clone(),
sequence: state.sequence,
tip_said: state.last_event_said.clone(),
updated_at: self.clock.now(),
};
#[allow(clippy::unwrap_used)]
if let Err(e) = index.lock().unwrap().upsert_identity(&indexed) {
log::warn!("Index update failed for identity {}: {}", prefix_str, e);
}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn validate_and_stage_event(
&self,
prefix: &Prefix,
event: &Event,
navigator: &TreeNavigator,
mutator: &mut TreeMutator,
state_overlay: &mut std::collections::HashMap<String, KeyState>,
tip_overlay: &mut std::collections::HashMap<String, TipInfo>,
identity_delta: &mut i64,
) -> Result<(), RegistryError> {
let base_path = identity_path(prefix)?;
let seq = event.sequence().value();
let event_path = paths::event_file(&base_path, seq);
let tip_path = paths::tip_file(&base_path);
let state_path = paths::state_file(&base_path);
let prefix_key = prefix.to_string();
if navigator.exists_path(&event_path) {
return Err(RegistryError::EventExists {
prefix: prefix_key,
seq,
});
}
if event.prefix() != prefix {
return Err(RegistryError::InvalidPrefix {
prefix: prefix_key,
reason: format!(
"event prefix '{}' does not match expected '{}'",
event.prefix(),
prefix
),
});
}
let current_tip = tip_overlay.get(&prefix_key).cloned().or_else(|| {
navigator
.read_blob_path(&tip_path)
.ok()
.and_then(|bytes| serde_json::from_slice::<TipInfo>(&bytes).ok())
});
let expected_seq = current_tip.as_ref().map(|t| t.sequence + 1).unwrap_or(0);
if seq != expected_seq {
return Err(RegistryError::SequenceGap {
prefix: prefix_key,
expected: expected_seq,
got: seq,
});
}
if seq == 0 && !event.is_inception() {
return Err(RegistryError::Internal(
"First event (seq 0) must be inception".into(),
));
}
if seq > 0 {
let prev_said = event.previous().ok_or_else(|| {
RegistryError::Internal(format!(
"Event at seq {} must have previous SAID (p field)",
seq
))
})?;
let expected_prev = current_tip
.as_ref()
.map(|t| t.said.as_str())
.ok_or_else(|| {
RegistryError::Internal("No tip found for non-zero sequence".into())
})?;
if prev_said != expected_prev {
return Err(RegistryError::SaidMismatch {
expected: expected_prev.to_string(),
actual: prev_said.to_string(),
});
}
}
verify_event_said(event).map_err(|e| match e {
ValidationError::InvalidSaid { expected, actual } => RegistryError::SaidMismatch {
expected: expected.to_string(),
actual: actual.to_string(),
},
_ => RegistryError::InvalidEvent {
reason: e.to_string(),
},
})?;
let current_state = state_overlay.get(&prefix_key).cloned().or_else(|| {
navigator
.read_blob_path(&state_path)
.ok()
.and_then(|bytes| serde_json::from_slice::<CachedStateJson>(&bytes).ok())
.map(|c| c.state)
});
verify_event_crypto(event, current_state.as_ref()).map_err(|e| match e {
ValidationError::SignatureFailed { sequence } => RegistryError::InvalidEvent {
reason: format!("Signature verification failed at sequence {}", sequence),
},
ValidationError::CommitmentMismatch { sequence } => RegistryError::InvalidEvent {
reason: format!("Pre-rotation commitment mismatch at sequence {}", sequence),
},
_ => RegistryError::InvalidEvent {
reason: e.to_string(),
},
})?;
let event_json = serde_json::to_vec_pretty(event)?;
mutator.write_blob(&event_path, event_json);
let tip = TipInfo::new(seq, event.said().clone());
mutator.write_blob(&tip_path, serde_json::to_vec_pretty(&tip)?);
let new_state = self.compute_state_after_event(current_state.as_ref(), event)?;
let cached_state = CachedStateJson::new(new_state.clone(), event.said().clone());
mutator.write_blob(&state_path, serde_json::to_vec_pretty(&cached_state)?);
tip_overlay.insert(prefix_key.clone(), tip);
state_overlay.insert(prefix_key, new_state);
if seq == 0 {
*identity_delta += 1;
}
Ok(())
}
}
impl RegistryBackend for GitRegistryBackend {
fn append_event(&self, prefix: &Prefix, event: &Event) -> Result<(), RegistryError> {
let repo = self.open_repo()?;
let (parent, base_tree) = self.current_commit_and_tree(&repo)?;
let navigator = TreeNavigator::new(&repo, base_tree.clone());
let base_path = identity_path(prefix)?;
let seq = event.sequence().value();
let event_path = paths::event_file(&base_path, seq);
let tip_path = paths::tip_file(&base_path);
let state_path = paths::state_file(&base_path);
if navigator.exists_path(&event_path) {
return Err(RegistryError::EventExists {
prefix: prefix.to_string(),
seq,
});
}
if event.prefix() != prefix {
return Err(RegistryError::InvalidPrefix {
prefix: prefix.to_string(),
reason: format!(
"event prefix '{}' does not match expected '{}'",
event.prefix(),
prefix
),
});
}
let current_tip = navigator
.read_blob_path(&tip_path)
.ok()
.and_then(|bytes| serde_json::from_slice::<TipInfo>(&bytes).ok());
let expected_seq = current_tip.as_ref().map(|t| t.sequence + 1).unwrap_or(0);
if seq != expected_seq {
return Err(RegistryError::SequenceGap {
prefix: prefix.to_string(),
expected: expected_seq,
got: seq,
});
}
if seq == 0 && !event.is_inception() {
return Err(RegistryError::Internal(
"First event (seq 0) must be inception".into(),
));
}
if seq > 0 {
let prev_said = event.previous().ok_or_else(|| {
RegistryError::Internal(format!(
"Event at seq {} must have previous SAID (p field)",
seq
))
})?;
let expected_prev = current_tip
.as_ref()
.map(|t| t.said.as_str())
.ok_or_else(|| {
RegistryError::Internal("No tip found for non-zero sequence".into())
})?;
if prev_said != expected_prev {
return Err(RegistryError::SaidMismatch {
expected: expected_prev.to_string(),
actual: prev_said.to_string(),
});
}
}
verify_event_said(event).map_err(|e| match e {
ValidationError::InvalidSaid { expected, actual } => RegistryError::SaidMismatch {
expected: expected.to_string(),
actual: actual.to_string(),
},
_ => RegistryError::InvalidEvent {
reason: e.to_string(),
},
})?;
let current_state = navigator
.read_blob_path(&state_path)
.ok()
.and_then(|bytes| serde_json::from_slice::<CachedStateJson>(&bytes).ok())
.map(|c| c.state);
verify_event_crypto(event, current_state.as_ref()).map_err(|e| match e {
ValidationError::SignatureFailed { sequence } => RegistryError::InvalidEvent {
reason: format!("Signature verification failed at sequence {}", sequence),
},
ValidationError::CommitmentMismatch { sequence } => RegistryError::InvalidEvent {
reason: format!("Pre-rotation commitment mismatch at sequence {}", sequence),
},
_ => RegistryError::InvalidEvent {
reason: e.to_string(),
},
})?;
let mut mutator = TreeMutator::new();
let event_json = serde_json::to_vec_pretty(event)?;
mutator.write_blob(&event_path, event_json);
let tip = TipInfo::new(seq, event.said().clone());
mutator.write_blob(&tip_path, serde_json::to_vec_pretty(&tip)?);
let new_state = self.compute_state_after_event(current_state.as_ref(), event)?;
let cached_state = CachedStateJson::new(new_state.clone(), event.said().clone());
mutator.write_blob(&state_path, serde_json::to_vec_pretty(&cached_state)?);
let identity_delta = if seq == 0 { 1 } else { 0 };
self.update_metadata(&mut mutator, &navigator, identity_delta, 0, 0)?;
let new_tree_oid = mutator.build_tree(&repo, Some(&base_tree))?;
self.create_commit(
&repo,
new_tree_oid,
Some(&parent),
&format!("Append event {} seq {}", prefix, seq),
)?;
#[cfg(feature = "indexed-storage")]
if let Some(index) = &self.index {
let indexed = auths_index::IndexedIdentity {
prefix: prefix.clone(),
current_keys: new_state.current_keys.clone(),
sequence: new_state.sequence,
tip_said: event.said().clone(),
updated_at: self.clock.now(),
};
#[allow(clippy::unwrap_used)]
if let Err(e) = index.lock().unwrap().upsert_identity(&indexed) {
log::warn!("Index update failed for identity {}: {}", prefix, e);
}
}
Ok(())
}
fn get_event(&self, prefix: &Prefix, seq: u64) -> Result<Event, RegistryError> {
let repo = self.open_repo()?;
let tree = self.current_tree(&repo)?;
let navigator = TreeNavigator::new(&repo, tree);
let base_path = identity_path(prefix)?;
let event_path = paths::event_file(&base_path, seq);
let bytes = navigator
.read_blob_path(&event_path)
.map_err(|_| RegistryError::event_not_found(prefix, seq))?;
serde_json::from_slice(&bytes).map_err(Into::into)
}
fn visit_events(
&self,
prefix: &Prefix,
from_seq: u64,
visitor: &mut dyn FnMut(&Event) -> ControlFlow<()>,
) -> Result<(), RegistryError> {
let repo = self.open_repo()?;
let tree = self.current_tree(&repo)?;
let navigator = TreeNavigator::new(&repo, tree);
let base_path = identity_path(prefix)?;
let tip = self.get_tip(prefix)?;
for seq in from_seq..=tip.sequence {
let event_path = paths::event_file(&base_path, seq);
let bytes = navigator
.read_blob_path(&event_path)
.map_err(|_| RegistryError::event_not_found(prefix, seq))?;
let event: Event = serde_json::from_slice(&bytes)?;
if visitor(&event).is_break() {
break;
}
}
Ok(())
}
fn get_tip(&self, prefix: &Prefix) -> Result<TipInfo, RegistryError> {
let repo = self.open_repo()?;
let tree = self.current_tree(&repo)?;
let navigator = TreeNavigator::new(&repo, tree);
let base_path = identity_path(prefix)?;
let tip_path = paths::tip_file(&base_path);
let bytes = navigator
.read_blob_path(&tip_path)
.map_err(|_| RegistryError::identity_not_found(prefix))?;
serde_json::from_slice(&bytes).map_err(Into::into)
}
fn get_key_state(&self, prefix: &Prefix) -> Result<KeyState, RegistryError> {
let repo = self.open_repo()?;
let tree = self.current_tree(&repo)?;
let navigator = TreeNavigator::new(&repo, tree);
let base_path = identity_path(prefix)?;
let state_path = paths::state_file(&base_path);
let tip_path = paths::tip_file(&base_path);
if let Ok(state_bytes) = navigator.read_blob_path(&state_path)
&& let Ok(cached) = serde_json::from_slice::<CachedStateJson>(&state_bytes)
&& let Ok(tip_bytes) = navigator.read_blob_path(&tip_path)
&& let Ok(tip) = serde_json::from_slice::<TipInfo>(&tip_bytes)
&& cached.is_valid_for(&tip.said)
{
return Ok(cached.state);
}
let mut state: Option<KeyState> = None;
let mut replay_error: Option<RegistryError> = None;
self.visit_events(prefix, 0, &mut |event| match self
.compute_state_after_event(state.as_ref(), event)
{
Ok(new_state) => {
state = Some(new_state);
ControlFlow::Continue(())
}
Err(e) => {
replay_error = Some(RegistryError::Internal(format!(
"KEL replay failed at seq {}: {}",
event.sequence().value(),
e
)));
ControlFlow::Break(())
}
})?;
if let Some(err) = replay_error {
return Err(err);
}
state.ok_or_else(|| RegistryError::identity_not_found(prefix))
}
fn visit_identities(
&self,
visitor: &mut dyn FnMut(&str) -> ControlFlow<()>,
) -> Result<(), RegistryError> {
let repo = self.open_repo()?;
let tree = self.current_tree(&repo)?;
let navigator = TreeNavigator::new(&repo, tree);
let identities_base = paths::versioned("identities");
let identities_path = path_parts(&identities_base);
if !navigator.exists(&identities_path) {
return Ok(());
}
let captured_error: Cell<Option<RegistryError>> = Cell::new(None);
let user_break: Cell<bool> = Cell::new(false);
navigator.visit_dir(&identities_path, |s1| {
if user_break.get() {
return ControlFlow::Break(());
}
let s1_path = paths::child(&identities_base, s1);
let s1_parts = path_parts(&s1_path);
if let Err(e) = navigator.visit_dir(&s1_parts, |s2| {
if user_break.get() {
return ControlFlow::Break(());
}
let s2_path = paths::child(&s1_path, s2);
let s2_parts = path_parts(&s2_path);
if let Err(e) = navigator.visit_dir(&s2_parts, |prefix| {
let flow = visitor(prefix);
if flow.is_break() {
user_break.set(true);
}
flow
}) {
captured_error.set(Some(e));
return ControlFlow::Break(());
}
if user_break.get() {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
}) {
captured_error.set(Some(e));
return ControlFlow::Break(());
}
if user_break.get() {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
})?;
if let Some(e) = captured_error.take() {
return Err(e);
}
Ok(())
}
fn store_attestation(&self, attestation: &Attestation) -> Result<(), RegistryError> {
let repo = self.open_repo()?;
let (parent, base_tree) = self.current_commit_and_tree(&repo)?;
let navigator = TreeNavigator::new(&repo, base_tree.clone());
let sanitized_did = sanitize_did(attestation.subject.as_ref());
let device_base = device_path(&sanitized_did)?;
let att_path = paths::attestation_file(&device_base);
let is_new = !navigator.exists_path(&att_path);
if !is_new
&& let Ok(existing_bytes) = navigator.read_blob_path(&att_path)
&& let Ok(existing) = serde_json::from_slice::<Attestation>(&existing_bytes)
{
match (&attestation.timestamp, &existing.timestamp) {
(Some(new_ts), Some(old_ts)) if new_ts <= old_ts => {
return Err(RegistryError::StaleAttestation(format!(
"new attestation timestamp ({}) is not newer than existing ({}) for device {}",
new_ts, old_ts, attestation.subject
)));
}
(None, Some(_)) => {
return Err(RegistryError::StaleAttestation(format!(
"new attestation has no timestamp but existing does for device {}",
attestation.subject
)));
}
_ => {} }
}
let now = self.clock.now();
let history_id = format!("{}_{:.8}", now.format("%Y%m%dT%H%M%S%.3f"), attestation.rid);
let history_path = paths::history_entry_file(&device_base, &history_id);
let att_json = serde_json::to_vec_pretty(attestation)?;
let mut mutator = TreeMutator::new();
mutator.write_blob(&att_path, att_json.clone());
mutator.write_blob(&history_path, att_json);
let device_delta = if is_new { 1 } else { 0 };
self.update_metadata(&mut mutator, &navigator, 0, device_delta, 0)?;
let new_tree_oid = mutator.build_tree(&repo, Some(&base_tree))?;
self.create_commit(
&repo,
new_tree_oid,
Some(&parent),
&format!("Store attestation for {}", attestation.subject),
)?;
#[cfg(feature = "indexed-storage")]
if let Some(index) = &self.index {
#[allow(clippy::disallowed_methods)]
let issuer_did = IdentityDID::new_unchecked(attestation.issuer.as_str());
let indexed = auths_index::IndexedAttestation {
rid: attestation.rid.clone(),
issuer_did,
device_did: attestation.subject.clone(),
git_ref: REGISTRY_REF.to_string(),
commit_oid: None,
revoked_at: attestation.revoked_at,
expires_at: attestation.expires_at,
updated_at: attestation.timestamp.unwrap_or_else(|| self.clock.now()),
};
#[allow(clippy::unwrap_used)]
if let Err(e) = index.lock().unwrap().upsert_attestation(&indexed) {
log::warn!(
"Index update failed for attestation {}: {}",
attestation.rid,
e
);
}
}
Ok(())
}
fn load_attestation(&self, did: &DeviceDID) -> Result<Option<Attestation>, RegistryError> {
let repo = self.open_repo()?;
let tree = self.current_tree(&repo)?;
let navigator = TreeNavigator::new(&repo, tree);
let sanitized_did = sanitize_did(&did.to_string());
let device_base = device_path(&sanitized_did)?;
let att_path = paths::attestation_file(&device_base);
match navigator.read_blob_path(&att_path) {
Ok(bytes) => {
let att: Attestation = serde_json::from_slice(&bytes)?;
Ok(Some(att))
}
Err(RegistryError::NotFound { .. }) => Ok(None),
Err(e) => Err(e),
}
}
fn visit_attestation_history(
&self,
did: &DeviceDID,
visitor: &mut dyn FnMut(&Attestation) -> ControlFlow<()>,
) -> Result<(), RegistryError> {
let repo = self.open_repo()?;
let tree = self.current_tree(&repo)?;
let navigator = TreeNavigator::new(&repo, tree);
let sanitized_did = sanitize_did(&did.to_string());
let device_base = device_path(&sanitized_did)?;
let history_path = paths::history_dir(&device_base);
let history_parts = path_parts(&history_path);
if !navigator.exists(&history_parts) {
return Ok(());
}
let mut filenames = Vec::new();
navigator.visit_dir(&history_parts, |filename| {
if filename.ends_with(".json") {
filenames.push(filename.to_string());
}
ControlFlow::Continue(())
})?;
filenames.sort();
for filename in filenames {
let full_path = paths::child(&history_path, &filename);
match navigator.read_blob_path(&full_path) {
Ok(bytes) => {
let att: Attestation = serde_json::from_slice(&bytes)?;
if visitor(&att).is_break() {
break;
}
}
Err(e) => return Err(e),
}
}
Ok(())
}
fn visit_devices(
&self,
visitor: &mut dyn FnMut(&DeviceDID) -> ControlFlow<()>,
) -> Result<(), RegistryError> {
let repo = self.open_repo()?;
let tree = self.current_tree(&repo)?;
let navigator = TreeNavigator::new(&repo, tree);
let devices_base = paths::versioned("devices");
let devices_path = path_parts(&devices_base);
if !navigator.exists(&devices_path) {
return Ok(());
}
let captured_error: Cell<Option<RegistryError>> = Cell::new(None);
navigator.visit_dir(&devices_path, |s1| {
let s1_path = paths::child(&devices_base, s1);
let s1_parts = path_parts(&s1_path);
if let Err(e) = navigator.visit_dir(&s1_parts, |s2| {
let s2_path = paths::child(&s1_path, s2);
let s2_parts = path_parts(&s2_path);
if let Err(e) = navigator.visit_dir(&s2_parts, |sanitized_did| {
let did_str = unsanitize_did(sanitized_did);
let did = match DeviceDID::parse(&did_str) {
Ok(d) => d,
Err(_) => {
log::warn!("Skipping unparseable DID from tree: {}", did_str);
return ControlFlow::Continue(());
}
};
visitor(&did)
}) {
captured_error.set(Some(e));
return ControlFlow::Break(());
}
ControlFlow::Continue(())
}) {
captured_error.set(Some(e));
return ControlFlow::Break(());
}
ControlFlow::Continue(())
})?;
if let Some(e) = captured_error.take() {
return Err(e);
}
Ok(())
}
fn store_org_member(&self, org: &str, member: &Attestation) -> Result<(), RegistryError> {
let repo = self.open_repo()?;
let (parent, base_tree) = self.current_commit_and_tree(&repo)?;
let navigator = TreeNavigator::new(&repo, base_tree.clone());
let org_base = org_path(&Prefix::new_unchecked(org.to_string()))?;
let sanitized_member_did = sanitize_did(member.subject.as_ref());
let member_path = paths::member_file(&org_base, &sanitized_member_did);
let is_new = !navigator.exists_path(&member_path);
let mut mutator = TreeMutator::new();
mutator.write_blob(&member_path, serde_json::to_vec_pretty(member)?);
let member_delta = if is_new { 1 } else { 0 };
self.update_metadata(&mut mutator, &navigator, 0, 0, member_delta)?;
let new_tree_oid = mutator.build_tree(&repo, Some(&base_tree))?;
self.create_commit(
&repo,
new_tree_oid,
Some(&parent),
&format!("Store org member {} in {}", member.subject, org),
)?;
#[cfg(feature = "indexed-storage")]
if let Some(index) = &self.index {
#[allow(clippy::disallowed_methods)]
let org_prefix = auths_verifier::keri::Prefix::new_unchecked(org.to_string());
#[allow(clippy::disallowed_methods)]
let issuer_did = IdentityDID::new_unchecked(member.issuer.as_str());
#[allow(clippy::disallowed_methods)]
let member_canonical = CanonicalDid::new_unchecked(member.subject.as_str());
let indexed = auths_index::IndexedOrgMember {
org_prefix,
member_did: member_canonical,
issuer_did,
rid: member.rid.clone(),
revoked_at: member.revoked_at,
expires_at: member.expires_at,
updated_at: member.timestamp.unwrap_or_else(|| self.clock.now()),
};
#[allow(clippy::unwrap_used)]
if let Err(e) = index.lock().unwrap().upsert_org_member(&indexed) {
log::warn!(
"Index update failed for org member {} in {}: {}",
member.subject,
org,
e
);
}
}
Ok(())
}
fn visit_org_member_attestations(
&self,
org: &str,
visitor: &mut dyn FnMut(&OrgMemberEntry) -> ControlFlow<()>,
) -> Result<(), RegistryError> {
let repo = self.open_repo()?;
let tree = self.current_tree(&repo)?;
let navigator = TreeNavigator::new(&repo, tree);
let org_base = org_path(&Prefix::new_unchecked(org.to_string()))?;
let members_path = paths::members_dir(&org_base);
let members_parts = path_parts(&members_path);
if !navigator.exists(&members_parts) {
return Ok(());
}
let expected_issuer = expected_org_issuer(org);
navigator.visit_dir(&members_parts, |filename| {
let Some(sanitized_did) = filename.strip_suffix(".json") else {
return ControlFlow::Continue(());
};
let did_str = unsanitize_did(sanitized_did);
let did = match CanonicalDid::parse(&did_str) {
Ok(d) => d,
Err(_) => {
log::warn!("Skipping unparseable member DID: {}", did_str);
return ControlFlow::Continue(());
}
};
let full_path = paths::child(&members_path, filename);
let attestation = match navigator.read_blob_path(&full_path) {
Ok(bytes) => {
match serde_json::from_slice::<Attestation>(&bytes) {
Ok(att) => {
if att.subject.as_str() != did_str {
#[allow(clippy::disallowed_methods)]
let att_subject = CanonicalDid::new_unchecked(att.subject.as_str());
Err(MemberInvalidReason::SubjectMismatch {
filename_did: did.clone(),
attestation_subject: att_subject,
})
} else if att.issuer.as_str() != expected_issuer {
#[allow(clippy::disallowed_methods)]
let expected = IdentityDID::new_unchecked(expected_issuer.clone());
#[allow(clippy::disallowed_methods)]
let actual = IdentityDID::new_unchecked(att.issuer.as_str());
Err(MemberInvalidReason::IssuerMismatch {
expected_issuer: expected,
actual_issuer: actual,
})
} else {
Ok(att)
}
}
Err(e) => Err(MemberInvalidReason::JsonParseError(e.to_string())),
}
}
Err(e) => Err(MemberInvalidReason::Other(e.to_string())),
};
#[allow(clippy::disallowed_methods)]
let org_did = IdentityDID::new_unchecked(format!("did:keri:{}", org));
let entry = OrgMemberEntry {
org: org_did,
did,
filename: filename.to_string(),
attestation,
};
visitor(&entry)
})?;
Ok(())
}
fn init_if_needed(&self) -> Result<bool, RegistryError> {
GitRegistryBackend::init_if_needed(self)
}
fn metadata(&self) -> Result<RegistryMetadata, RegistryError> {
let repo = self.open_repo()?;
let tree = self.current_tree(&repo)?;
let navigator = TreeNavigator::new(&repo, tree);
let bytes = navigator.read_blob_path(&paths::versioned("metadata.json"))?;
serde_json::from_slice(&bytes).map_err(Into::into)
}
fn write_key_state(&self, prefix: &Prefix, state: &KeyState) -> Result<(), RegistryError> {
let repo = self.open_repo()?;
let (parent, base_tree) = self.current_commit_and_tree(&repo)?;
let navigator = TreeNavigator::new(&repo, base_tree.clone());
let base_path = identity_path(prefix)?;
let tip_path = paths::tip_file(&base_path);
let state_path = paths::state_file(&base_path);
let tip_bytes = navigator
.read_blob_path(&tip_path)
.map_err(|_| RegistryError::identity_not_found(prefix))?;
let tip: TipInfo = serde_json::from_slice(&tip_bytes)?;
let cached = CachedStateJson::new(state.clone(), tip.said.clone());
let state_json = serde_json::to_vec_pretty(&cached)?;
let mut mutator = TreeMutator::new();
mutator.write_blob(&state_path, state_json);
let new_tree_oid = mutator.build_tree(&repo, Some(&base_tree))?;
self.create_commit(
&repo,
new_tree_oid,
Some(&parent),
&format!("Write key state for {}", prefix),
)?;
Ok(())
}
#[cfg(feature = "indexed-storage")]
fn list_org_members_fast(
&self,
org: &str,
filter: &MemberFilter,
) -> Result<Vec<MemberView>, RegistryError> {
use auths_id::storage::registry::org_member::{MemberStatus, MemberView};
if filter.roles_any.is_some()
|| filter.capabilities_any.is_some()
|| filter.capabilities_all.is_some()
{
return self.list_org_members(org, filter);
}
let Some(index) = &self.index else {
return self.list_org_members(org, filter);
};
#[allow(clippy::unwrap_used)]
let indexed = index
.lock()
.unwrap()
.list_org_members_indexed(org)
.map_err(|e| RegistryError::Internal(format!("Index query failed: {}", e)))?;
if indexed.is_empty() {
log::debug!("No index entries for org {}, falling back to Git scan", org);
return self.list_org_members(org, filter);
}
let members: Vec<MemberView> = indexed
.into_iter()
.map(|m| {
#[allow(clippy::disallowed_methods)]
let issuer =
auths_core::storage::keychain::IdentityDID::new_unchecked(m.issuer_did);
MemberView {
did: m.member_did.clone(),
status: MemberStatus::Active,
role: None,
capabilities: vec![],
issuer,
rid: m.rid,
revoked_at: m.revoked_at,
expires_at: m.expires_at,
timestamp: None,
source_filename: String::new(),
}
})
.collect();
Ok(members)
}
}
use auths_id::error::StorageError;
use auths_id::storage::attestation::AttestationSource;
fn registry_to_storage_err(e: RegistryError) -> StorageError {
match e {
RegistryError::NotFound { entity_type, id } => {
StorageError::NotFound(format!("{} '{}'", entity_type, id))
}
RegistryError::Serialization(e) => StorageError::Serialization(e),
_ => StorageError::InvalidData(e.to_string()),
}
}
impl AttestationSource for GitRegistryBackend {
fn load_attestations_for_device(
&self,
device_did: &DeviceDID,
) -> Result<Vec<Attestation>, StorageError> {
match self.load_attestation(device_did) {
Ok(Some(att)) => Ok(vec![att]),
Ok(None) => Ok(vec![]),
Err(e) => Err(registry_to_storage_err(e)),
}
}
fn load_all_attestations(&self) -> Result<Vec<Attestation>, StorageError> {
self.load_all_attestations_paginated(usize::MAX, 0)
}
fn load_all_attestations_paginated(
&self,
limit: usize,
offset: usize,
) -> Result<Vec<Attestation>, StorageError> {
let mut attestations = Vec::new();
let mut error: Option<StorageError> = None;
let mut skipped: usize = 0;
let mut collected: usize = 0;
let visit_result = self.visit_devices(&mut |did| {
if skipped < offset {
skipped += 1;
return ControlFlow::Continue(());
}
if collected >= limit {
return ControlFlow::Break(());
}
collected += 1;
match self.load_attestation(did) {
Ok(Some(att)) => attestations.push(att),
Ok(None) => {}
Err(e) => {
error = Some(registry_to_storage_err(e));
return ControlFlow::Break(());
}
}
ControlFlow::Continue(())
});
if let Err(e) = visit_result {
return Err(registry_to_storage_err(e));
}
if let Some(e) = error {
return Err(e);
}
Ok(attestations)
}
fn discover_device_dids(&self) -> Result<Vec<DeviceDID>, StorageError> {
let mut dids = Vec::new();
self.visit_devices(&mut |did| {
dids.push(did.clone());
ControlFlow::Continue(())
})
.map_err(registry_to_storage_err)?;
Ok(dids)
}
}
use auths_id::attestation::AttestationSink;
impl AttestationSink for GitRegistryBackend {
fn export(&self, attestation: &VerifiedAttestation) -> Result<(), StorageError> {
self.store_attestation(attestation.inner())
.map_err(registry_to_storage_err)
}
}
#[cfg(feature = "indexed-storage")]
#[allow(dead_code)] pub fn rebuild_identities_from_registry(
index: &auths_index::AttestationIndex,
backend: &GitRegistryBackend,
) -> auths_index::Result<auths_index::RebuildStats> {
use auths_index::{IndexedIdentity, RebuildStats};
let mut stats = RebuildStats::default();
let result = backend.visit_identities(&mut |prefix| {
stats.refs_scanned += 1;
let prefix_typed = Prefix::new_unchecked(prefix.to_string());
match backend.get_key_state(&prefix_typed) {
Ok(state) => {
let indexed = IndexedIdentity {
prefix: state.prefix.clone(),
current_keys: state.current_keys.clone(),
sequence: state.sequence,
tip_said: state.last_event_said.clone(),
updated_at: backend.clock.now(),
};
match index.upsert_identity(&indexed) {
Ok(()) => stats.attestations_indexed += 1,
Err(e) => {
log::warn!("Failed to index identity {}: {}", prefix, e);
stats.errors += 1;
}
}
}
Err(e) => {
log::warn!("Failed to get key state for {}: {}", prefix, e);
stats.errors += 1;
}
}
ControlFlow::Continue(())
});
if let Err(e) = result {
log::warn!("visit_identities failed during rebuild: {}", e);
stats.errors += 1;
}
Ok(stats)
}
#[cfg(feature = "indexed-storage")]
#[allow(dead_code)] pub fn rebuild_org_members_from_registry(
index: &auths_index::AttestationIndex,
backend: &GitRegistryBackend,
) -> auths_index::Result<auths_index::RebuildStats> {
use auths_index::{IndexedOrgMember, RebuildStats};
let mut stats = RebuildStats::default();
let mut org_prefixes: Vec<String> = Vec::new();
let _ = backend.visit_orgs(|org| {
org_prefixes.push(org.to_string());
ControlFlow::Continue(())
});
for org_prefix in &org_prefixes {
match backend.visit_org_member_attestations(org_prefix, &mut |entry| {
stats.refs_scanned += 1;
if let Ok(att) = &entry.attestation {
#[allow(clippy::disallowed_methods)]
let prefix = auths_verifier::keri::Prefix::new_unchecked(org_prefix.clone());
#[allow(clippy::disallowed_methods)]
let issuer_did = IdentityDID::new_unchecked(att.issuer.as_str());
let indexed = IndexedOrgMember {
org_prefix: prefix,
member_did: entry.did.clone(),
issuer_did,
rid: att.rid.clone(),
revoked_at: att.revoked_at,
expires_at: att.expires_at,
updated_at: att.timestamp.unwrap_or_else(|| backend.clock.now()),
};
match index.upsert_org_member(&indexed) {
Ok(()) => stats.attestations_indexed += 1,
Err(e) => {
log::warn!(
"Failed to index org member {} in {}: {}",
entry.did,
org_prefix,
e
);
stats.errors += 1;
}
}
}
ControlFlow::Continue(())
}) {
Ok(()) => {}
Err(e) => {
log::debug!(
"visit_org_member_attestations for {} returned error (likely not an org): {}",
org_prefix,
e
);
}
}
}
Ok(stats)
}
use async_trait::async_trait;
use auths_id::storage::driver::{StorageDriver, StorageError as DriverStorageError};
impl GitRegistryBackend {
fn get_blob_sync(&self, path: &str) -> Result<Vec<u8>, DriverStorageError> {
let repo = self.open_repo().map_err(DriverStorageError::io)?;
let tree = self.current_tree(&repo).map_err(|e| match e {
RegistryError::NotFound { .. } => DriverStorageError::not_found(path),
other => DriverStorageError::io(other),
})?;
let navigator = TreeNavigator::new(&repo, tree);
navigator.read_blob_path(path).map_err(|e| match e {
RegistryError::NotFound { .. } => DriverStorageError::not_found(path),
other => DriverStorageError::io(other),
})
}
fn put_blob_sync(&self, path: &str, data: &[u8]) -> Result<(), DriverStorageError> {
let repo = self.open_repo().map_err(DriverStorageError::io)?;
let (parent, base_tree) = match self.current_commit_and_tree(&repo) {
Ok((commit, tree)) => (Some(commit), Some(tree)),
Err(RegistryError::NotFound { .. }) => (None, None),
Err(e) => return Err(DriverStorageError::io(e)),
};
let mut mutator = TreeMutator::new();
mutator.write_blob(path, data.to_vec());
let tree_oid = mutator
.build_tree(&repo, base_tree.as_ref())
.map_err(DriverStorageError::io)?;
self.create_commit(&repo, tree_oid, parent.as_ref(), &format!("put {}", path))
.map_err(|e| match e {
RegistryError::ConcurrentModification(msg) => {
DriverStorageError::cas_conflict(None, Some(msg.into_bytes()))
}
other => DriverStorageError::io(other),
})?;
Ok(())
}
fn delete_sync(&self, path: &str) -> Result<(), DriverStorageError> {
let repo = self.open_repo().map_err(DriverStorageError::io)?;
let (parent, base_tree) = match self.current_commit_and_tree(&repo) {
Ok((commit, tree)) => (commit, tree),
Err(RegistryError::NotFound { .. }) => return Ok(()), Err(e) => return Err(DriverStorageError::io(e)),
};
let navigator = TreeNavigator::new(&repo, base_tree.clone());
if !navigator.exists_path(path) {
return Ok(()); }
let mut mutator = TreeMutator::new();
mutator.delete(path);
let tree_oid = mutator
.build_tree(&repo, Some(&base_tree))
.map_err(DriverStorageError::io)?;
self.create_commit(&repo, tree_oid, Some(&parent), &format!("delete {}", path))
.map_err(DriverStorageError::io)?;
Ok(())
}
fn exists_sync(&self, path: &str) -> Result<bool, DriverStorageError> {
let repo = self.open_repo().map_err(DriverStorageError::io)?;
let tree = match self.current_tree(&repo) {
Ok(t) => t,
Err(RegistryError::NotFound { .. }) => return Ok(false),
Err(e) => return Err(DriverStorageError::io(e)),
};
let navigator = TreeNavigator::new(&repo, tree);
Ok(navigator.exists_path(path))
}
fn list_prefix_sync(&self, prefix: &str) -> Result<Vec<String>, DriverStorageError> {
let repo = self.open_repo().map_err(DriverStorageError::io)?;
let tree = match self.current_tree(&repo) {
Ok(t) => t,
Err(RegistryError::NotFound { .. }) => return Ok(vec![]),
Err(e) => return Err(DriverStorageError::io(e)),
};
let navigator = TreeNavigator::new(&repo, tree);
let mut paths = Vec::new();
self.collect_paths_recursive(&navigator, prefix, &mut paths)?;
Ok(paths)
}
fn collect_paths_recursive(
&self,
navigator: &TreeNavigator,
prefix: &str,
paths: &mut Vec<String>,
) -> Result<(), DriverStorageError> {
let parts = path_parts(prefix);
let result = navigator.visit_dir(&parts, |name| {
let full_path = if prefix.is_empty() {
name.to_string()
} else {
format!("{}/{}", prefix, name)
};
let child_parts = path_parts(&full_path);
if navigator.exists(&child_parts) {
if navigator.read_blob(&child_parts).is_ok() {
paths.push(full_path);
} else {
let _ = self.collect_paths_recursive(navigator, &full_path, paths);
}
}
ControlFlow::Continue(())
});
match result {
Ok(()) => Ok(()),
Err(RegistryError::NotFound { .. }) => Ok(()), Err(e) => Err(DriverStorageError::io(e)),
}
}
}
#[async_trait]
impl StorageDriver for GitRegistryBackend {
async fn get_blob(&self, path: &str) -> Result<Vec<u8>, DriverStorageError> {
let this = self.clone();
let path = path.to_string();
tokio::task::spawn_blocking(move || this.get_blob_sync(&path))
.await
.map_err(DriverStorageError::io)?
}
async fn put_blob(&self, path: &str, data: &[u8]) -> Result<(), DriverStorageError> {
let this = self.clone();
let path = path.to_string();
let data = data.to_vec();
tokio::task::spawn_blocking(move || this.put_blob_sync(&path, &data))
.await
.map_err(DriverStorageError::io)?
}
async fn cas_update(
&self,
ref_key: &str,
expected: Option<&[u8]>,
new: &[u8],
) -> Result<(), DriverStorageError> {
let current = match self.get_blob(ref_key).await {
Ok(data) => Some(data),
Err(DriverStorageError::NotFound(_)) => None,
Err(e) => return Err(e),
};
let expected_vec = expected.map(|b| b.to_vec());
if current != expected_vec {
return Err(DriverStorageError::cas_conflict(expected_vec, current));
}
self.put_blob(ref_key, new).await
}
async fn list_prefix(&self, prefix: &str) -> Result<Vec<String>, DriverStorageError> {
let this = self.clone();
let prefix = prefix.to_string();
tokio::task::spawn_blocking(move || this.list_prefix_sync(&prefix))
.await
.map_err(DriverStorageError::io)?
}
async fn exists(&self, path: &str) -> Result<bool, DriverStorageError> {
let this = self.clone();
let path = path.to_string();
tokio::task::spawn_blocking(move || this.exists_sync(&path))
.await
.map_err(DriverStorageError::io)?
}
async fn delete(&self, path: &str) -> Result<(), DriverStorageError> {
let this = self.clone();
let path = path.to_string();
tokio::task::spawn_blocking(move || this.delete_sync(&path))
.await
.map_err(DriverStorageError::io)?
}
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
use auths_core::crypto::said::compute_next_commitment;
use auths_id::keri::KERI_VERSION;
use auths_id::keri::event::{IcpEvent, IxnEvent, KeriSequence, RotEvent};
use auths_id::keri::seal::Seal;
use auths_id::keri::types::{Prefix, Said};
use auths_id::keri::validate::{compute_event_said, finalize_icp_event, serialize_for_signing};
use auths_verifier::AttestationBuilder;
use auths_verifier::core::{Ed25519PublicKey, Role};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{DateTime, Utc};
use ring::rand::SystemRandom;
use ring::signature::{Ed25519KeyPair, KeyPair};
use tempfile::TempDir;
fn setup_test_repo() -> (TempDir, GitRegistryBackend) {
let dir = TempDir::new().unwrap();
let backend =
GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(dir.path()));
backend.init_if_needed().unwrap();
(dir, backend)
}
fn create_signed_icp() -> (Event, Prefix, Ed25519KeyPair, Ed25519KeyPair) {
let rng = SystemRandom::new();
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(keypair.public_key().as_ref()));
let next_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let next_keypair = Ed25519KeyPair::from_pkcs8(next_pkcs8.as_ref()).unwrap();
let next_commitment = compute_next_commitment(next_keypair.public_key().as_ref());
let icp = IcpEvent {
v: KERI_VERSION.to_string(),
d: Said::default(),
i: Prefix::default(),
s: KeriSequence::new(0),
kt: "1".to_string(),
k: vec![key_encoded],
nt: "1".to_string(),
n: vec![next_commitment],
bt: "0".to_string(),
b: vec![],
a: vec![],
x: String::new(),
};
let mut finalized = finalize_icp_event(icp).unwrap();
let canonical = serialize_for_signing(&Event::Icp(finalized.clone())).unwrap();
let sig = keypair.sign(&canonical);
finalized.x = URL_SAFE_NO_PAD.encode(sig.as_ref());
let prefix = finalized.i.clone();
(Event::Icp(finalized), prefix, keypair, next_keypair)
}
fn create_signed_rot(
prefix: &Prefix,
seq: u64,
prev_said: &str,
new_keypair: &Ed25519KeyPair,
) -> (Event, Ed25519KeyPair) {
let rng = SystemRandom::new();
let new_key_encoded = format!(
"D{}",
URL_SAFE_NO_PAD.encode(new_keypair.public_key().as_ref())
);
let nn_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let nn_keypair = Ed25519KeyPair::from_pkcs8(nn_pkcs8.as_ref()).unwrap();
let nn_commitment = compute_next_commitment(nn_keypair.public_key().as_ref());
let mut rot = RotEvent {
v: KERI_VERSION.to_string(),
d: Said::default(),
i: prefix.clone(),
s: KeriSequence::new(seq),
p: Said::new_unchecked(prev_said.to_string()),
kt: "1".to_string(),
k: vec![new_key_encoded],
nt: "1".to_string(),
n: vec![nn_commitment],
bt: "0".to_string(),
b: vec![],
a: vec![],
x: String::new(),
};
let event = Event::Rot(rot.clone());
rot.d = compute_event_said(&event).unwrap();
let canonical = serialize_for_signing(&Event::Rot(rot.clone())).unwrap();
let sig = new_keypair.sign(&canonical);
rot.x = URL_SAFE_NO_PAD.encode(sig.as_ref());
(Event::Rot(rot), nn_keypair)
}
fn create_signed_ixn(
prefix: &Prefix,
seq: u64,
prev_said: &str,
keypair: &Ed25519KeyPair,
) -> Event {
let mut ixn = IxnEvent {
v: KERI_VERSION.to_string(),
d: Said::default(),
i: prefix.clone(),
s: KeriSequence::new(seq),
p: Said::new_unchecked(prev_said.to_string()),
a: vec![Seal::device_attestation("ETest")],
x: String::new(),
};
let event = Event::Ixn(ixn.clone());
ixn.d = compute_event_said(&event).unwrap();
let canonical = serialize_for_signing(&Event::Ixn(ixn.clone())).unwrap();
let sig = keypair.sign(&canonical);
ixn.x = URL_SAFE_NO_PAD.encode(sig.as_ref());
Event::Ixn(ixn)
}
fn create_unsigned_icp(key: &str, next: &str) -> (Event, Prefix) {
let icp = IcpEvent {
v: KERI_VERSION.to_string(),
d: Said::default(),
i: Prefix::default(),
s: KeriSequence::new(0),
kt: "1".to_string(),
k: vec![key.to_string()],
nt: "1".to_string(),
n: vec![next.to_string()],
bt: "0".to_string(),
b: vec![],
a: vec![],
x: String::new(),
};
let finalized = finalize_icp_event(icp).unwrap();
let prefix = finalized.i.clone();
(Event::Icp(finalized), prefix)
}
#[test]
fn init_creates_registry() {
let (_dir, backend) = setup_test_repo();
let meta = backend.metadata().unwrap();
assert_eq!(meta.identity_count, 0);
assert_eq!(meta.device_count, 0);
}
#[test]
fn append_and_get_event() {
let (_dir, backend) = setup_test_repo();
let (event, prefix, _keypair, _next_keypair) = create_signed_icp();
backend.append_event(&prefix, &event).unwrap();
let retrieved = backend.get_event(&prefix, 0).unwrap();
assert_eq!(retrieved.prefix(), &prefix);
assert_eq!(retrieved.sequence().value(), 0);
}
#[test]
fn append_multiple_events() {
let (_dir, backend) = setup_test_repo();
let (icp, prefix, _keypair, next_keypair) = create_signed_icp();
let icp_said = icp.said().to_string();
backend.append_event(&prefix, &icp).unwrap();
let (rot, _nn_keypair) = create_signed_rot(&prefix, 1, &icp_said, &next_keypair);
let rot_said = rot.said().to_string();
backend.append_event(&prefix, &rot).unwrap();
let ixn = create_signed_ixn(&prefix, 2, &rot_said, &next_keypair);
backend.append_event(&prefix, &ixn).unwrap();
let tip = backend.get_tip(&prefix).unwrap();
assert_eq!(tip.sequence, 2);
}
#[test]
fn append_rejects_duplicate() {
let (_dir, backend) = setup_test_repo();
let (event, prefix, _keypair, _next_keypair) = create_signed_icp();
backend.append_event(&prefix, &event).unwrap();
let result = backend.append_event(&prefix, &event);
assert!(matches!(result, Err(RegistryError::EventExists { .. })));
}
#[test]
fn append_rejects_sequence_gap() {
let (_dir, backend) = setup_test_repo();
let (_, prefix) = create_unsigned_icp("DKey1", "ENext1");
let rng = SystemRandom::new();
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let kp = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let key_enc = format!("D{}", URL_SAFE_NO_PAD.encode(kp.public_key().as_ref()));
let next_commit = compute_next_commitment(kp.public_key().as_ref());
let mut rot = RotEvent {
v: KERI_VERSION.to_string(),
d: Said::default(),
i: prefix.clone(),
s: KeriSequence::new(1),
p: Said::new_unchecked("EPrev".to_string()),
kt: "1".to_string(),
k: vec![key_enc],
nt: "1".to_string(),
n: vec![next_commit],
bt: "0".to_string(),
b: vec![],
a: vec![],
x: String::new(),
};
let event = Event::Rot(rot.clone());
rot.d = compute_event_said(&event).unwrap();
let result = backend.append_event(&prefix, &Event::Rot(rot));
assert!(matches!(
result,
Err(RegistryError::SequenceGap {
expected: 0,
got: 1,
..
})
));
}
#[test]
fn append_rejects_prefix_mismatch() {
let (_dir, backend) = setup_test_repo();
let (icp, _prefix, _keypair, _next_keypair) = create_signed_icp();
let wrong_prefix = Prefix::new_unchecked("EWrongPrefix1234".to_string());
let result = backend.append_event(&wrong_prefix, &icp);
assert!(matches!(result, Err(RegistryError::InvalidPrefix { .. })));
}
#[test]
fn append_rejects_non_icp_at_seq_zero() {
let (_dir, backend) = setup_test_repo();
let (_, prefix) = create_unsigned_icp("DKey1", "ENext1");
let mut ixn = IxnEvent {
v: KERI_VERSION.to_string(),
d: Said::default(),
i: prefix.clone(),
s: KeriSequence::new(0),
p: Said::new_unchecked("EPrev".to_string()),
a: vec![Seal::device_attestation("ETest")],
x: String::new(),
};
let event = Event::Ixn(ixn.clone());
ixn.d = compute_event_said(&event).unwrap();
let ixn_event = Event::Ixn(ixn);
let result = backend.append_event(&prefix, &ixn_event);
assert!(matches!(result, Err(RegistryError::Internal(msg)) if msg.contains("inception")));
}
#[test]
fn append_rejects_broken_chain() {
let (_dir, backend) = setup_test_repo();
let (icp, prefix, keypair, _next_keypair) = create_signed_icp();
backend.append_event(&prefix, &icp).unwrap();
let ixn = create_signed_ixn(&prefix, 1, "EWrongPreviousSaid", &keypair);
let result = backend.append_event(&prefix, &ixn);
assert!(matches!(result, Err(RegistryError::SaidMismatch { .. })));
}
#[test]
fn append_rejects_invalid_said() {
let (_dir, backend) = setup_test_repo();
let tampered_said = "ETamperedSaid1234567890";
let icp = IcpEvent {
v: KERI_VERSION.to_string(),
d: Said::new_unchecked(tampered_said.to_string()),
i: Prefix::new_unchecked(tampered_said.to_string()),
s: KeriSequence::new(0),
kt: "1".to_string(),
k: vec!["DKey1".to_string()],
nt: "1".to_string(),
n: vec!["ENext1".to_string()],
bt: "0".to_string(),
b: vec![],
a: vec![],
x: String::new(),
};
let event = Event::Icp(icp);
let tampered_prefix = Prefix::new_unchecked(tampered_said.to_string());
let result = backend.append_event(&tampered_prefix, &event);
assert!(matches!(result, Err(RegistryError::SaidMismatch { .. })));
}
#[test]
fn get_key_state() {
let (_dir, backend) = setup_test_repo();
let (icp, prefix, _keypair, _next_keypair) = create_signed_icp();
backend.append_event(&prefix, &icp).unwrap();
let state = backend.get_key_state(&prefix).unwrap();
assert_eq!(state.prefix, prefix);
assert_eq!(state.sequence, 0);
}
#[test]
fn get_key_state_after_rotation() {
let (_dir, backend) = setup_test_repo();
let (icp, prefix, _keypair, next_keypair) = create_signed_icp();
let icp_said = icp.said().to_string();
backend.append_event(&prefix, &icp).unwrap();
let (rot, _nn_keypair) = create_signed_rot(&prefix, 1, &icp_said, &next_keypair);
backend.append_event(&prefix, &rot).unwrap();
let state = backend.get_key_state(&prefix).unwrap();
assert_eq!(state.sequence, 1);
}
#[test]
fn visit_identities() {
let (_dir, backend) = setup_test_repo();
let (icp1, prefix1, _, _) = create_signed_icp();
let (icp2, prefix2, _, _) = create_signed_icp();
backend.append_event(&prefix1, &icp1).unwrap();
backend.append_event(&prefix2, &icp2).unwrap();
let mut prefixes = Vec::new();
backend
.visit_identities(&mut |prefix| {
prefixes.push(prefix.to_string());
ControlFlow::Continue(())
})
.unwrap();
assert_eq!(prefixes.len(), 2);
assert!(prefixes.contains(&prefix1.to_string()));
assert!(prefixes.contains(&prefix2.to_string()));
}
#[test]
fn store_and_load_attestation() {
let (_dir, backend) = setup_test_repo();
let did = DeviceDID::new_unchecked("did:key:z6MkTest123");
let attestation = AttestationBuilder::default()
.rid("test-rid")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.build();
backend.store_attestation(&attestation).unwrap();
let loaded = backend.load_attestation(&did).unwrap();
assert!(loaded.is_some());
let loaded = loaded.unwrap();
assert_eq!(loaded.rid, "test-rid");
assert_eq!(loaded.issuer, "did:keri:EIssuer");
}
#[test]
fn load_nonexistent_attestation() {
let (_dir, backend) = setup_test_repo();
let did = DeviceDID::new_unchecked("did:key:z6MkNonexistent");
let result = backend.load_attestation(&did).unwrap();
assert!(result.is_none());
}
#[test]
fn store_attestation_overwrites_existing() {
let (_dir, backend) = setup_test_repo();
let did = DeviceDID::new_unchecked("did:key:z6MkTestDevice");
let original = AttestationBuilder::default()
.rid("original")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.note(Some("original note".to_string()))
.build();
backend.store_attestation(&original).unwrap();
let loaded = backend.load_attestation(&did).unwrap().unwrap();
assert_eq!(loaded.rid, "original");
assert_eq!(loaded.note, Some("original note".to_string()));
let updated = AttestationBuilder::default()
.rid("updated")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.note(Some("updated note".to_string()))
.build();
backend.store_attestation(&updated).unwrap();
let loaded = backend.load_attestation(&did).unwrap().unwrap();
assert_eq!(loaded.rid, "updated");
assert_eq!(loaded.note, Some("updated note".to_string()));
}
#[test]
fn replay_same_attestation_rejected() {
let (_dir, backend) = setup_test_repo();
let did = DeviceDID::new_unchecked("did:key:z6MkReplay1");
let att = AttestationBuilder::default()
.rid("same-rid")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.timestamp(Some(Utc::now()))
.build();
backend.store_attestation(&att).unwrap();
let result = backend.store_attestation(&att);
assert!(result.is_err());
assert!(
matches!(result, Err(RegistryError::StaleAttestation(_))),
"expected StaleAttestation, got {:?}",
result
);
}
#[test]
fn replay_older_attestation_rejected() {
let (_dir, backend) = setup_test_repo();
let did = DeviceDID::new_unchecked("did:key:z6MkReplay2");
let newer = AttestationBuilder::default()
.rid("rid-newer")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.timestamp(Some(Utc::now()))
.build();
let older = AttestationBuilder::default()
.rid("rid-older")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.timestamp(Some(Utc::now() - chrono::Duration::hours(1)))
.build();
backend.store_attestation(&newer).unwrap();
let result = backend.store_attestation(&older);
assert!(matches!(result, Err(RegistryError::StaleAttestation(_))));
}
#[test]
fn newer_attestation_accepted() {
let (_dir, backend) = setup_test_repo();
let did = DeviceDID::new_unchecked("did:key:z6MkReplay3");
let older = AttestationBuilder::default()
.rid("rid-old")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.timestamp(Some(Utc::now() - chrono::Duration::hours(1)))
.build();
let newer = AttestationBuilder::default()
.rid("rid-new")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.timestamp(Some(Utc::now()))
.build();
backend.store_attestation(&older).unwrap();
backend.store_attestation(&newer).unwrap();
let loaded = backend.load_attestation(&did).unwrap().unwrap();
assert_eq!(loaded.rid, "rid-new");
}
#[test]
fn replay_revoked_attestation_rejected() {
let (_dir, backend) = setup_test_repo();
let did = DeviceDID::new_unchecked("did:key:z6MkReplay4");
let revoked = AttestationBuilder::default()
.rid("rid-revoked")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.revoked_at(Some(Utc::now()))
.timestamp(Some(Utc::now()))
.build();
let unrevoked_old = AttestationBuilder::default()
.rid("rid-unrevoked")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.timestamp(Some(Utc::now() - chrono::Duration::hours(1)))
.build();
backend.store_attestation(&revoked).unwrap();
let result = backend.store_attestation(&unrevoked_old);
assert!(matches!(result, Err(RegistryError::StaleAttestation(_))));
}
#[test]
fn first_attestation_always_accepted() {
let (_dir, backend) = setup_test_repo();
let did = DeviceDID::new_unchecked("did:key:z6MkReplay5");
let att = AttestationBuilder::default()
.rid("first-ever")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.timestamp(Some(Utc::now()))
.build();
assert!(backend.store_attestation(&att).is_ok());
}
#[test]
fn attestation_without_timestamp_rejected_when_existing_has_timestamp() {
let (_dir, backend) = setup_test_repo();
let did = DeviceDID::new_unchecked("did:key:z6MkReplay6");
let with_ts = AttestationBuilder::default()
.rid("rid-with-ts")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.timestamp(Some(Utc::now()))
.build();
let without_ts = AttestationBuilder::default()
.rid("rid-no-ts")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.build();
backend.store_attestation(&with_ts).unwrap();
let result = backend.store_attestation(&without_ts);
assert!(matches!(result, Err(RegistryError::StaleAttestation(_))));
}
#[test]
fn visit_devices() {
let (_dir, backend) = setup_test_repo();
let did1 = DeviceDID::new_unchecked("did:key:z6MkTest1");
let did2 = DeviceDID::new_unchecked("did:key:z6MkTest2");
let att1 = AttestationBuilder::default()
.rid("rid1")
.issuer("did:keri:EIssuer")
.subject(&did1.to_string())
.build();
let att2 = AttestationBuilder::default()
.rid("rid2")
.issuer("did:keri:EIssuer")
.subject(&did2.to_string())
.device_public_key(Ed25519PublicKey::from_bytes([1u8; 32]))
.build();
backend.store_attestation(&att1).unwrap();
backend.store_attestation(&att2).unwrap();
let mut devices = Vec::new();
backend
.visit_devices(&mut |did| {
devices.push(did.to_string());
ControlFlow::Continue(())
})
.unwrap();
assert_eq!(devices.len(), 2);
}
#[test]
fn metadata_counts_increase() {
let (_dir, backend) = setup_test_repo();
let (icp, prefix, _keypair, _next_keypair) = create_signed_icp();
backend.append_event(&prefix, &icp).unwrap();
let meta = backend.metadata().unwrap();
assert_eq!(meta.identity_count, 1);
assert_eq!(meta.device_count, 0);
let did = DeviceDID::new_unchecked("did:key:z6MkTest");
let att = AttestationBuilder::default()
.rid("rid")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.build();
backend.store_attestation(&att).unwrap();
let meta = backend.metadata().unwrap();
assert_eq!(meta.identity_count, 1);
assert_eq!(meta.device_count, 1);
}
#[test]
fn store_and_visit_org_member_attestations() {
let (_dir, backend) = setup_test_repo();
let org = "EOrg1234567890";
let member_did = DeviceDID::new_unchecked("did:key:z6MkMember1");
let member_att = AttestationBuilder::default()
.rid("org-member")
.issuer(&format!("did:keri:{}", org))
.subject(&member_did.to_string())
.role(Some(Role::Member))
.build();
backend.store_org_member(org, &member_att).unwrap();
let mut entries = Vec::new();
backend
.visit_org_member_attestations(org, &mut |entry| {
entries.push((entry.did.to_string(), entry.attestation.is_ok()));
ControlFlow::Continue(())
})
.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].0, member_did.to_string());
assert!(entries[0].1); }
#[test]
fn store_and_visit_org_member_with_keri_did() {
let (_dir, backend) = setup_test_repo();
let org = "EOrg1234567890";
let keri_did = "did:keri:EH-Bgtw9tm61YHxUWOw37UweX_7LNJC89t0Pl7ateDdM";
let member_att = AttestationBuilder::default()
.rid("org-keri-member")
.issuer(&format!("did:keri:{}", org))
.subject(keri_did)
.role(Some(Role::Member))
.build();
backend.store_org_member(org, &member_att).unwrap();
let mut found_did: Option<String> = None;
backend
.visit_org_member_attestations(org, &mut |entry| {
found_did = Some(entry.did.as_str().to_string());
ControlFlow::Continue(())
})
.unwrap();
assert_eq!(
found_did.as_deref(),
Some(keri_did),
"did:keri: member must be findable after store"
);
}
#[test]
fn store_and_visit_org_member_keri_did_with_underscore() {
let (_dir, backend) = setup_test_repo();
let org = "EOrg1234567890";
let did_with_underscore = "did:keri:EH-Bgtw9tm61YHxUWOw37UweX_7LNJC89t0Pl7ateDdM";
let member_att = AttestationBuilder::default()
.rid("org-underscore-member")
.issuer(&format!("did:keri:{}", org))
.subject(did_with_underscore)
.role(Some(Role::Member))
.build();
backend.store_org_member(org, &member_att).unwrap();
let mut found_did: Option<String> = None;
let mut attestation_error: Option<String> = None;
backend
.visit_org_member_attestations(org, &mut |entry| {
found_did = Some(entry.did.as_str().to_string());
if let Err(reason) = &entry.attestation {
attestation_error = Some(format!("{:?}", reason));
}
ControlFlow::Continue(())
})
.unwrap();
assert_eq!(
found_did.as_deref(),
Some(did_with_underscore),
"DID with underscore in KERI prefix must survive sanitize/unsanitize round-trip"
);
assert!(
attestation_error.is_none(),
"attestation should be valid but got error: {:?}",
attestation_error
);
}
#[test]
fn visit_org_member_attestations_yields_invalid_on_bad_json() {
let (_dir, backend) = setup_test_repo();
let org = "EOrg1234567890";
let repo = backend.open_repo().unwrap();
let base_tree = backend.current_tree(&repo).unwrap();
let org_base = org_path(&Prefix::new_unchecked(org.to_string())).unwrap();
let bad_path = paths::member_file(&org_base, "did_key_z6MkBadJson");
let mut mutator = TreeMutator::new();
mutator.write_blob(&bad_path, b"{ not valid json }".to_vec());
let new_tree_oid = mutator.build_tree(&repo, Some(&base_tree)).unwrap();
let parent = repo
.find_reference(REGISTRY_REF)
.unwrap()
.peel_to_commit()
.unwrap();
backend
.create_commit(&repo, new_tree_oid, Some(&parent), "Add invalid JSON")
.unwrap();
let mut found_invalid = false;
backend
.visit_org_member_attestations(org, &mut |entry| {
if entry.did.as_str() == "did:key:z6MkBadJson"
&& let Err(MemberInvalidReason::JsonParseError(_)) = &entry.attestation
{
found_invalid = true;
}
ControlFlow::Continue(())
})
.unwrap();
assert!(found_invalid, "Expected to find invalid JSON entry");
}
#[test]
fn visit_org_member_attestations_detects_subject_mismatch() {
let (_dir, backend) = setup_test_repo();
let org = "EOrg1234567890";
let repo = backend.open_repo().unwrap();
let base_tree = backend.current_tree(&repo).unwrap();
let correct_did = DeviceDID::new_unchecked("did:key:z6MkCorrect");
let att = AttestationBuilder::default()
.rid("mismatch-test")
.issuer(&format!("did:keri:{}", org))
.subject(&correct_did.to_string())
.build();
let org_base = org_path(&Prefix::new_unchecked(org.to_string())).unwrap();
let wrong_path = paths::member_file(&org_base, "did_key_z6MkWRONG");
let mut mutator = TreeMutator::new();
mutator.write_blob(&wrong_path, serde_json::to_vec(&att).unwrap());
let new_tree_oid = mutator.build_tree(&repo, Some(&base_tree)).unwrap();
let parent = repo
.find_reference(REGISTRY_REF)
.unwrap()
.peel_to_commit()
.unwrap();
backend
.create_commit(&repo, new_tree_oid, Some(&parent), "Add mismatched member")
.unwrap();
let mut found_mismatch = false;
backend
.visit_org_member_attestations(org, &mut |entry| {
if entry.did.as_str() == "did:key:z6MkWRONG"
&& let Err(MemberInvalidReason::SubjectMismatch {
filename_did,
attestation_subject,
}) = &entry.attestation
{
assert_eq!(filename_did.to_string(), "did:key:z6MkWRONG");
assert_eq!(attestation_subject.to_string(), "did:key:z6MkCorrect");
found_mismatch = true;
}
ControlFlow::Continue(())
})
.unwrap();
assert!(found_mismatch, "Expected to find subject mismatch entry");
}
#[test]
fn visit_org_member_attestations_detects_issuer_mismatch() {
let (_dir, backend) = setup_test_repo();
let org = "EOrg1234567890";
let member_did = DeviceDID::new_unchecked("did:key:z6MkWrongIssuer");
let att = AttestationBuilder::default()
.rid("issuer-mismatch-test")
.issuer("did:keri:EDifferentOrg") .subject(&member_did.to_string())
.build();
backend.store_org_member(org, &att).unwrap();
let mut found_mismatch = false;
backend
.visit_org_member_attestations(org, &mut |entry| {
if entry.did.to_string() == member_did.to_string()
&& let Err(MemberInvalidReason::IssuerMismatch {
expected_issuer,
actual_issuer,
}) = &entry.attestation
{
assert_eq!(expected_issuer.to_string(), format!("did:keri:{}", org));
assert_eq!(actual_issuer.to_string(), "did:keri:EDifferentOrg");
found_mismatch = true;
}
ControlFlow::Continue(())
})
.unwrap();
assert!(found_mismatch, "Expected to find issuer mismatch entry");
}
#[test]
fn list_org_members_returns_all_valid_members() {
use auths_id::storage::registry::org_member::MemberFilter;
let (_dir, backend) = setup_test_repo();
let org = "EOrg1234567890";
let active_did = DeviceDID::new_unchecked("did:key:z6MkActive1");
let active_att = AttestationBuilder::default()
.rid("active")
.issuer(&format!("did:keri:{}", org))
.subject(&active_did.to_string())
.role(Some(Role::Member))
.build();
backend.store_org_member(org, &active_att).unwrap();
let revoked_did = DeviceDID::new_unchecked("did:key:z6MkRevoked");
let revoked_att = AttestationBuilder::default()
.rid("revoked")
.issuer(&format!("did:keri:{}", org))
.subject(&revoked_did.to_string())
.revoked_at(Some(Utc::now()))
.role(Some(Role::Member))
.build();
backend.store_org_member(org, &revoked_att).unwrap();
let filter = MemberFilter::default();
let members = backend.list_org_members(org, &filter).unwrap();
assert_eq!(members.len(), 2);
assert_eq!(members[0].did.to_string(), active_did.to_string());
assert_eq!(members[1].did.to_string(), revoked_did.to_string());
let active_count = members.iter().filter(|m| m.revoked_at.is_none()).count();
let revoked_count = members.iter().filter(|m| m.revoked_at.is_some()).count();
assert_eq!(active_count, 1);
assert_eq!(revoked_count, 1);
}
#[test]
fn list_org_members_exposes_revoked_flag() {
use auths_id::storage::registry::org_member::MemberFilter;
let (_dir, backend) = setup_test_repo();
let org = "EOrg1234567890";
let revoked_did = DeviceDID::new_unchecked("did:key:z6MkRevoked");
let revoked_att = AttestationBuilder::default()
.rid("revoked")
.issuer(&format!("did:keri:{}", org))
.subject(&revoked_did.to_string())
.revoked_at(Some(Utc::now()))
.role(Some(Role::Member))
.build();
backend.store_org_member(org, &revoked_att).unwrap();
let filter = MemberFilter::default();
let members = backend.list_org_members(org, &filter).unwrap();
assert_eq!(members.len(), 1);
assert_eq!(members[0].did.to_string(), revoked_did.to_string());
assert!(members[0].revoked_at.is_some());
}
#[test]
fn list_org_members_exposes_expires_at_field() {
use auths_id::storage::registry::org_member::MemberFilter;
use chrono::Duration;
let (_dir, backend) = setup_test_repo();
let org = "EOrg1234567890";
let past = Utc::now() - Duration::hours(1);
let expired_did = DeviceDID::new_unchecked("did:key:z6MkExpired");
let expired_att = AttestationBuilder::default()
.rid("expired")
.issuer(&format!("did:keri:{}", org))
.subject(&expired_did.to_string())
.expires_at(Some(past))
.role(Some(Role::Member))
.build();
backend.store_org_member(org, &expired_att).unwrap();
let filter = MemberFilter::default();
let members = backend.list_org_members(org, &filter).unwrap();
assert_eq!(members.len(), 1);
assert!(members[0].expires_at.is_some());
assert!(members[0].expires_at.unwrap() <= Utc::now());
assert!(members[0].revoked_at.is_none());
}
#[test]
fn list_org_members_marks_issuer_mismatch_as_invalid() {
use auths_id::storage::registry::org_member::{
MemberFilter, MemberInvalidReason, MemberStatus,
};
let (_dir, backend) = setup_test_repo();
let org = "EOrg1234567890";
let org_member_did = DeviceDID::new_unchecked("did:key:z6MkOrgMember");
let org_issuer = format!("did:keri:{}", org);
let org_att = AttestationBuilder::default()
.rid("org")
.issuer(&org_issuer)
.subject(&org_member_did.to_string())
.role(Some(Role::Member))
.build();
backend.store_org_member(org, &org_att).unwrap();
let wrong_did = DeviceDID::new_unchecked("did:key:z6MkWrongIssuer");
let wrong_att = AttestationBuilder::default()
.rid("wrong")
.issuer("did:keri:EDifferentIssuer") .subject(&wrong_did.to_string())
.role(Some(Role::Member))
.build();
backend.store_org_member(org, &wrong_att).unwrap();
let filter = MemberFilter::default();
let members = backend.list_org_members(org, &filter).unwrap();
assert_eq!(members.len(), 2);
let valid = members
.iter()
.find(|m| m.did.to_string() == org_member_did.to_string())
.unwrap();
assert!(matches!(valid.status, MemberStatus::Active));
let invalid = members
.iter()
.find(|m| m.did.to_string() == wrong_did.to_string())
.unwrap();
if let MemberStatus::Invalid { reason } = &invalid.status {
assert!(matches!(reason, MemberInvalidReason::IssuerMismatch { .. }));
} else {
panic!("Expected Invalid status for wrong issuer");
}
}
#[test]
fn list_org_members_filters_by_role() {
use auths_id::storage::registry::org_member::MemberFilter;
use std::collections::HashSet;
let (_dir, backend) = setup_test_repo();
let org = "EOrg1234567890";
let admin_did = DeviceDID::new_unchecked("did:key:z6MkAdminUser");
let admin_att = AttestationBuilder::default()
.rid("admin")
.issuer(&format!("did:keri:{}", org))
.subject(&admin_did.to_string())
.role(Some(Role::Admin))
.build();
backend.store_org_member(org, &admin_att).unwrap();
let member_did = DeviceDID::new_unchecked("did:key:z6MkMemberUser");
let member_att = AttestationBuilder::default()
.rid("member")
.issuer(&format!("did:keri:{}", org))
.subject(&member_did.to_string())
.role(Some(Role::Member))
.build();
backend.store_org_member(org, &member_att).unwrap();
let mut roles = HashSet::new();
roles.insert(Role::Admin);
let filter = MemberFilter {
roles_any: Some(roles),
..Default::default()
};
let members = backend.list_org_members(org, &filter).unwrap();
assert_eq!(members.len(), 1);
assert_eq!(members[0].did.to_string(), admin_did.to_string());
}
#[test]
fn list_org_members_filters_by_capability_any() {
use auths_id::storage::registry::org_member::MemberFilter;
use auths_verifier::core::Capability;
use std::collections::HashSet;
let (_dir, backend) = setup_test_repo();
let org = "EOrg1234567890";
let signer_did = DeviceDID::new_unchecked("did:key:z6MkSigner1");
let signer_att = AttestationBuilder::default()
.rid("signer")
.issuer(&format!("did:keri:{}", org))
.subject(&signer_did.to_string())
.role(Some(Role::Member))
.capabilities(vec![Capability::sign_commit()])
.build();
backend.store_org_member(org, &signer_att).unwrap();
let nocap_did = DeviceDID::new_unchecked("did:key:z6MkNoCaps1");
let nocap_att = AttestationBuilder::default()
.rid("nocap")
.issuer(&format!("did:keri:{}", org))
.subject(&nocap_did.to_string())
.role(Some(Role::Member))
.build();
backend.store_org_member(org, &nocap_att).unwrap();
let mut caps = HashSet::new();
caps.insert(Capability::sign_commit());
let filter = MemberFilter {
capabilities_any: Some(caps),
..Default::default()
};
let members = backend.list_org_members(org, &filter).unwrap();
assert_eq!(members.len(), 1);
assert_eq!(members[0].did.to_string(), signer_did.to_string());
}
#[test]
fn list_org_members_filters_by_capability_all() {
use auths_id::storage::registry::org_member::MemberFilter;
use auths_verifier::core::Capability;
use std::collections::HashSet;
let (_dir, backend) = setup_test_repo();
let org = "EOrg1234567890";
let both_did = DeviceDID::new_unchecked("did:key:z6MkBothCaps");
let both_att = AttestationBuilder::default()
.rid("both")
.issuer(&format!("did:keri:{}", org))
.subject(&both_did.to_string())
.role(Some(Role::Member))
.capabilities(vec![Capability::sign_commit(), Capability::sign_release()])
.build();
backend.store_org_member(org, &both_att).unwrap();
let one_did = DeviceDID::new_unchecked("did:key:z6MkOneCap1");
let one_att = AttestationBuilder::default()
.rid("one")
.issuer(&format!("did:keri:{}", org))
.subject(&one_did.to_string())
.role(Some(Role::Member))
.capabilities(vec![Capability::sign_commit()])
.build();
backend.store_org_member(org, &one_att).unwrap();
let mut caps = HashSet::new();
caps.insert(Capability::sign_commit());
caps.insert(Capability::sign_release());
let filter = MemberFilter {
capabilities_all: Some(caps),
..Default::default()
};
let members = backend.list_org_members(org, &filter).unwrap();
assert_eq!(members.len(), 1);
assert_eq!(members[0].did.to_string(), both_did.to_string());
}
#[test]
fn list_org_members_includes_invalid_entries() {
use auths_id::storage::registry::org_member::{MemberFilter, MemberStatus};
let (_dir, backend) = setup_test_repo();
let org = "EOrg1234567890";
let valid_did = DeviceDID::new_unchecked("did:key:z6MkValid11");
let valid_att = AttestationBuilder::default()
.rid("valid")
.issuer(&format!("did:keri:{}", org))
.subject(&valid_did.to_string())
.role(Some(Role::Member))
.build();
backend.store_org_member(org, &valid_att).unwrap();
let repo = backend.open_repo().unwrap();
let base_tree = backend.current_tree(&repo).unwrap();
let org_base = org_path(&Prefix::new_unchecked(org.to_string())).unwrap();
let bad_path = paths::member_file(&org_base, "did_key_z6MkBadOne");
let mut mutator = TreeMutator::new();
mutator.write_blob(&bad_path, b"{ invalid }".to_vec());
let new_tree_oid = mutator.build_tree(&repo, Some(&base_tree)).unwrap();
let parent = repo
.find_reference(REGISTRY_REF)
.unwrap()
.peel_to_commit()
.unwrap();
backend
.create_commit(&repo, new_tree_oid, Some(&parent), "Add invalid")
.unwrap();
let filter = MemberFilter::default();
let members = backend.list_org_members(org, &filter).unwrap();
assert_eq!(members.len(), 2);
let valid_count = members
.iter()
.filter(|m| matches!(m.status, MemberStatus::Active))
.count();
let invalid_count = members
.iter()
.filter(|m| matches!(m.status, MemberStatus::Invalid { .. }))
.count();
assert_eq!(valid_count, 1);
assert_eq!(invalid_count, 1);
}
#[test]
fn list_org_members_deterministic_ordering_by_did() {
use auths_id::storage::registry::org_member::MemberFilter;
use chrono::Duration;
let (_dir, backend) = setup_test_repo();
let org = "EOrg1234567890";
let now = Utc::now();
type MemberEntry<'a> = (&'a str, Option<DateTime<Utc>>, Option<DateTime<Utc>>);
let dids: Vec<MemberEntry> = vec![
("did:key:z6MkZZZLast", None, None), ("did:key:z6MkAAAFirst", None, None), ("did:key:z6MkBBBRevoked", Some(now), None), (
"did:key:z6MkCCCExpired",
None,
Some(now - Duration::hours(1)),
), ];
for (did_str, revoked_at, expires_at) in &dids {
let did = DeviceDID::new_unchecked(*did_str);
let att = AttestationBuilder::default()
.rid("test")
.issuer(&format!("did:keri:{}", org))
.subject(&did.to_string())
.revoked_at(*revoked_at)
.expires_at(*expires_at)
.role(Some(Role::Member))
.build();
backend.store_org_member(org, &att).unwrap();
}
let filter = MemberFilter::default();
let members = backend.list_org_members(org, &filter).unwrap();
assert_eq!(members.len(), 4);
assert_eq!(members[0].did.to_string(), "did:key:z6MkAAAFirst");
assert_eq!(members[1].did.to_string(), "did:key:z6MkBBBRevoked");
assert_eq!(members[2].did.to_string(), "did:key:z6MkCCCExpired");
assert_eq!(members[3].did.to_string(), "did:key:z6MkZZZLast");
assert!(members[0].revoked_at.is_none());
assert!(members[1].revoked_at.is_some());
assert!(members[2].expires_at.is_some());
assert!(members[3].revoked_at.is_none());
}
#[test]
fn attestation_source_load_for_device() {
let (_dir, backend) = setup_test_repo();
let did = DeviceDID::new_unchecked("did:key:z6MkSourceTest");
let attestation = AttestationBuilder::default()
.rid("source-test")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.build();
backend.store_attestation(&attestation).unwrap();
let loaded = backend.load_attestations_for_device(&did).unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].rid, "source-test");
}
#[test]
fn attestation_source_load_for_nonexistent() {
let (_dir, backend) = setup_test_repo();
let did = DeviceDID::new_unchecked("did:key:z6MkNonexistent");
let loaded = backend.load_attestations_for_device(&did).unwrap();
assert!(loaded.is_empty());
}
#[test]
fn attestation_source_load_all() {
let (_dir, backend) = setup_test_repo();
for i in 0..3 {
let did = DeviceDID::new_unchecked(format!("did:key:z6MkDevice{}", i));
let attestation = AttestationBuilder::default()
.rid(format!("rid-{}", i))
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.device_public_key(Ed25519PublicKey::from_bytes([i as u8; 32]))
.build();
backend.store_attestation(&attestation).unwrap();
}
let all = backend.load_all_attestations().unwrap();
assert_eq!(all.len(), 3);
}
#[test]
fn attestation_source_discover_dids() {
let (_dir, backend) = setup_test_repo();
let dids: Vec<_> = (0..3)
.map(|i| DeviceDID::new_unchecked(format!("did:key:z6MkDiscover{}", i)))
.collect();
for did in &dids {
let attestation = AttestationBuilder::default()
.rid("discover-test")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.build();
backend.store_attestation(&attestation).unwrap();
}
let discovered = backend.discover_device_dids().unwrap();
assert_eq!(discovered.len(), 3);
for did in &dids {
assert!(discovered.iter().any(|d| d.to_string() == did.to_string()));
}
}
#[test]
fn attestation_sink_export() {
let (_dir, backend) = setup_test_repo();
let did = DeviceDID::new_unchecked("did:key:z6MkSinkTest");
let attestation = AttestationBuilder::default()
.rid("sink-test")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.build();
backend
.export(&VerifiedAttestation::dangerous_from_unchecked(attestation))
.unwrap();
let loaded = backend.load_attestation(&did).unwrap();
assert!(loaded.is_some());
assert_eq!(loaded.unwrap().rid, "sink-test");
}
#[test]
fn attestation_sink_export_updates_existing() {
let (_dir, backend) = setup_test_repo();
let did = DeviceDID::new_unchecked("did:key:z6MkUpdateTest");
let attestation1 = AttestationBuilder::default()
.rid("original")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.build();
backend
.export(&VerifiedAttestation::dangerous_from_unchecked(attestation1))
.unwrap();
let attestation2 = AttestationBuilder::default()
.rid("updated")
.issuer("did:keri:EIssuer")
.subject(&did.to_string())
.revoked_at(Some(Utc::now())) .build();
backend
.export(&VerifiedAttestation::dangerous_from_unchecked(attestation2))
.unwrap();
let loaded = backend.load_attestation(&did).unwrap().unwrap();
assert_eq!(loaded.rid, "updated");
assert!(loaded.is_revoked());
let meta = backend.metadata().unwrap();
assert_eq!(meta.device_count, 1);
}
#[test]
fn cas_detects_concurrent_modification() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
let repo = git2::Repository::init(path).unwrap();
repo.config().unwrap().set_str("user.name", "Test").unwrap();
repo.config()
.unwrap()
.set_str("user.email", "test@test.com")
.unwrap();
let backend1 =
GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(path));
let backend2 =
GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(path));
backend1.init_if_needed().unwrap();
let (icp1, prefix1, _, _) = create_signed_icp();
backend1.append_event(&prefix1, &icp1).unwrap();
let (icp2, prefix2, _, _) = create_signed_icp();
backend2.append_event(&prefix2, &icp2).unwrap();
let mut count = 0;
backend1
.visit_identities(&mut |_| {
count += 1;
ControlFlow::Continue(())
})
.unwrap();
assert_eq!(count, 2);
}
#[test]
fn corrupt_metadata_fails_write() {
use crate::git::tree_ops::TreeMutator;
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
let repo = git2::Repository::init(path).unwrap();
repo.config().unwrap().set_str("user.name", "Test").unwrap();
repo.config()
.unwrap()
.set_str("user.email", "test@test.com")
.unwrap();
let backend =
GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(path));
backend.init_if_needed().unwrap();
{
let repo = git2::Repository::open(path).unwrap();
let reference = repo.find_reference(REGISTRY_REF).unwrap();
let parent = reference.peel_to_commit().unwrap();
let base_tree = parent.tree().unwrap();
let mut mutator = TreeMutator::new();
mutator.write_blob(&paths::versioned("metadata.json"), b"{bad json".to_vec());
let tree_oid = mutator.build_tree(&repo, Some(&base_tree)).unwrap();
let sig = repo.signature().unwrap();
let tree = repo.find_tree(tree_oid).unwrap();
let commit_oid = repo
.commit(None, &sig, &sig, "Corrupt metadata", &tree, &[&parent])
.unwrap();
repo.reference(REGISTRY_REF, commit_oid, true, "Corrupt metadata for test")
.unwrap();
}
let (icp, prefix, _, _) = create_signed_icp();
let result = backend.append_event(&prefix, &icp);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, RegistryError::Internal(ref msg) if msg.contains("Corrupt metadata.json")),
"Expected corrupt metadata error, got: {:?}",
err
);
}
#[test]
fn write_key_state_persists_state_to_git() {
let (_dir, backend) = setup_test_repo();
let (event, prefix, _keypair, _next_keypair) = create_signed_icp();
backend.append_event(&prefix, &event).unwrap();
let original_state = backend.get_key_state(&prefix).unwrap();
let modified_state = KeyState {
prefix: prefix.clone(),
current_keys: vec!["DModifiedKey123456789012345678901234".to_string()],
next_commitment: original_state.next_commitment.clone(),
sequence: original_state.sequence,
last_event_said: original_state.last_event_said.clone(),
is_abandoned: false,
threshold: 1,
next_threshold: 1,
};
backend.write_key_state(&prefix, &modified_state).unwrap();
let retrieved = backend.get_key_state(&prefix).unwrap();
assert_eq!(
retrieved.current_keys[0],
"DModifiedKey123456789012345678901234"
);
assert_eq!(retrieved.sequence, original_state.sequence);
}
#[test]
fn write_key_state_returns_not_found_for_nonexistent_identity() {
let (_dir, backend) = setup_test_repo();
let prefix = Prefix::new_unchecked("EXq5Test1234".to_string());
let state = KeyState::from_inception(
prefix.clone(),
vec!["DKey1".to_string()],
vec!["ENext1".to_string()],
1,
1,
Said::new_unchecked("ESAID12345".to_string()),
);
let result = backend.write_key_state(&prefix, &state);
assert!(
matches!(result, Err(RegistryError::NotFound { .. })),
"Expected NotFound, got: {:?}",
result
);
}
}
#[cfg(all(test, feature = "indexed-storage"))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod index_consistency_tests {
use super::*;
use auths_core::crypto::said::compute_next_commitment;
use auths_id::keri::KERI_VERSION;
use auths_id::keri::event::{IcpEvent, KeriSequence};
use auths_id::keri::types::{Prefix, Said};
use auths_id::keri::validate::{finalize_icp_event, serialize_for_signing};
use auths_id::storage::registry::org_member::MemberFilter;
use auths_verifier::core::{Ed25519PublicKey, Ed25519Signature, ResourceId};
use auths_verifier::types::CanonicalDid;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Utc;
use ring::rand::SystemRandom;
use ring::signature::{Ed25519KeyPair, KeyPair};
use tempfile::TempDir;
fn setup() -> (TempDir, GitRegistryBackend) {
let dir = TempDir::new().unwrap();
let backend =
GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(dir.path()));
backend.init_if_needed().unwrap();
(dir, backend)
}
fn make_icp() -> (Event, Prefix) {
let rng = SystemRandom::new();
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(keypair.public_key().as_ref()));
let next_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let next_keypair = Ed25519KeyPair::from_pkcs8(next_pkcs8.as_ref()).unwrap();
let next_commitment = compute_next_commitment(next_keypair.public_key().as_ref());
let icp = IcpEvent {
v: KERI_VERSION.to_string(),
d: Said::default(),
i: Prefix::default(),
s: KeriSequence::new(0),
kt: "1".to_string(),
k: vec![key_encoded],
nt: "1".to_string(),
n: vec![next_commitment],
bt: "0".to_string(),
b: vec![],
a: vec![],
x: String::new(),
};
let mut finalized = finalize_icp_event(icp).unwrap();
let canonical = serialize_for_signing(&Event::Icp(finalized.clone())).unwrap();
let sig = keypair.sign(&canonical);
finalized.x = URL_SAFE_NO_PAD.encode(sig.as_ref());
let prefix = finalized.i.clone();
(Event::Icp(finalized), prefix)
}
#[allow(clippy::disallowed_methods)]
fn make_org_attestation(org_prefix: &str, did_suffix: &str, rid: &str) -> Attestation {
Attestation {
version: 1,
rid: ResourceId::new(rid),
issuer: CanonicalDid::new_unchecked(format!("did:keri:{}", org_prefix)),
subject: CanonicalDid::new_unchecked(format!("did:key:z6Mk{}", did_suffix)),
device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
identity_signature: Ed25519Signature::empty(),
device_signature: Ed25519Signature::empty(),
revoked_at: None,
expires_at: None,
timestamp: Some(
chrono::DateTime::parse_from_rfc3339("2099-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
note: None,
payload: None,
commit_sha: None,
commit_message: None,
author: None,
oidc_binding: None,
role: None,
capabilities: vec![],
delegated_by: None,
signer_type: None,
environment_claim: None,
}
}
fn make_attestation(did_suffix: &str, rid: &str) -> Attestation {
make_org_attestation("EIssuer", did_suffix, rid)
}
fn index_of(
backend: &GitRegistryBackend,
) -> std::sync::MutexGuard<'_, auths_index::AttestationIndex> {
backend
.index
.as_ref()
.expect("index must be present in test")
.lock()
.unwrap()
}
#[test]
fn index_reflects_store_attestation_immediately() {
let (_dir, backend) = setup();
let att = make_attestation("ConsistencyA", "rid-consist-a");
backend.store_attestation(&att).unwrap();
let results = index_of(&backend)
.query_by_device("did:key:z6MkConsistencyA")
.unwrap();
assert_eq!(
results.len(),
1,
"index must have the attestation immediately after store"
);
assert_eq!(results[0].rid, "rid-consist-a");
}
#[test]
fn index_reflects_store_org_member_immediately() {
let (_dir, backend) = setup();
let org = "EOrgConsistency";
let member = make_org_attestation(org, "OrgMemberC", "rid-org-c");
backend.store_org_member(org, &member).unwrap();
let members = index_of(&backend).list_org_members_indexed(org).unwrap();
assert_eq!(
members.len(),
1,
"index must reflect org member immediately after store"
);
assert_eq!(members[0].rid, "rid-org-c");
}
#[test]
fn index_reflects_append_event_immediately() {
let (_dir, backend) = setup();
let (icp, prefix) = make_icp();
backend.append_event(&prefix, &icp).unwrap();
let identity = index_of(&backend).query_identity(prefix.as_str()).unwrap();
assert!(
identity.is_some(),
"index must have identity after append_event"
);
let identity = identity.unwrap();
assert_eq!(identity.prefix, prefix.as_str());
assert_eq!(identity.sequence, 0);
}
#[test]
fn list_org_members_fast_matches_list_org_members() {
let (_dir, backend) = setup();
let org = "EOrgFastMatch";
let filter = MemberFilter::default();
for i in 0..5u8 {
let member =
make_org_attestation(org, &format!("FastMember{}", i), &format!("rid-fast-{}", i));
backend.store_org_member(org, &member).unwrap();
}
let git_members = backend.list_org_members(org, &filter).unwrap();
let fast_members = backend.list_org_members_fast(org, &filter).unwrap();
let mut git_dids: Vec<String> = git_members.iter().map(|m| m.did.to_string()).collect();
let mut fast_dids: Vec<String> = fast_members.iter().map(|m| m.did.to_string()).collect();
git_dids.sort();
fast_dids.sort();
assert_eq!(
git_dids, fast_dids,
"list_org_members_fast must return same DIDs as list_org_members"
);
}
#[test]
fn list_org_members_fast_empty_org_returns_empty() {
let (_dir, backend) = setup();
let filter = MemberFilter::default();
let result = backend.list_org_members_fast("EOrgEmpty", &filter).unwrap();
assert!(result.is_empty());
}
#[test]
fn rebuild_identities_from_scratch_via_separate_index() {
let (_dir, backend) = setup();
for _ in 0..3 {
let (icp, prefix) = make_icp();
backend.append_event(&prefix, &icp).unwrap();
}
let fresh_index = auths_index::AttestationIndex::in_memory().unwrap();
let stats = rebuild_identities_from_registry(&fresh_index, &backend).unwrap();
assert_eq!(
stats.attestations_indexed, 3,
"rebuild must index all 3 identities, got {}",
stats.attestations_indexed
);
}
#[test]
fn rebuild_org_members_from_scratch_via_separate_index() {
let (_dir, backend) = setup();
let org = "EOrgRebuild";
for i in 0..4u8 {
let member = make_org_attestation(
org,
&format!("RebuildM{}", i),
&format!("rid-rebuild-{}", i),
);
backend.store_org_member(org, &member).unwrap();
}
let fresh_index = auths_index::AttestationIndex::in_memory().unwrap();
let stats = rebuild_org_members_from_registry(&fresh_index, &backend).unwrap();
assert!(
stats.attestations_indexed >= 4,
"rebuild must index all 4 org members, got {}",
stats.attestations_indexed
);
}
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tenant_isolation_tests {
use std::ops::ControlFlow;
use std::sync::Arc;
use auths_verifier::core::{Attestation, Ed25519PublicKey, Ed25519Signature, ResourceId};
use auths_verifier::types::{CanonicalDid, DeviceDID};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ring::rand::SystemRandom;
use ring::signature::{Ed25519KeyPair, KeyPair};
use tempfile::TempDir;
use auths_core::crypto::said::compute_next_commitment;
use auths_id::keri::KERI_VERSION;
use auths_id::keri::event::{IcpEvent, KeriSequence};
use auths_id::keri::types::{Prefix, Said};
use auths_id::keri::validate::{finalize_icp_event, serialize_for_signing};
use super::*;
use auths_id::storage::registry::backend::TenantIdError;
fn setup_tenant_backend(base: &TempDir, tenant_id: &str) -> GitRegistryBackend {
let config = RegistryConfig::for_tenant(base.path(), tenant_id).unwrap();
let b = GitRegistryBackend::from_config_unchecked(config);
assert!(b.init_if_needed().unwrap(), "expected new provisioning");
b
}
fn setup_tenant_backend_open(base: &TempDir, tenant_id: &str) -> GitRegistryBackend {
let config = RegistryConfig::for_tenant(base.path(), tenant_id).unwrap();
GitRegistryBackend::open_existing(config).unwrap()
}
fn make_icp() -> (Event, Prefix) {
let rng = SystemRandom::new();
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(keypair.public_key().as_ref()));
let next_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let next_keypair = Ed25519KeyPair::from_pkcs8(next_pkcs8.as_ref()).unwrap();
let next_commitment = compute_next_commitment(next_keypair.public_key().as_ref());
let icp = IcpEvent {
v: KERI_VERSION.to_string(),
d: Said::default(),
i: Prefix::default(),
s: KeriSequence::new(0),
kt: "1".to_string(),
k: vec![key_encoded],
nt: "1".to_string(),
n: vec![next_commitment],
bt: "0".to_string(),
b: vec![],
a: vec![],
x: String::new(),
};
let mut finalized = finalize_icp_event(icp).unwrap();
let canonical = serialize_for_signing(&Event::Icp(finalized.clone())).unwrap();
let sig = keypair.sign(&canonical);
finalized.x = URL_SAFE_NO_PAD.encode(sig.as_ref());
let prefix = finalized.i.clone();
(Event::Icp(finalized), prefix)
}
fn make_test_attestation(device_did: &str) -> Attestation {
Attestation {
version: 1,
rid: ResourceId::new("test-rid"),
issuer: CanonicalDid::new_unchecked("did:keri:EIssuer"),
subject: CanonicalDid::new_unchecked(device_did),
device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
identity_signature: Ed25519Signature::empty(),
device_signature: Ed25519Signature::empty(),
revoked_at: None,
expires_at: None,
timestamp: None,
note: None,
payload: None,
commit_sha: None,
commit_message: None,
author: None,
oidc_binding: None,
role: None,
capabilities: vec![],
delegated_by: None,
signer_type: None,
environment_claim: None,
}
}
#[test]
fn tenant_identity_isolation() {
let base = TempDir::new().unwrap();
let acme = setup_tenant_backend(&base, "acme");
let globocorp = setup_tenant_backend(&base, "globocorp");
assert_ne!(
acme.repo_path(),
globocorp.repo_path(),
"tenant repo paths must differ"
);
let (icp_acme, prefix_acme) = make_icp();
acme.append_event(&prefix_acme, &icp_acme).unwrap();
let (icp_glob, prefix_glob) = make_icp();
globocorp.append_event(&prefix_glob, &icp_glob).unwrap();
let mut acme_ids = Vec::new();
acme.visit_identities(&mut |p| {
acme_ids.push(p.to_string());
ControlFlow::Continue(())
})
.unwrap();
assert_eq!(acme_ids, vec![prefix_acme.to_string()]);
let mut glob_ids = Vec::new();
globocorp
.visit_identities(&mut |p| {
glob_ids.push(p.to_string());
ControlFlow::Continue(())
})
.unwrap();
assert_eq!(glob_ids, vec![prefix_glob.to_string()]);
assert!(acme.get_key_state(&prefix_glob).is_err());
assert!(globocorp.get_key_state(&prefix_acme).is_err());
}
#[test]
fn tenant_attestation_isolation() {
let base = TempDir::new().unwrap();
let acme = setup_tenant_backend(&base, "acme");
let globocorp = setup_tenant_backend(&base, "globocorp");
let att = make_test_attestation("did:key:z6MkTest123");
let did = DeviceDID::new_unchecked(att.subject.as_str());
acme.store_attestation(&att).unwrap();
let result = globocorp.load_attestation(&did).unwrap();
assert!(
result.is_none(),
"globocorp must not see acme's attestation"
);
}
#[test]
fn tenant_org_isolation() {
let base = TempDir::new().unwrap();
let acme = setup_tenant_backend(&base, "acme");
let globocorp = setup_tenant_backend(&base, "globocorp");
let member_att = make_test_attestation("did:key:z6MkMember");
acme.store_org_member("did:keri:EOrgAcme", &member_att)
.unwrap();
let mut count = 0usize;
globocorp
.visit_org_member_attestations("did:keri:EOrgAcme", &mut |_| {
count += 1;
ControlFlow::Continue(())
})
.unwrap();
assert_eq!(count, 0, "globocorp must not see acme's org members");
}
#[test]
fn invalid_tenant_id_rejected() {
let base = TempDir::new().unwrap();
let cases: &[(&str, TenantIdError)] = &[
("", TenantIdError::InvalidLength(0)),
(&"a".repeat(65), TenantIdError::InvalidLength(65)),
("tenant with spaces", TenantIdError::InvalidCharacter(' ')),
("acme/sub", TenantIdError::InvalidCharacter('/')),
("acme\\sub", TenantIdError::InvalidCharacter('\\')),
("../escape", TenantIdError::InvalidCharacter('.')),
(".hidden", TenantIdError::InvalidCharacter('.')),
("admin", TenantIdError::Reserved("admin".into())),
("health", TenantIdError::Reserved("health".into())),
("metrics", TenantIdError::Reserved("metrics".into())),
];
for (input, expected_kind) in cases {
match RegistryConfig::for_tenant(base.path(), *input) {
Err(RegistryError::InvalidTenantId { kind, .. }) => {
assert_eq!(&kind, expected_kind, "wrong error for input {:?}", input);
}
other => panic!("expected InvalidTenantId for {:?}, got {:?}", input, other),
}
}
}
#[test]
fn valid_tenant_id_accepted() {
let base = TempDir::new().unwrap();
let cases: &[(&str, &str)] = &[
("acme", "acme"),
("globo-corp", "globo-corp"),
("tenant_123", "tenant_123"),
("TENANT", "tenant"),
("Acme", "acme"),
("a", "a"),
];
for (input, expected_canonical) in cases {
let config = RegistryConfig::for_tenant(base.path(), *input)
.unwrap_or_else(|e| panic!("for_tenant({:?}) failed: {}", input, e));
assert_eq!(
config.tenant_id.as_ref().map(|t| t.as_str()),
Some(*expected_canonical),
"wrong canonical ID for input {:?}",
input
);
}
let long_id = "a".repeat(64);
let config = RegistryConfig::for_tenant(base.path(), &long_id).unwrap();
assert_eq!(
config.tenant_id.as_ref().map(|t| t.as_str()),
Some(long_id.as_str())
);
}
#[cfg(not(target_os = "windows"))]
#[tokio::test]
async fn ten_concurrent_tenants_no_interference() {
let base = Arc::new(TempDir::new().unwrap());
let handles: Vec<_> = (0..10)
.map(|i| {
let base = Arc::clone(&base);
tokio::task::spawn_blocking(move || {
let tid = format!("tenant-{:02}", i);
let backend = setup_tenant_backend(&base, &tid);
let (icp, prefix) = make_icp();
backend.append_event(&prefix, &icp).unwrap();
(tid, prefix)
})
})
.collect();
let mut results: Vec<(String, Prefix)> = Vec::new();
for handle in handles {
results.push(handle.await.unwrap());
}
for (tid, prefix) in &results {
let backend = setup_tenant_backend_open(&base, tid);
let mut found = Vec::new();
backend
.visit_identities(&mut |p| {
found.push(p.to_string());
ControlFlow::Continue(())
})
.unwrap();
assert_eq!(
found,
vec![prefix.to_string()],
"tenant {} should have exactly 1 identity",
tid
);
}
drop(base); }
#[test]
fn tenant_metadata_written_correctly() {
let base = TempDir::new().unwrap();
let backend = setup_tenant_backend(&base, "acme");
let meta = backend.load_tenant_metadata().unwrap();
assert_eq!(meta.tenant_id, "acme");
assert_eq!(meta.status, TenantStatus::Active);
assert_eq!(meta.version, 1);
}
}