use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime};
use async_trait::async_trait;
use thiserror::Error;
use crate::audit::{SubstrateAuditEvent, SubstrateAuditSink};
use crate::identity::{KeyId, PublicKey, TraceId};
use crate::proto::Did;
pub const MAX_DID_DOCUMENT_CACHE_AGE: Duration = Duration::from_secs(3600);
pub const MAX_TRUST_ROOT_CACHE_AGE: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct DidDocument {
pub did: Did,
pub verification_methods: Vec<(KeyId, PublicKey)>,
pub rotation_history: Vec<(KeyId, PublicKey)>,
pub services: Vec<DidService>,
pub also_known_as: Vec<String>,
pub resolved_at: SystemTime,
pub resolver_cache_max_age: Duration,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct DidService {
pub id: String,
pub service_type: String,
pub endpoint: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum DidResolutionError {
#[error("DID not found")]
NotFound,
#[error("DID document malformed")]
Malformed,
#[error("DID resolution exceeded deadline")]
DeadlineExceeded,
#[error("DID method not supported: {0}")]
MethodNotSupported(String),
#[error("DID resolution upstream error: {0}")]
UpstreamError(String),
#[error("DID tombstoned")]
Tombstoned,
}
#[async_trait]
pub trait DidResolver: Send + Sync {
async fn resolve(
&self,
did: &Did,
deadline: Instant,
trace_id: TraceId,
) -> Result<DidDocument, DidResolutionError>;
async fn invalidate(&self, did: &Did, trace_id: TraceId);
fn supported_methods(&self) -> &[&'static str] {
&["plc", "web"]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ContentType {
ApplicationJson,
ApplicationDidJson,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RawDidDoc {
pub bytes: Vec<u8>,
pub content_type: ContentType,
}
#[async_trait]
pub trait HttpDidFetcher: Send + Sync {
async fn fetch_plc(
&self,
did: &Did,
deadline: Instant,
) -> Result<RawDidDoc, DidResolutionError>;
async fn fetch_web(
&self,
did: &Did,
deadline: Instant,
) -> Result<RawDidDoc, DidResolutionError>;
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ResolverConfig {
pub max_document_cache_age: Duration,
pub max_trust_root_cache_age: Duration,
pub plc_directory_url: String,
}
impl Default for ResolverConfig {
fn default() -> Self {
ResolverConfig {
max_document_cache_age: MAX_DID_DOCUMENT_CACHE_AGE,
max_trust_root_cache_age: MAX_TRUST_ROOT_CACHE_AGE,
plc_directory_url: "https://plc.directory".to_string(),
}
}
}
#[derive(Debug, Clone)]
enum CachedEntry {
Live {
document: DidDocument,
expires_at: Instant,
},
Tombstoned,
}
pub struct DefaultDidResolver<F: HttpDidFetcher> {
fetcher: F,
config: ResolverConfig,
document_cache: Mutex<HashMap<Did, CachedEntry>>,
trust_root_cache: Mutex<HashMap<Did, CachedEntry>>,
audit_sink: Option<Arc<dyn SubstrateAuditSink>>,
}
impl<F: HttpDidFetcher> DefaultDidResolver<F> {
#[must_use]
pub fn new(fetcher: F) -> Self {
Self::with_config(fetcher, ResolverConfig::default(), None)
}
#[must_use]
pub fn with_config(
fetcher: F,
config: ResolverConfig,
audit_sink: Option<Arc<dyn SubstrateAuditSink>>,
) -> Self {
DefaultDidResolver {
fetcher,
config,
document_cache: Mutex::new(HashMap::new()),
trust_root_cache: Mutex::new(HashMap::new()),
audit_sink,
}
}
async fn resolve_with_cache(
&self,
did: &Did,
deadline: Instant,
trace_id: TraceId,
cache: &Mutex<HashMap<Did, CachedEntry>>,
cache_max_age: Duration,
) -> Result<DidDocument, DidResolutionError> {
{
let guard = cache
.lock()
.map_err(|_| DidResolutionError::UpstreamError("cache poisoned".into()))?;
match guard.get(did) {
Some(CachedEntry::Tombstoned) => {
return Err(DidResolutionError::Tombstoned);
}
Some(CachedEntry::Live { document, expires_at })
if Instant::now() < *expires_at =>
{
return Ok(document.clone());
}
_ => {
}
}
}
let raw = match did_method(did) {
"plc" => self.fetcher.fetch_plc(did, deadline).await,
"web" => self.fetcher.fetch_web(did, deadline).await,
other => {
return Err(DidResolutionError::MethodNotSupported(other.to_string()))
}
};
let raw = match raw {
Ok(r) => r,
Err(DidResolutionError::Tombstoned) => {
if let Ok(mut guard) = cache.lock() {
guard.insert(did.clone(), CachedEntry::Tombstoned);
}
return Err(DidResolutionError::Tombstoned);
}
Err(other) => return Err(other),
};
let document = parse_did_document(did, &raw)?;
let previous = {
cache
.lock()
.ok()
.and_then(|g| match g.get(did) {
Some(CachedEntry::Live { document, .. }) => Some(document.clone()),
_ => None,
})
};
if let Some(prev) = &previous {
if prev.verification_methods != document.verification_methods {
self.emit_rotation_audit(did, prev, &document, trace_id);
}
}
let ttl = document.resolver_cache_max_age.min(cache_max_age);
let expires_at = Instant::now() + ttl;
if let Ok(mut guard) = cache.lock() {
guard.insert(
did.clone(),
CachedEntry::Live {
document: document.clone(),
expires_at,
},
);
}
Ok(document)
}
pub async fn resolve_for_trust_root(
&self,
did: &Did,
deadline: Instant,
trace_id: TraceId,
) -> Result<DidDocument, DidResolutionError> {
self.resolve_with_cache(
did,
deadline,
trace_id,
&self.trust_root_cache,
self.config.max_trust_root_cache_age,
)
.await
}
fn emit_rotation_audit(
&self,
did: &Did,
previous: &DidDocument,
current: &DidDocument,
trace_id: TraceId,
) {
let Some(sink) = &self.audit_sink else { return };
let event = SubstrateAuditEvent::DidDocumentRotated {
trace_id,
did: did.clone(),
previous_methods: previous
.verification_methods
.iter()
.map(|(k, _)| *k)
.collect(),
current_methods: current
.verification_methods
.iter()
.map(|(k, _)| *k)
.collect(),
at: SystemTime::now(),
};
let _ = sink.record(event);
}
fn emit_invalidation_audit(
&self,
did: &Did,
source: crate::audit::InvalidationSource,
trace_id: TraceId,
) {
let Some(sink) = &self.audit_sink else { return };
let event = SubstrateAuditEvent::DidDocumentInvalidated {
trace_id,
did: did.clone(),
invalidated_by: source,
at: SystemTime::now(),
};
let _ = sink.record(event);
}
}
#[async_trait]
impl<F: HttpDidFetcher> DidResolver for DefaultDidResolver<F> {
async fn resolve(
&self,
did: &Did,
deadline: Instant,
trace_id: TraceId,
) -> Result<DidDocument, DidResolutionError> {
self.resolve_with_cache(
did,
deadline,
trace_id,
&self.document_cache,
self.config.max_document_cache_age,
)
.await
}
async fn invalidate(&self, did: &Did, trace_id: TraceId) {
let removed_doc = self
.document_cache
.lock()
.ok()
.and_then(|mut g| g.remove(did));
let removed_trust = self
.trust_root_cache
.lock()
.ok()
.and_then(|mut g| g.remove(did));
if removed_doc.is_some() || removed_trust.is_some() {
self.emit_invalidation_audit(
did,
crate::audit::InvalidationSource::Operator,
trace_id,
);
}
}
fn supported_methods(&self) -> &[&'static str] {
&["plc", "web"]
}
}
fn did_method(did: &Did) -> &str {
let s = did.as_str();
let mut iter = s.split(':');
iter.next(); iter.next().unwrap_or("unknown")
}
fn parse_did_document(
did: &Did,
raw: &RawDidDoc,
) -> Result<DidDocument, DidResolutionError> {
let json: serde_json::Value = serde_json::from_slice(&raw.bytes)
.map_err(|_| DidResolutionError::Malformed)?;
let mut verification_methods = Vec::new();
if let Some(arr) = json.get("verificationMethod").and_then(|v| v.as_array()) {
for entry in arr {
let id = entry
.get("id")
.and_then(|v| v.as_str())
.ok_or(DidResolutionError::Malformed)?;
let key_bytes = decode_multibase_key(entry).ok_or(DidResolutionError::Malformed)?;
let key_id = synthesize_key_id(id, &key_bytes);
verification_methods.push((
key_id,
PublicKey {
algorithm: crate::identity::SignatureAlgorithm::Ed25519,
bytes: key_bytes,
},
));
}
}
let mut services = Vec::new();
if let Some(arr) = json.get("service").and_then(|v| v.as_array()) {
for entry in arr {
let id = entry
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let service_type = entry
.get("type")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let endpoint = entry
.get("serviceEndpoint")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
services.push(DidService {
id,
service_type,
endpoint,
});
}
}
let mut also_known_as = Vec::new();
if let Some(arr) = json.get("alsoKnownAs").and_then(|v| v.as_array()) {
for entry in arr {
if let Some(s) = entry.as_str() {
also_known_as.push(s.to_string());
}
}
}
Ok(DidDocument {
did: did.clone(),
verification_methods,
rotation_history: Vec::new(),
services,
also_known_as,
resolved_at: SystemTime::now(),
resolver_cache_max_age: MAX_DID_DOCUMENT_CACHE_AGE,
})
}
fn decode_multibase_key(entry: &serde_json::Value) -> Option<[u8; 32]> {
let mb = entry.get("publicKeyMultibase").and_then(|v| v.as_str())?;
if !mb.starts_with('z') {
return None;
}
let payload = &mb[1..];
let decoded = base58btc_decode(payload)?;
if decoded.len() != 34 || decoded[0] != 0xed || decoded[1] != 0x01 {
return None;
}
let mut key = [0u8; 32];
key.copy_from_slice(&decoded[2..]);
Some(key)
}
const BASE58_ALPHABET: &[u8] =
b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
fn base58btc_decode(s: &str) -> Option<Vec<u8>> {
let mut out = vec![0u8; s.len()];
let mut len = 0usize;
for c in s.bytes() {
let mut carry = BASE58_ALPHABET.iter().position(|&a| a == c)? as u32;
for byte in &mut out[..len] {
carry += (*byte as u32) * 58;
*byte = (carry & 0xff) as u8;
carry >>= 8;
}
while carry > 0 {
out[len] = (carry & 0xff) as u8;
len += 1;
carry >>= 8;
}
}
let zeros = s.bytes().take_while(|&c| c == b'1').count();
let mut result = vec![0u8; zeros];
out[..len].reverse();
result.extend_from_slice(&out[..len]);
Some(result)
}
fn synthesize_key_id(id_uri: &str, key_bytes: &[u8; 32]) -> KeyId {
let suffix = id_uri.rsplit('#').next().unwrap_or(id_uri);
let mut out = [0u8; 32];
let suffix_bytes = suffix.as_bytes();
for (i, b) in suffix_bytes.iter().take(16).enumerate() {
out[i] = *b;
}
out[16..].copy_from_slice(&key_bytes[..16]);
KeyId::from_bytes(out)
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PeerKind {
Internal,
Federation,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerHealth {
pub reachable: bool,
pub last_observed_at: std::time::SystemTime,
pub operator_notes: String,
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct TrustQuery {
pub peer: Did,
pub operation: TrustOperation,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TrustOperation {
AcceptSyncHandshake,
AcceptCapabilityClaim,
ReplicateRecord,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TrustDecision {
Accept,
Reject,
}
#[async_trait]
pub trait PeerTrustResolver: Send + Sync {
async fn trust_for_operation(
&self,
query: &TrustQuery,
deadline: Instant,
) -> Result<TrustDecision, PeerTrustError>;
async fn record_peer_observation(
&self,
peer: &Did,
observation: PeerObservation,
deadline: Instant,
) -> Result<(), PeerTrustError>;
async fn peer_health(
&self,
peer: &Did,
deadline: Instant,
) -> Result<PeerHealth, PeerTrustError>;
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub enum PeerObservation {
SignatureVerified,
SignatureFailed,
Unreachable,
ResponseWithin(Duration),
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum PeerTrustError {
#[error("peer is not configured")]
UnknownPeer,
#[error("peer-trust query exceeded deadline")]
DeadlineExceeded,
#[error("peer-trust upstream error: {0}")]
UpstreamError(String),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::SignatureAlgorithm;
fn sample_did() -> Did {
Did::new("did:plc:resolverexample").unwrap()
}
fn sample_pubkey(byte: u8) -> PublicKey {
PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: [byte; 32],
}
}
fn sample_doc(did: &Did, key_byte: u8) -> DidDocument {
DidDocument {
did: did.clone(),
verification_methods: vec![(KeyId::from_bytes([key_byte; 32]), sample_pubkey(key_byte))],
rotation_history: vec![],
services: vec![],
also_known_as: vec![],
resolved_at: SystemTime::now(),
resolver_cache_max_age: Duration::from_secs(3600),
}
}
struct MockFetcher {
responses: Mutex<HashMap<Did, Result<RawDidDoc, DidResolutionError>>>,
plc_calls: Mutex<u32>,
web_calls: Mutex<u32>,
}
impl MockFetcher {
fn new() -> Self {
MockFetcher {
responses: Mutex::new(HashMap::new()),
plc_calls: Mutex::new(0),
web_calls: Mutex::new(0),
}
}
fn set(&self, did: &Did, response: Result<RawDidDoc, DidResolutionError>) {
self.responses.lock().unwrap().insert(did.clone(), response);
}
}
#[async_trait]
impl HttpDidFetcher for MockFetcher {
async fn fetch_plc(
&self,
did: &Did,
_deadline: Instant,
) -> Result<RawDidDoc, DidResolutionError> {
*self.plc_calls.lock().unwrap() += 1;
self.responses
.lock()
.unwrap()
.get(did)
.cloned()
.unwrap_or(Err(DidResolutionError::NotFound))
}
async fn fetch_web(
&self,
did: &Did,
_deadline: Instant,
) -> Result<RawDidDoc, DidResolutionError> {
*self.web_calls.lock().unwrap() += 1;
self.responses
.lock()
.unwrap()
.get(did)
.cloned()
.unwrap_or(Err(DidResolutionError::NotFound))
}
}
fn deadline() -> Instant {
Instant::now() + Duration::from_secs(30)
}
fn test_trace_id() -> TraceId {
TraceId::from_bytes([0xAB; 16])
}
#[test]
fn cache_age_constants_pinned_per_7_3() {
assert_eq!(MAX_DID_DOCUMENT_CACHE_AGE, Duration::from_secs(3600));
assert_eq!(MAX_TRUST_ROOT_CACHE_AGE, Duration::from_secs(60));
}
#[test]
fn resolver_config_defaults_match_7_3() {
let c = ResolverConfig::default();
assert_eq!(c.max_document_cache_age, MAX_DID_DOCUMENT_CACHE_AGE);
assert_eq!(c.max_trust_root_cache_age, MAX_TRUST_ROOT_CACHE_AGE);
assert_eq!(c.plc_directory_url, "https://plc.directory");
}
#[test]
fn resolver_supported_methods_default_is_plc_and_web() {
struct BareImpl;
#[async_trait]
impl DidResolver for BareImpl {
async fn resolve(
&self,
_did: &Did,
_deadline: Instant,
_trace_id: TraceId,
) -> Result<DidDocument, DidResolutionError> {
panic!("BareImpl::resolve should never be reached in this test")
}
async fn invalidate(&self, _did: &Did, _trace_id: TraceId) {}
}
let r = BareImpl;
assert_eq!(r.supported_methods(), &["plc", "web"]);
}
fn build_did_plc_json(_did: &Did, key_bytes: &[u8; 32]) -> Vec<u8> {
let mut payload = vec![0xed, 0x01];
payload.extend_from_slice(key_bytes);
let mb = format!("z{}", base58btc_encode(&payload));
let body = format!(
r##"{{"verificationMethod":[{{"id":"#atproto","controller":"did:plc:x","publicKeyMultibase":"{mb}"}}]}}"##
);
body.into_bytes()
}
fn base58btc_encode(input: &[u8]) -> String {
let mut result = String::new();
let mut leading_zeros = 0;
for &b in input {
if b == 0 {
leading_zeros += 1;
} else {
break;
}
}
let mut num = input.iter().fold(num_bigint_minimal::Big::zero(), |acc, &b| {
acc.mul_u32(256).add_u32(b as u32)
});
while !num.is_zero() {
let rem = num.div_mod_u32(58);
result.push(BASE58_ALPHABET[rem as usize] as char);
}
for _ in 0..leading_zeros {
result.push('1');
}
result.chars().rev().collect()
}
mod num_bigint_minimal {
#[derive(Clone)]
pub struct Big(pub Vec<u32>); impl Big {
pub fn zero() -> Self {
Big(vec![])
}
pub fn is_zero(&self) -> bool {
self.0.iter().all(|&x| x == 0)
}
pub fn mul_u32(mut self, v: u32) -> Self {
let mut carry: u64 = 0;
for limb in &mut self.0 {
let p = (*limb as u64) * (v as u64) + carry;
*limb = (p & 0xffff_ffff) as u32;
carry = p >> 32;
}
while carry > 0 {
self.0.push((carry & 0xffff_ffff) as u32);
carry >>= 32;
}
self
}
pub fn add_u32(mut self, v: u32) -> Self {
let mut carry = v as u64;
for limb in &mut self.0 {
let s = (*limb as u64) + carry;
*limb = (s & 0xffff_ffff) as u32;
carry = s >> 32;
}
if carry > 0 {
self.0.push(carry as u32);
}
self
}
pub fn div_mod_u32(&mut self, v: u32) -> u32 {
let mut rem: u64 = 0;
for i in (0..self.0.len()).rev() {
let acc = (rem << 32) | (self.0[i] as u64);
self.0[i] = (acc / (v as u64)) as u32;
rem = acc % (v as u64);
}
while let Some(&0) = self.0.last() {
self.0.pop();
}
rem as u32
}
}
}
#[tokio::test]
async fn resolve_caches_fresh_documents() {
let fetcher = MockFetcher::new();
let did = sample_did();
let key_bytes = [7u8; 32];
fetcher.set(
&did,
Ok(RawDidDoc {
bytes: build_did_plc_json(&did, &key_bytes),
content_type: ContentType::ApplicationJson,
}),
);
let resolver = DefaultDidResolver::new(fetcher);
let _doc1 = resolver.resolve(&did, deadline(), test_trace_id()).await.unwrap();
let _doc2 = resolver.resolve(&did, deadline(), test_trace_id()).await.unwrap();
let calls = *resolver.fetcher.plc_calls.lock().unwrap();
assert_eq!(calls, 1, "expected one fetch, got {calls}");
}
#[tokio::test]
async fn invalidate_clears_cache_and_forces_refetch() {
let fetcher = MockFetcher::new();
let did = sample_did();
let key_bytes = [7u8; 32];
fetcher.set(
&did,
Ok(RawDidDoc {
bytes: build_did_plc_json(&did, &key_bytes),
content_type: ContentType::ApplicationJson,
}),
);
let resolver = DefaultDidResolver::new(fetcher);
let _doc1 = resolver.resolve(&did, deadline(), test_trace_id()).await.unwrap();
resolver.invalidate(&did, test_trace_id()).await;
let _doc2 = resolver.resolve(&did, deadline(), test_trace_id()).await.unwrap();
let calls = *resolver.fetcher.plc_calls.lock().unwrap();
assert_eq!(calls, 2, "expected two fetches after invalidation, got {calls}");
}
#[tokio::test]
async fn tombstoned_did_caches_tombstone_permanently() {
let fetcher = MockFetcher::new();
let did = sample_did();
fetcher.set(&did, Err(DidResolutionError::Tombstoned));
let resolver = DefaultDidResolver::new(fetcher);
let err1 = resolver.resolve(&did, deadline(), test_trace_id()).await.unwrap_err();
let err2 = resolver.resolve(&did, deadline(), test_trace_id()).await.unwrap_err();
assert!(matches!(err1, DidResolutionError::Tombstoned));
assert!(matches!(err2, DidResolutionError::Tombstoned));
let calls = *resolver.fetcher.plc_calls.lock().unwrap();
assert_eq!(calls, 1);
}
#[tokio::test]
async fn two_caches_isolate_per_request_and_trust_root() {
let fetcher = MockFetcher::new();
let did = sample_did();
let key_bytes = [7u8; 32];
fetcher.set(
&did,
Ok(RawDidDoc {
bytes: build_did_plc_json(&did, &key_bytes),
content_type: ContentType::ApplicationJson,
}),
);
let resolver = DefaultDidResolver::new(fetcher);
let _doc_a = resolver.resolve(&did, deadline(), test_trace_id()).await.unwrap();
let _doc_b = resolver.resolve_for_trust_root(&did, deadline(), test_trace_id()).await.unwrap();
let calls = *resolver.fetcher.plc_calls.lock().unwrap();
assert_eq!(calls, 2, "expected two fetches across two caches, got {calls}");
let _doc_c = resolver.resolve(&did, deadline(), test_trace_id()).await.unwrap();
let _doc_d = resolver.resolve_for_trust_root(&did, deadline(), test_trace_id()).await.unwrap();
let calls_after = *resolver.fetcher.plc_calls.lock().unwrap();
assert_eq!(calls_after, 2, "both caches should hit; got {calls_after}");
}
#[tokio::test]
async fn unsupported_method_returns_method_not_supported() {
let fetcher = MockFetcher::new();
let resolver = DefaultDidResolver::new(fetcher);
let weird_did = Did::new("did:weird:something").unwrap();
let err = resolver.resolve(&weird_did, deadline(), test_trace_id()).await.unwrap_err();
assert!(matches!(err, DidResolutionError::MethodNotSupported(_)));
}
#[test]
fn base58btc_round_trip() {
let payload = vec![0xed, 0x01, 1, 2, 3, 4, 5];
let encoded = base58btc_encode(&payload);
let decoded = base58btc_decode(&encoded).unwrap();
assert_eq!(payload, decoded);
}
#[test]
fn synthesize_key_id_is_deterministic() {
let id1 = synthesize_key_id("did:plc:x#atproto", &[7; 32]);
let id2 = synthesize_key_id("did:plc:x#atproto", &[7; 32]);
let id3 = synthesize_key_id("did:plc:x#different", &[7; 32]);
assert_eq!(id1, id2);
assert_ne!(id1, id3);
}
#[test]
fn parse_did_document_accepts_multibase_did_plc() {
let did = sample_did();
let key = [9u8; 32];
let raw = RawDidDoc {
bytes: build_did_plc_json(&did, &key),
content_type: ContentType::ApplicationJson,
};
let doc = parse_did_document(&did, &raw).unwrap();
assert_eq!(doc.did, did);
assert_eq!(doc.verification_methods.len(), 1);
assert_eq!(doc.verification_methods[0].1.bytes, key);
}
#[test]
fn sample_doc_helper_constructs_expected_shape() {
let did = sample_did();
let doc = sample_doc(&did, 5);
assert_eq!(doc.did, did);
assert_eq!(doc.verification_methods.len(), 1);
}
use crate::audit::{AuditError, SubstrateAuditEvent, SubstrateAuditSink};
use std::sync::Mutex as StdMutex;
struct CapturingSink {
events: StdMutex<Vec<SubstrateAuditEvent>>,
}
impl CapturingSink {
fn new() -> Self {
CapturingSink {
events: StdMutex::new(Vec::new()),
}
}
fn captured(&self) -> Vec<SubstrateAuditEvent> {
self.events.lock().unwrap().clone()
}
}
impl SubstrateAuditSink for CapturingSink {
fn record(&self, event: SubstrateAuditEvent) -> Result<(), AuditError> {
self.events.lock().unwrap().push(event);
Ok(())
}
}
#[tokio::test]
async fn did_resolver_audit_emit_carries_caller_trace_id_not_zero() {
let fetcher = MockFetcher::new();
let did = sample_did();
let key_a = [0x11u8; 32];
fetcher.set(
&did,
Ok(RawDidDoc {
bytes: build_did_plc_json(&did, &key_a),
content_type: ContentType::ApplicationJson,
}),
);
let sink = Arc::new(CapturingSink::new());
let resolver = DefaultDidResolver::with_config(
fetcher,
ResolverConfig {
max_document_cache_age: Duration::from_millis(0),
..ResolverConfig::default()
},
Some(sink.clone() as Arc<dyn SubstrateAuditSink>),
);
let trace_id_x = TraceId::from_bytes([0x77; 16]);
let trace_id_y = TraceId::from_bytes([0x88; 16]);
resolver
.resolve(&did, deadline(), trace_id_x)
.await
.unwrap();
let key_b = [0x22u8; 32];
resolver.fetcher.set(
&did,
Ok(RawDidDoc {
bytes: build_did_plc_json(&did, &key_b),
content_type: ContentType::ApplicationJson,
}),
);
resolver
.resolve(&did, deadline(), trace_id_x)
.await
.unwrap();
resolver.invalidate(&did, trace_id_y).await;
let events = sink.captured();
assert!(
events.len() >= 2,
"expected at least DidDocumentRotated + DidDocumentInvalidated, got {}",
events.len()
);
let mut saw_rotated_with_x = false;
let mut saw_invalidated_with_y = false;
for ev in &events {
match ev {
SubstrateAuditEvent::DidDocumentRotated { trace_id, .. } => {
assert_eq!(*trace_id, trace_id_x, "rotated must carry caller's trace_id");
saw_rotated_with_x = true;
}
SubstrateAuditEvent::DidDocumentInvalidated { trace_id, .. } => {
assert_eq!(*trace_id, trace_id_y, "invalidated must carry caller's trace_id");
saw_invalidated_with_y = true;
}
_ => {}
}
}
assert!(saw_rotated_with_x, "expected DidDocumentRotated with trace_id_x");
assert!(saw_invalidated_with_y, "expected DidDocumentInvalidated with trace_id_y");
}
}