use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::{
ReceiptPolicy, RegistryClient, RevocationCache, RevocationPolicy, VerificationPolicy,
VerifiedContext,
};
use acdp_did::WebResolver;
use acdp_primitives::error::AcdpError;
use acdp_safe_http::SsrfPolicy;
use acdp_types::body::Body;
use acdp_types::primitives::CtxId;
use acdp_types::CapabilitiesDocument;
#[derive(Debug, Clone)]
pub struct ResolverOptions {
pub max_depth: usize,
pub max_nodes: usize,
pub max_fanout: usize,
pub total_timeout: Duration,
pub capabilities_ttl: Duration,
}
impl Default for ResolverOptions {
fn default() -> Self {
Self {
max_depth: 10,
max_nodes: 100,
max_fanout: 32,
total_timeout: Duration::from_secs(30),
capabilities_ttl: Duration::from_secs(300),
}
}
}
pub struct CrossRegistryResolver {
did_resolver: WebResolver,
options: ResolverOptions,
allowlist: Option<HashSet<String>>,
ssrf_policy: SsrfPolicy,
client_cache: Mutex<HashMap<String, RegistryClient>>,
caps_cache: Mutex<HashMap<String, (CapabilitiesDocument, Instant, Duration)>>,
revocation_policy: RevocationPolicy,
revocation_cache: Option<RevocationCache>,
}
impl Default for CrossRegistryResolver {
fn default() -> Self {
Self::new()
}
}
impl CrossRegistryResolver {
pub fn new() -> Self {
Self {
did_resolver: WebResolver::new(),
options: ResolverOptions::default(),
allowlist: None,
ssrf_policy: SsrfPolicy::default(),
client_cache: Mutex::new(HashMap::new()),
caps_cache: Mutex::new(HashMap::new()),
revocation_policy: RevocationPolicy::default(),
revocation_cache: None,
}
}
pub fn with_ssrf_policy(mut self, policy: SsrfPolicy) -> Self {
self.ssrf_policy = policy;
self
}
pub fn with_max_depth(mut self, depth: usize) -> Self {
self.options.max_depth = depth;
self
}
pub fn with_options(mut self, options: ResolverOptions) -> Self {
self.options = options;
self
}
pub fn options(&self) -> &ResolverOptions {
&self.options
}
pub fn with_revocation_policy(mut self, policy: RevocationPolicy) -> Self {
self.revocation_policy = policy;
self
}
pub fn with_revocation_cache(mut self, cache: RevocationCache) -> Self {
self.revocation_cache = Some(cache);
self
}
pub fn revocation_policy(&self) -> &RevocationPolicy {
&self.revocation_policy
}
pub fn with_did_resolver(mut self, resolver: WebResolver) -> Self {
self.did_resolver = resolver;
self
}
pub fn seed_client(&self, authority: impl Into<String>, client: RegistryClient) {
self.client_cache
.lock()
.unwrap()
.insert(authority.into(), client);
}
pub fn with_allowlist<I, S>(mut self, authorities: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.allowlist = Some(authorities.into_iter().map(Into::into).collect());
self
}
pub async fn resolve(&self, ctx_id: &CtxId) -> Result<VerifiedContext, AcdpError> {
self.resolve_inner(ctx_id, self.revocation_cache.as_ref(), None)
.await
}
async fn resolve_inner(
&self,
ctx_id: &CtxId,
cache: Option<&RevocationCache>,
walk_scoped_freshness: Option<Duration>,
) -> Result<VerifiedContext, AcdpError> {
let parsed = CtxId::parse(ctx_id.as_str())?;
let authority = parsed.authority().to_string();
self.check_allowlist(&authority)?;
let base = format!("https://{authority}");
self.ssrf_policy
.check_url(&base)
.map_err(|e| AcdpError::CrossRegistryResolutionFailed(format!("SSRF policy: {e}")))?;
let registry = self.client_for(&authority, &base).await?;
let registry = match cache {
Some(cache) if registry.revocation_cache().is_none() => {
registry.with_revocation_cache(cache.clone())
}
_ => registry,
};
let caps = self.capabilities_for(&authority, ®istry).await?;
let expected_did = acdp_did::authority_to_did_web(&authority);
if caps.registry_did != expected_did {
return Err(AcdpError::CrossRegistryResolutionFailed(format!(
"registry DID '{}' does not match expected '{expected_did}'",
caps.registry_did
)));
}
let registry_doc = self
.did_resolver
.resolve(&caps.registry_did)
.await
.map_err(|e| {
AcdpError::CrossRegistryResolutionFailed(format!(
"could not resolve registry DID document for '{}': {e}",
caps.registry_did
))
})?;
if registry_doc.id != caps.registry_did {
return Err(AcdpError::CrossRegistryResolutionFailed(format!(
"registry DID document `id` '{}' does not match capabilities.registry_did '{}'",
registry_doc.id, caps.registry_did
)));
}
let mut revocations = self.revocation_policy.clone();
if let Some(freshness) = walk_scoped_freshness {
if let Some(discovery) = revocations.discover.as_mut() {
discovery.freshness = freshness;
}
}
let mut policy = VerificationPolicy {
revocations,
..VerificationPolicy::default()
};
if caps.claims_profile(acdp_types::profile::Profile::RegistryReceipts) {
policy.receipts = ReceiptPolicy::Require;
}
VerifiedContext::fetch_with_policy(®istry, &self.did_resolver, &parsed, &policy)
.await
.map_err(|e| ssrf_refusal_to_cross_registry(&authority, e))
}
pub async fn walk_derived_from(&self, body: &Body) -> Result<Vec<VerifiedContext>, AcdpError> {
let total_timeout = self.options.total_timeout;
let (walk_cache, walk_scoped_freshness) = match &self.revocation_cache {
Some(cache) => (cache.clone(), None),
None => (RevocationCache::default(), Some(total_timeout)),
};
let fut = self.walk_derived_from_inner(body, &walk_cache, walk_scoped_freshness);
match tokio::time::timeout(total_timeout, fut).await {
Ok(res) => res,
Err(_) => Err(AcdpError::CrossRegistryResolutionFailed(format!(
"derived_from walk exceeded total_timeout={:?}",
total_timeout
))),
}
}
async fn walk_derived_from_inner(
&self,
body: &Body,
cache: &RevocationCache,
walk_scoped_freshness: Option<Duration>,
) -> Result<Vec<VerifiedContext>, AcdpError> {
let mut seen: HashSet<String> = HashSet::new();
seen.insert(body.ctx_id.0.clone());
if body.derived_from.len() > self.options.max_fanout {
return Err(AcdpError::CrossRegistryResolutionFailed(format!(
"root context {} has derived_from fanout {} > max_fanout={}",
body.ctx_id.0,
body.derived_from.len(),
self.options.max_fanout
)));
}
let mut results: Vec<VerifiedContext> = Vec::new();
let mut frontier: VecDeque<(CtxId, usize)> = body
.derived_from
.iter()
.map(|c| (c.clone(), 1usize))
.collect();
while let Some((next, depth)) = frontier.pop_front() {
if !seen.insert(next.0.clone()) {
continue; }
if depth > self.options.max_depth {
return Err(AcdpError::CrossRegistryResolutionFailed(format!(
"derived_from walk exceeded max_depth={} at {}",
self.options.max_depth, next.0
)));
}
if results.len() >= self.options.max_nodes {
return Err(AcdpError::CrossRegistryResolutionFailed(format!(
"derived_from walk exceeded max_nodes={} (last attempted: {})",
self.options.max_nodes, next.0
)));
}
let verified = self
.resolve_inner(&next, Some(cache), walk_scoped_freshness)
.await?;
let parents = &verified.body().derived_from;
if parents.len() > self.options.max_fanout {
return Err(AcdpError::CrossRegistryResolutionFailed(format!(
"context {} has derived_from fanout {} > max_fanout={}",
next.0,
parents.len(),
self.options.max_fanout
)));
}
for parent in parents {
if !seen.contains(parent.as_str()) {
frontier.push_back((parent.clone(), depth + 1));
}
}
results.push(verified);
}
Ok(results)
}
fn check_allowlist(&self, authority: &str) -> Result<(), AcdpError> {
if let Some(list) = &self.allowlist {
if !list.contains(authority) {
return Err(AcdpError::CrossRegistryResolutionFailed(format!(
"authority '{authority}' is not on the resolver allowlist"
)));
}
}
Ok(())
}
async fn client_for(&self, authority: &str, base: &str) -> Result<RegistryClient, AcdpError> {
{
let cache = self.client_cache.lock().unwrap();
if let Some(c) = cache.get(authority) {
return Ok(c.clone());
}
}
let client = RegistryClient::builder(base)
.pinned(true)
.ssrf_policy(self.ssrf_policy.clone())
.build()
.await
.map_err(|e| ssrf_refusal_to_cross_registry(authority, e))?;
let mut cache = self.client_cache.lock().unwrap();
Ok(cache.entry(authority.to_string()).or_insert(client).clone())
}
async fn capabilities_for(
&self,
authority: &str,
registry: &RegistryClient,
) -> Result<CapabilitiesDocument, AcdpError> {
{
let cache = self.caps_cache.lock().unwrap();
if let Some((caps, fetched_at, ttl)) = cache.get(authority) {
if fetched_at.elapsed() < *ttl {
return Ok(caps.clone());
}
}
}
let (caps, response_ttl) = registry
.capabilities_with_ttl()
.await
.map_err(|e| match e {
AcdpError::Http(_) | AcdpError::KeyResolutionUnreachable(_) => {
AcdpError::CrossRegistryResolutionFailed(format!(
"could not reach registry '{authority}': {e}"
))
}
other => ssrf_refusal_to_cross_registry(authority, other),
})?;
let ttl = response_ttl.min(self.options.capabilities_ttl);
let mut cache = self.caps_cache.lock().unwrap();
cache.insert(authority.to_string(), (caps.clone(), Instant::now(), ttl));
Ok(caps)
}
pub fn cached_capabilities(&self, authority: &str) -> Option<CapabilitiesDocument> {
let cache = self.caps_cache.lock().unwrap();
cache
.get(authority)
.and_then(|(caps, fetched_at, ttl)| (fetched_at.elapsed() < *ttl).then(|| caps.clone()))
}
}
fn ssrf_refusal_to_cross_registry(authority: &str, e: AcdpError) -> AcdpError {
if e.is_ssrf_policy_refusal() {
AcdpError::CrossRegistryResolutionFailed(format!(
"could not reach registry '{authority}': {e}"
))
} else {
e
}
}
#[cfg(test)]
mod tests {
use super::*;
const SSRF_CTX: &str = "acdp://localhost/550e8400-e29b-41d4-a716-446655440000";
fn is_cross_registry_wire_code(e: &AcdpError) -> bool {
let wire = acdp_primitives::WireError {
error: acdp_primitives::WireErrorBody {
code: "cross_registry_resolution_failed".into(),
message: String::new(),
details: None,
},
};
std::mem::discriminant(e) == std::mem::discriminant(&AcdpError::from_wire_error(wire))
}
#[tokio::test]
async fn resolve_maps_pin_time_ssrf_refusal_to_cross_registry_failure() {
let resolver = CrossRegistryResolver::new();
let err = resolver
.resolve(&CtxId::parse(SSRF_CTX).unwrap())
.await
.expect_err("localhost authority MUST be refused");
assert!(
is_cross_registry_wire_code(&err),
"expected cross_registry_resolution_failed, got {err:?}"
);
assert!(err.to_string().contains("SSRF policy"), "{err}");
}
#[tokio::test]
async fn resolve_maps_dns_time_ssrf_refusal_to_cross_registry_failure() {
let direct = RegistryClient::new("https://localhost").unwrap();
let direct_err = direct.capabilities().await.unwrap_err();
assert!(
direct_err.is_ssrf_policy_refusal(),
"direct caller sees the permanent SSRF SchemaViolation, got {direct_err:?}"
);
let resolver = CrossRegistryResolver::new();
resolver.seed_client("localhost", direct);
let err = resolver
.resolve(&CtxId::parse(SSRF_CTX).unwrap())
.await
.expect_err("localhost authority MUST be refused");
assert!(
is_cross_registry_wire_code(&err),
"expected cross_registry_resolution_failed, got {err:?}"
);
assert!(err.to_string().contains("SSRF policy"), "{err}");
}
#[test]
fn ssrf_remap_leaves_other_errors_alone() {
let e = ssrf_refusal_to_cross_registry("r", AcdpError::SchemaViolation("bad".into()));
assert!(matches!(e, AcdpError::SchemaViolation(m) if m == "bad"));
}
fn test_caps() -> CapabilitiesDocument {
serde_json::from_value(serde_json::json!({
"acdp_version": "0.4.0",
"registry_did": "did:web:registry.example.com",
"supported_signature_algorithms": ["ed25519"],
"supported_did_methods": ["did:web"],
"profiles": ["acdp-registry-core"],
"limits": {"max_payload_bytes": 1_048_576, "max_embedded_bytes": 65536},
}))
.unwrap()
}
#[test]
fn cached_capabilities_returns_none_when_never_fetched() {
let resolver = CrossRegistryResolver::new();
assert!(resolver
.cached_capabilities("registry.example.com")
.is_none());
}
#[test]
fn cached_capabilities_returns_fresh_entry_without_fetching() {
let resolver = CrossRegistryResolver::new();
resolver.caps_cache.lock().unwrap().insert(
"registry.example.com".to_string(),
(test_caps(), Instant::now(), Duration::from_secs(300)),
);
let caps = resolver
.cached_capabilities("registry.example.com")
.expect("entry was just seeded fresh");
assert_eq!(caps.registry_did, "did:web:registry.example.com");
}
#[test]
fn cached_capabilities_reports_expired_entry_as_absent() {
let resolver = CrossRegistryResolver::new();
let long_ago = Instant::now()
.checked_sub(Duration::from_secs(60))
.expect("test host uptime exceeds 60s");
resolver.caps_cache.lock().unwrap().insert(
"registry.example.com".to_string(),
(test_caps(), long_ago, Duration::from_secs(1)),
);
assert!(resolver
.cached_capabilities("registry.example.com")
.is_none());
}
#[test]
fn allowlist_rejects_outside_authorities() {
let resolver =
CrossRegistryResolver::new().with_allowlist(["registry.example.com".to_string()]);
let err = resolver.check_allowlist("evil.com").unwrap_err();
assert!(matches!(err, AcdpError::CrossRegistryResolutionFailed(_)));
resolver.check_allowlist("registry.example.com").unwrap();
}
#[test]
fn options_default_values_match_doc() {
let o = ResolverOptions::default();
assert_eq!(o.max_depth, 10);
assert_eq!(o.max_nodes, 100);
assert_eq!(o.max_fanout, 32);
assert_eq!(o.total_timeout, Duration::from_secs(30));
assert_eq!(o.capabilities_ttl, Duration::from_secs(300));
}
#[test]
fn with_options_replaces_full_struct() {
let r = CrossRegistryResolver::new().with_options(ResolverOptions {
max_depth: 3,
max_nodes: 7,
max_fanout: 2,
total_timeout: Duration::from_secs(5),
capabilities_ttl: Duration::from_secs(60),
});
assert_eq!(r.options().max_depth, 3);
assert_eq!(r.options().max_nodes, 7);
assert_eq!(r.options().max_fanout, 2);
}
#[test]
fn cycle_detection_short_circuits() {
let _resolver = CrossRegistryResolver::new();
let mut seen: HashSet<String> = HashSet::new();
let id = "acdp://r/12345678-1234-4321-8123-123456781234".to_string();
assert!(seen.insert(id.clone()));
assert!(!seen.insert(id));
}
}