Skip to main content

acdp_client/
cross_registry.rs

1//! Cross-registry resolution per RFC-ACDP-0006 (feature = "client").
2//!
3//! Resolves a `ctx_id` whose authority differs from the registry the
4//! consumer is currently talking to. Walks the lineage of `derived_from`
5//! references with cycle detection, configurable depth / node / fanout
6//! caps, and per-authority caching of the `RegistryClient` and
7//! capabilities document.
8//!
9//! See RFC-ACDP-0006 §4.1 for the seven-step algorithm:
10//!   1. Parse URI → authority
11//!   2. Fetch the foreign registry's capabilities
12//!   3. Verify the registry DID matches `did:web:<authority>`
13//!   4. Retrieve the full context
14//!   5. Verify content_hash
15//!   6. Verify signature via DID resolution
16//!   7. Walk `derived_from` references (with cycle/depth/node/fanout/timeout limits)
17
18use std::collections::{HashMap, HashSet, VecDeque};
19use std::sync::Mutex;
20use std::time::{Duration, Instant};
21
22use crate::{ReceiptPolicy, RegistryClient, VerificationPolicy, VerifiedContext};
23use acdp_did::WebResolver;
24use acdp_primitives::error::AcdpError;
25use acdp_safe_http::SsrfPolicy;
26use acdp_types::body::Body;
27use acdp_types::primitives::CtxId;
28use acdp_types::CapabilitiesDocument;
29
30/// Per-walk and per-resolve safety options.
31///
32/// Defaults are tuned for RFC-ACDP-0006 §7.4 / §7.5 — they bound a walk
33/// even when the producer fabricates `derived_from` lists pointing into a
34/// foreign registry's pathological lineage graph.
35#[derive(Debug, Clone)]
36pub struct ResolverOptions {
37    /// Per-edge maximum depth (default 10).
38    pub max_depth: usize,
39    /// Total number of contexts the walk may verify (default 100). Acts
40    /// as a hard ceiling even when individual hops respect `max_depth`.
41    pub max_nodes: usize,
42    /// Maximum `derived_from` count permitted on any single context the
43    /// walker visits (default 32). A context that lists more parents is
44    /// either malformed or hostile — short-circuit before fanning out.
45    pub max_fanout: usize,
46    /// Wall-clock budget for the entire walk (default 30 s). Wraps
47    /// [`CrossRegistryResolver::walk_derived_from`] in `tokio::time::timeout`.
48    pub total_timeout: Duration,
49    /// How long to cache a foreign registry's capabilities document
50    /// before re-fetching (default 5 min). Avoids hammering the foreign
51    /// `/.well-known/acdp.json` on every hop.
52    pub capabilities_ttl: Duration,
53}
54
55impl Default for ResolverOptions {
56    fn default() -> Self {
57        Self {
58            max_depth: 10,
59            max_nodes: 100,
60            max_fanout: 32,
61            total_timeout: Duration::from_secs(30),
62            capabilities_ttl: Duration::from_secs(300),
63        }
64    }
65}
66
67/// Resolver for cross-registry references.
68///
69/// Holds a [`WebResolver`] for DID lookups and caches a [`RegistryClient`]
70/// + capabilities document per authority for the lifetime of the resolver.
71///
72/// The [`SsrfPolicy`] is consulted on every URL the resolver constructs
73/// (RFC-ACDP-0006 §7.1, §7.2).
74pub struct CrossRegistryResolver {
75    did_resolver: WebResolver,
76    options: ResolverOptions,
77    allowlist: Option<HashSet<String>>,
78    ssrf_policy: SsrfPolicy,
79    // Per-authority caches. Mutex-guarded for interior mutability across
80    // the immutable `&self` API surface; contention is low since
81    // authorities are few per walk.
82    client_cache: Mutex<HashMap<String, RegistryClient>>,
83    /// Per-authority capabilities cache. The `Duration` is the
84    /// per-response TTL parsed from `Cache-Control: max-age=N` (capped
85    /// at 3600s per RFC-ACDP-0006 §4.2). Replaces an earlier shape
86    /// that used the resolver-wide `capabilities_ttl` for every entry,
87    /// ignoring the registry's own cache hint (BUG-09).
88    caps_cache: Mutex<HashMap<String, (CapabilitiesDocument, Instant, Duration)>>,
89}
90
91impl Default for CrossRegistryResolver {
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97impl CrossRegistryResolver {
98    /// Build a resolver with default settings: no allowlist, depth 10,
99    /// HTTPS-only / no IP literals SSRF policy.
100    pub fn new() -> Self {
101        Self {
102            did_resolver: WebResolver::new(),
103            options: ResolverOptions::default(),
104            allowlist: None,
105            ssrf_policy: SsrfPolicy::default(),
106            client_cache: Mutex::new(HashMap::new()),
107            caps_cache: Mutex::new(HashMap::new()),
108        }
109    }
110
111    /// Override the [`SsrfPolicy`] applied to outbound URLs.
112    ///
113    /// Useful for test environments that need to allow `http://` or
114    /// IP-literal hosts. Production deployments SHOULD keep the default.
115    pub fn with_ssrf_policy(mut self, policy: SsrfPolicy) -> Self {
116        self.ssrf_policy = policy;
117        self
118    }
119
120    /// Cap the number of `derived_from` hops walked in a single
121    /// [`Self::walk_derived_from`] call.
122    pub fn with_max_depth(mut self, depth: usize) -> Self {
123        self.options.max_depth = depth;
124        self
125    }
126
127    /// Replace the complete options struct (overrides every individual
128    /// `with_*` setter that wasn't already applied).
129    pub fn with_options(mut self, options: ResolverOptions) -> Self {
130        self.options = options;
131        self
132    }
133
134    /// Borrow the active options. Useful for tests + telemetry.
135    pub fn options(&self) -> &ResolverOptions {
136        &self.options
137    }
138
139    /// Override the [`WebResolver`] used for DID document lookups.
140    ///
141    /// Primary use is supplying a `WebResolver::with_root_cert_pem`
142    /// instance in tests so a self-signed mock can answer DID-document
143    /// requests for `did:web:localhost%3A<port>`. Production callers do
144    /// not need this — the default resolver trusts the system CA bundle.
145    pub fn with_did_resolver(mut self, resolver: WebResolver) -> Self {
146        self.did_resolver = resolver;
147        self
148    }
149
150    /// Pre-populate the per-authority [`RegistryClient`] cache.
151    ///
152    /// Primary use is the conformance harness: tests supply a client
153    /// whose HTTP layer trusts the in-process TLS server's self-signed
154    /// root certificate (via [`RegistryClient::with_root_cert_pem`]), so
155    /// the resolver hits the mock instead of attempting a real network
156    /// call. The seeded client wins over the lazy pin-once
157    /// `RegistryClient::builder(..).pinned(true)` client that
158    /// [`Self::resolve`] would otherwise build on first access.
159    pub fn seed_client(&self, authority: impl Into<String>, client: RegistryClient) {
160        self.client_cache
161            .lock()
162            .unwrap()
163            .insert(authority.into(), client);
164    }
165
166    /// Restrict cross-registry resolution to a fixed set of authorities
167    /// (lowercase DNS hostnames). When set, any reference outside the
168    /// allowlist is rejected with [`AcdpError::CrossRegistryResolutionFailed`].
169    pub fn with_allowlist<I, S>(mut self, authorities: I) -> Self
170    where
171        I: IntoIterator<Item = S>,
172        S: Into<String>,
173    {
174        self.allowlist = Some(authorities.into_iter().map(Into::into).collect());
175        self
176    }
177
178    /// Resolve a single cross-registry [`CtxId`] end-to-end.
179    ///
180    /// Steps 1–7 of RFC-ACDP-0006 §4.1: parse, fetch capabilities,
181    /// verify the registry DID *and* its DID document's web binding,
182    /// retrieve, recompute hash, verify signature, and (step 7,
183    /// NORMATIVE) bind the resolved identity — reached through
184    /// `fetch_with_policy`, which refuses a served body whose `ctx_id`
185    /// is not the one requested. The [`SsrfPolicy`] is checked first so
186    /// a hostile authority cannot drive an internal-network request.
187    pub async fn resolve(&self, ctx_id: &CtxId) -> Result<VerifiedContext, AcdpError> {
188        let parsed = CtxId::parse(ctx_id.as_str())?;
189        let authority = parsed.authority().to_string();
190        self.check_allowlist(&authority)?;
191
192        // RFC-ACDP-0006 §7: SSRF policy on the outbound base URL.
193        let base = format!("https://{authority}");
194        self.ssrf_policy
195            .check_url(&base)
196            .map_err(|e| AcdpError::CrossRegistryResolutionFailed(format!("SSRF policy: {e}")))?;
197
198        // Cached client (and capabilities) per authority.
199        let registry = self.client_for(&authority, &base).await?;
200        let caps = self.capabilities_for(&authority, &registry).await?;
201
202        // Step 3a: capabilities.registry_did MUST be `did:web:<authority>`.
203        // BUG-06: percent-encode `:` for host:port authorities so the
204        // expected DID round-trips with `authority_to_did_web`.
205        let expected_did = acdp_did::authority_to_did_web(&authority);
206        if caps.registry_did != expected_did {
207            return Err(AcdpError::CrossRegistryResolutionFailed(format!(
208                "registry DID '{}' does not match expected '{expected_did}'",
209                caps.registry_did
210            )));
211        }
212
213        // Step 3b (RFC-ACDP-0006 §4.1 step 3): resolve the registry's
214        // DID document and confirm the web binding matches `<authority>`.
215        let registry_doc = self
216            .did_resolver
217            .resolve(&caps.registry_did)
218            .await
219            .map_err(|e| {
220                AcdpError::CrossRegistryResolutionFailed(format!(
221                    "could not resolve registry DID document for '{}': {e}",
222                    caps.registry_did
223                ))
224            })?;
225        if registry_doc.id != caps.registry_did {
226            return Err(AcdpError::CrossRegistryResolutionFailed(format!(
227                "registry DID document `id` '{}' does not match capabilities.registry_did '{}'",
228                registry_doc.id, caps.registry_did
229            )));
230        }
231
232        // Steps 4–6: retrieve + verify. fed-009 / RFC-ACDP-0010 §7+§11:
233        // an upstream advertising `acdp-registry-receipts` MUST always
234        // serve a receipt — absence is a registry fault (`invalid_receipt`),
235        // not a degraded mode — so the policy escalates to `Require` for
236        // such upstreams. Receipt-less upstreams proceed under the
237        // v0.1.0 trust model (receipt verified only if one is present).
238        let policy = if caps.claims_profile(acdp_types::profile::Profile::RegistryReceipts) {
239            VerificationPolicy {
240                receipts: ReceiptPolicy::Require,
241                ..VerificationPolicy::default()
242            }
243        } else {
244            VerificationPolicy::default()
245        };
246        VerifiedContext::fetch_with_policy(&registry, &self.did_resolver, &parsed, &policy).await
247    }
248
249    /// Walk the `derived_from` graph rooted at `body` with cycle detection,
250    /// a per-edge depth cap of [`ResolverOptions::max_depth`], a total-
251    /// nodes cap of `max_nodes`, a per-context fanout cap of `max_fanout`,
252    /// and a wall-clock `total_timeout`. Returns each verified ancestor
253    /// (excluding the root). Breadth-first; closer ancestors are returned
254    /// first.
255    pub async fn walk_derived_from(&self, body: &Body) -> Result<Vec<VerifiedContext>, AcdpError> {
256        let total_timeout = self.options.total_timeout;
257        let fut = self.walk_derived_from_inner(body);
258        match tokio::time::timeout(total_timeout, fut).await {
259            Ok(res) => res,
260            Err(_) => Err(AcdpError::CrossRegistryResolutionFailed(format!(
261                "derived_from walk exceeded total_timeout={:?}",
262                total_timeout
263            ))),
264        }
265    }
266
267    async fn walk_derived_from_inner(
268        &self,
269        body: &Body,
270    ) -> Result<Vec<VerifiedContext>, AcdpError> {
271        let mut seen: HashSet<String> = HashSet::new();
272        seen.insert(body.ctx_id.0.clone());
273
274        if body.derived_from.len() > self.options.max_fanout {
275            return Err(AcdpError::CrossRegistryResolutionFailed(format!(
276                "root context {} has derived_from fanout {} > max_fanout={}",
277                body.ctx_id.0,
278                body.derived_from.len(),
279                self.options.max_fanout
280            )));
281        }
282
283        let mut results: Vec<VerifiedContext> = Vec::new();
284        let mut frontier: VecDeque<(CtxId, usize)> = body
285            .derived_from
286            .iter()
287            .map(|c| (c.clone(), 1usize))
288            .collect();
289
290        while let Some((next, depth)) = frontier.pop_front() {
291            if !seen.insert(next.0.clone()) {
292                continue; // cycle
293            }
294            if depth > self.options.max_depth {
295                return Err(AcdpError::CrossRegistryResolutionFailed(format!(
296                    "derived_from walk exceeded max_depth={} at {}",
297                    self.options.max_depth, next.0
298                )));
299            }
300            if results.len() >= self.options.max_nodes {
301                return Err(AcdpError::CrossRegistryResolutionFailed(format!(
302                    "derived_from walk exceeded max_nodes={} (last attempted: {})",
303                    self.options.max_nodes, next.0
304                )));
305            }
306            let verified = self.resolve(&next).await?;
307            let parents = &verified.body().derived_from;
308            if parents.len() > self.options.max_fanout {
309                return Err(AcdpError::CrossRegistryResolutionFailed(format!(
310                    "context {} has derived_from fanout {} > max_fanout={}",
311                    next.0,
312                    parents.len(),
313                    self.options.max_fanout
314                )));
315            }
316            for parent in parents {
317                if !seen.contains(parent.as_str()) {
318                    frontier.push_back((parent.clone(), depth + 1));
319                }
320            }
321            results.push(verified);
322        }
323        Ok(results)
324    }
325
326    fn check_allowlist(&self, authority: &str) -> Result<(), AcdpError> {
327        if let Some(list) = &self.allowlist {
328            if !list.contains(authority) {
329                return Err(AcdpError::CrossRegistryResolutionFailed(format!(
330                    "authority '{authority}' is not on the resolver allowlist"
331                )));
332            }
333        }
334        Ok(())
335    }
336
337    /// Return a cached `RegistryClient` for the authority, building one
338    /// on first use. Reuse across hops avoids per-hop reqwest
339    /// connection-pool churn.
340    ///
341    /// SEC-01: the client is built via
342    /// `RegistryClient::builder(base).pinned(true)`, which resolves the
343    /// authority's DNS up-front, filters every resolved IP through the
344    /// resolver's [`SsrfPolicy`], and pins the connection to that
345    /// address. Without pinning a hostile `ctx_id`
346    /// authority (e.g. `internal-host.example.com` resolving to
347    /// `10.0.0.1` or `169.254.169.254`) would slip past the URL-syntax
348    /// `check_url` gate and reach an internal target. The seeded test
349    /// path ([`Self::seed_client`]) bypasses this constructor.
350    async fn client_for(&self, authority: &str, base: &str) -> Result<RegistryClient, AcdpError> {
351        {
352            let cache = self.client_cache.lock().unwrap();
353            if let Some(c) = cache.get(authority) {
354                return Ok(c.clone());
355            }
356        }
357        // Build with pin-once DNS resolution before taking the cache
358        // lock — the builder's `.build()` is async (it resolves the
359        // authority up front) and the cache mutex must not be held
360        // across the await.
361        let client = RegistryClient::builder(base)
362            .pinned(true)
363            .ssrf_policy(self.ssrf_policy.clone())
364            .build()
365            .await?;
366        let mut cache = self.client_cache.lock().unwrap();
367        Ok(cache.entry(authority.to_string()).or_insert(client).clone())
368    }
369
370    /// Return the cached capabilities for `authority`, fetching when
371    /// the entry is missing or its per-response TTL has elapsed.
372    ///
373    /// BUG-09: TTL comes from the response's `Cache-Control: max-age=N`
374    /// (clamped to `[1s, ResolverOptions::capabilities_ttl]` so the
375    /// resolver-wide ceiling still applies) rather than a fixed value.
376    /// A registry serving `Cache-Control: max-age=60` is honored; one
377    /// serving no `Cache-Control` falls back to the
378    /// [`RegistryClient::capabilities_with_ttl`] default (300s).
379    async fn capabilities_for(
380        &self,
381        authority: &str,
382        registry: &RegistryClient,
383    ) -> Result<CapabilitiesDocument, AcdpError> {
384        // Fast path: cache hit + within per-response TTL.
385        {
386            let cache = self.caps_cache.lock().unwrap();
387            if let Some((caps, fetched_at, ttl)) = cache.get(authority) {
388                if fetched_at.elapsed() < *ttl {
389                    return Ok(caps.clone());
390                }
391            }
392        }
393        let (caps, response_ttl) = registry
394            .capabilities_with_ttl()
395            .await
396            .map_err(|e| match e {
397                AcdpError::Http(_) | AcdpError::KeyResolutionUnreachable(_) => {
398                    AcdpError::CrossRegistryResolutionFailed(format!(
399                        "could not reach registry '{authority}': {e}"
400                    ))
401                }
402                other => other,
403            })?;
404        // Clamp to the resolver-wide ceiling so a registry advertising
405        // an absurd `max-age` can't pin a stale doc indefinitely.
406        let ttl = response_ttl.min(self.options.capabilities_ttl);
407        let mut cache = self.caps_cache.lock().unwrap();
408        cache.insert(authority.to_string(), (caps.clone(), Instant::now(), ttl));
409        Ok(caps)
410    }
411
412    /// Return the capabilities document already cached for `authority`
413    /// from a prior walk, without fetching.
414    ///
415    /// `resolve`/`walk_derived_from` fetch and cache a foreign registry's
416    /// capabilities internally (via the private `capabilities_for`) but
417    /// never exposed the result, so a caller that also needs that document
418    /// (e.g. to check a profile the resolver itself didn't need) had no
419    /// way to read it back and had to issue a second, duplicate fetch.
420    /// Returns `None` if the resolver has never cached an entry for this
421    /// authority, or if the cached entry's per-response TTL has elapsed —
422    /// this is a cache peek, not a fetch-or-refresh, so a stale entry is
423    /// reported as absent rather than silently returned.
424    pub fn cached_capabilities(&self, authority: &str) -> Option<CapabilitiesDocument> {
425        let cache = self.caps_cache.lock().unwrap();
426        cache
427            .get(authority)
428            .and_then(|(caps, fetched_at, ttl)| (fetched_at.elapsed() < *ttl).then(|| caps.clone()))
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    fn test_caps() -> CapabilitiesDocument {
437        serde_json::from_value(serde_json::json!({
438            "acdp_version": "0.4.0",
439            "registry_did": "did:web:registry.example.com",
440            "supported_signature_algorithms": ["ed25519"],
441            "supported_did_methods": ["did:web"],
442            "profiles": ["acdp-registry-core"],
443            "limits": {"max_payload_bytes": 1_048_576, "max_embedded_bytes": 65536},
444        }))
445        .unwrap()
446    }
447
448    #[test]
449    fn cached_capabilities_returns_none_when_never_fetched() {
450        let resolver = CrossRegistryResolver::new();
451        assert!(resolver
452            .cached_capabilities("registry.example.com")
453            .is_none());
454    }
455
456    #[test]
457    fn cached_capabilities_returns_fresh_entry_without_fetching() {
458        let resolver = CrossRegistryResolver::new();
459        resolver.caps_cache.lock().unwrap().insert(
460            "registry.example.com".to_string(),
461            (test_caps(), Instant::now(), Duration::from_secs(300)),
462        );
463        let caps = resolver
464            .cached_capabilities("registry.example.com")
465            .expect("entry was just seeded fresh");
466        assert_eq!(caps.registry_did, "did:web:registry.example.com");
467    }
468
469    #[test]
470    fn cached_capabilities_reports_expired_entry_as_absent() {
471        let resolver = CrossRegistryResolver::new();
472        // `checked_sub` avoids a debug-mode underflow panic if the test
473        // runs within 60s of process start.
474        let long_ago = Instant::now()
475            .checked_sub(Duration::from_secs(60))
476            .expect("test host uptime exceeds 60s");
477        resolver.caps_cache.lock().unwrap().insert(
478            "registry.example.com".to_string(),
479            (test_caps(), long_ago, Duration::from_secs(1)),
480        );
481        assert!(resolver
482            .cached_capabilities("registry.example.com")
483            .is_none());
484    }
485
486    #[test]
487    fn allowlist_rejects_outside_authorities() {
488        let resolver =
489            CrossRegistryResolver::new().with_allowlist(["registry.example.com".to_string()]);
490        let err = resolver.check_allowlist("evil.com").unwrap_err();
491        assert!(matches!(err, AcdpError::CrossRegistryResolutionFailed(_)));
492        resolver.check_allowlist("registry.example.com").unwrap();
493    }
494
495    #[test]
496    fn options_default_values_match_doc() {
497        let o = ResolverOptions::default();
498        assert_eq!(o.max_depth, 10);
499        assert_eq!(o.max_nodes, 100);
500        assert_eq!(o.max_fanout, 32);
501        assert_eq!(o.total_timeout, Duration::from_secs(30));
502        assert_eq!(o.capabilities_ttl, Duration::from_secs(300));
503    }
504
505    #[test]
506    fn with_options_replaces_full_struct() {
507        let r = CrossRegistryResolver::new().with_options(ResolverOptions {
508            max_depth: 3,
509            max_nodes: 7,
510            max_fanout: 2,
511            total_timeout: Duration::from_secs(5),
512            capabilities_ttl: Duration::from_secs(60),
513        });
514        assert_eq!(r.options().max_depth, 3);
515        assert_eq!(r.options().max_nodes, 7);
516        assert_eq!(r.options().max_fanout, 2);
517    }
518
519    #[test]
520    fn cycle_detection_short_circuits() {
521        let _resolver = CrossRegistryResolver::new();
522        let mut seen: HashSet<String> = HashSet::new();
523        let id = "acdp://r/12345678-1234-4321-8123-123456781234".to_string();
524        assert!(seen.insert(id.clone()));
525        assert!(!seen.insert(id));
526    }
527}