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::{
23    ReceiptPolicy, RegistryClient, RevocationCache, RevocationPolicy, VerificationPolicy,
24    VerifiedContext,
25};
26use acdp_did::WebResolver;
27use acdp_primitives::error::AcdpError;
28use acdp_safe_http::SsrfPolicy;
29use acdp_types::body::Body;
30use acdp_types::primitives::CtxId;
31use acdp_types::CapabilitiesDocument;
32
33/// Per-walk and per-resolve safety options.
34///
35/// Defaults are tuned for RFC-ACDP-0006 §7.4 / §7.5 — they bound a walk
36/// even when the producer fabricates `derived_from` lists pointing into a
37/// foreign registry's pathological lineage graph.
38#[derive(Debug, Clone)]
39pub struct ResolverOptions {
40    /// Per-edge maximum depth (default 10).
41    pub max_depth: usize,
42    /// Total number of contexts the walk may verify (default 100). Acts
43    /// as a hard ceiling even when individual hops respect `max_depth`.
44    pub max_nodes: usize,
45    /// Maximum `derived_from` count permitted on any single context the
46    /// walker visits (default 32). A context that lists more parents is
47    /// either malformed or hostile — short-circuit before fanning out.
48    pub max_fanout: usize,
49    /// Wall-clock budget for the entire walk (default 30 s). Wraps
50    /// [`CrossRegistryResolver::walk_derived_from`] in `tokio::time::timeout`.
51    pub total_timeout: Duration,
52    /// How long to cache a foreign registry's capabilities document
53    /// before re-fetching (default 5 min). Avoids hammering the foreign
54    /// `/.well-known/acdp.json` on every hop.
55    pub capabilities_ttl: Duration,
56}
57
58impl Default for ResolverOptions {
59    fn default() -> Self {
60        Self {
61            max_depth: 10,
62            max_nodes: 100,
63            max_fanout: 32,
64            total_timeout: Duration::from_secs(30),
65            capabilities_ttl: Duration::from_secs(300),
66        }
67    }
68}
69
70/// Resolver for cross-registry references.
71///
72/// Holds a [`WebResolver`] for DID lookups and caches a [`RegistryClient`]
73/// + capabilities document per authority for the lifetime of the resolver.
74///
75/// The [`SsrfPolicy`] is consulted on every URL the resolver constructs
76/// (RFC-ACDP-0006 §7.1, §7.2).
77///
78/// # Revocation discovery (issue #260)
79///
80/// [`Self::with_revocation_policy`] injects a [`RevocationPolicy`] into
81/// every node this resolver verifies, closing the LIM-1 gap recorded in
82/// `crate::verified`'s `RevocationPolicy` rustdoc: before this, neither a
83/// caller-supplied `known` set nor `discover` could reach a
84/// cross-registry walk at all. [`Self::with_revocation_cache`] additionally
85/// shares one [`RevocationCache`] handle across every per-authority
86/// client the resolver builds (or is seeded with) — see
87/// [`Self::walk_derived_from`]'s doc for the walk-scoped default this
88/// replaces.
89pub struct CrossRegistryResolver {
90    did_resolver: WebResolver,
91    options: ResolverOptions,
92    allowlist: Option<HashSet<String>>,
93    ssrf_policy: SsrfPolicy,
94    // Per-authority caches. Mutex-guarded for interior mutability across
95    // the immutable `&self` API surface; contention is low since
96    // authorities are few per walk.
97    client_cache: Mutex<HashMap<String, RegistryClient>>,
98    /// Per-authority capabilities cache. The `Duration` is the
99    /// per-response TTL parsed from `Cache-Control: max-age=N` (capped
100    /// at 3600s per RFC-ACDP-0006 §4.2). Replaces an earlier shape
101    /// that used the resolver-wide `capabilities_ttl` for every entry,
102    /// ignoring the registry's own cache hint (BUG-09).
103    caps_cache: Mutex<HashMap<String, (CapabilitiesDocument, Instant, Duration)>>,
104    /// Injected via [`Self::with_revocation_policy`]. Default
105    /// [`RevocationPolicy::default`] (empty `known`, `discover: None`) is
106    /// inert, so a resolver built without calling this setter behaves
107    /// byte-identically to before this field existed (issue #260 AC7).
108    /// Deliberately `RevocationPolicy`, never `VerificationPolicy` — see
109    /// [`Self::with_revocation_policy`]'s doc for why `receipts` is the
110    /// one field this injection point can never carry.
111    revocation_policy: RevocationPolicy,
112    /// Injected via [`Self::with_revocation_cache`]. `None` (the default)
113    /// means [`Self::walk_derived_from`] creates a fresh, walk-scoped
114    /// cache for each call instead of reusing one across walks — see
115    /// that method's doc.
116    revocation_cache: Option<RevocationCache>,
117}
118
119impl Default for CrossRegistryResolver {
120    fn default() -> Self {
121        Self::new()
122    }
123}
124
125impl CrossRegistryResolver {
126    /// Build a resolver with default settings: no allowlist, depth 10,
127    /// HTTPS-only / no IP literals SSRF policy.
128    pub fn new() -> Self {
129        Self {
130            did_resolver: WebResolver::new(),
131            options: ResolverOptions::default(),
132            allowlist: None,
133            ssrf_policy: SsrfPolicy::default(),
134            client_cache: Mutex::new(HashMap::new()),
135            caps_cache: Mutex::new(HashMap::new()),
136            revocation_policy: RevocationPolicy::default(),
137            revocation_cache: None,
138        }
139    }
140
141    /// Override the [`SsrfPolicy`] applied to outbound URLs.
142    ///
143    /// Useful for test environments that need to allow `http://` or
144    /// IP-literal hosts. Production deployments SHOULD keep the default.
145    pub fn with_ssrf_policy(mut self, policy: SsrfPolicy) -> Self {
146        self.ssrf_policy = policy;
147        self
148    }
149
150    /// Cap the number of `derived_from` hops walked in a single
151    /// [`Self::walk_derived_from`] call.
152    pub fn with_max_depth(mut self, depth: usize) -> Self {
153        self.options.max_depth = depth;
154        self
155    }
156
157    /// Replace the complete options struct (overrides every individual
158    /// `with_*` setter that wasn't already applied).
159    pub fn with_options(mut self, options: ResolverOptions) -> Self {
160        self.options = options;
161        self
162    }
163
164    /// Borrow the active options. Useful for tests + telemetry.
165    pub fn options(&self) -> &ResolverOptions {
166        &self.options
167    }
168
169    /// Inject a [`RevocationPolicy`] into every node this resolver
170    /// verifies (issue #260, closing LIM-1). Default
171    /// [`RevocationPolicy::default`] (empty `known`, `discover: None`) is
172    /// inert — this method is the opt-in.
173    ///
174    /// Deliberately `RevocationPolicy`, never `VerificationPolicy`: this
175    /// resolver derives [`VerificationPolicy::receipts`] per node from
176    /// that node's upstream-advertised capabilities (`Require` iff the
177    /// upstream claims `acdp-registry-receipts`) — a capability-dependent
178    /// escalation a caller cannot express statically, since a walk can
179    /// visit authorities it does not know in advance. Accepting a full
180    /// `VerificationPolicy` here would have no coherent semantics:
181    /// honoring it verbatim would let a caller unknowingly strip
182    /// `Require` on a receipts-capable upstream (a downgrade primitive),
183    /// while silently overriding it would violate the "uniform policy"
184    /// contract every other policy-taking entry point upholds. `known`
185    /// travels with this policy, so a caller can enforce a
186    /// pre-discovered revocation set across a whole walk without
187    /// enabling live discovery at all.
188    ///
189    /// Not consulted by [`Self::resolve`]/[`Self::walk_derived_from`]'s
190    /// safety limits ([`ResolverOptions`]) — see that struct's doc for
191    /// why revocation configuration does not live there either.
192    pub fn with_revocation_policy(mut self, policy: RevocationPolicy) -> Self {
193        self.revocation_policy = policy;
194        self
195    }
196
197    /// Inject a [`RevocationCache`] to share across every per-authority
198    /// client this resolver builds or is seeded with (issue #260),
199    /// instead of the fresh, walk-scoped cache
200    /// [`Self::walk_derived_from`] otherwise creates for each call. Use
201    /// this when discovery should stay warm ACROSS separate walks
202    /// against a long-lived resolver, at the cost of cached absence
203    /// (a freshness marker) potentially outliving any single walk — see
204    /// [`Self::walk_derived_from`]'s doc for the default this replaces
205    /// and the exposure it carries. This cache's effective
206    /// `RevocationDiscovery::freshness` is always the caller's own
207    /// (MATERIAL-3, fresh-Opus whole-wave review) — `walk_derived_from`
208    /// never derives a freshness value for a caller-supplied cache.
209    pub fn with_revocation_cache(mut self, cache: RevocationCache) -> Self {
210        self.revocation_cache = Some(cache);
211        self
212    }
213
214    /// Borrow the active revocation policy. Useful for tests + telemetry.
215    pub fn revocation_policy(&self) -> &RevocationPolicy {
216        &self.revocation_policy
217    }
218
219    /// Override the [`WebResolver`] used for DID document lookups.
220    ///
221    /// Primary use is supplying a `WebResolver::with_root_cert_pem`
222    /// instance in tests so a self-signed mock can answer DID-document
223    /// requests for `did:web:localhost%3A<port>`. Production callers do
224    /// not need this — the default resolver trusts the system CA bundle.
225    pub fn with_did_resolver(mut self, resolver: WebResolver) -> Self {
226        self.did_resolver = resolver;
227        self
228    }
229
230    /// Pre-populate the per-authority [`RegistryClient`] cache.
231    ///
232    /// Primary use is the conformance harness: tests supply a client
233    /// whose HTTP layer trusts the in-process TLS server's self-signed
234    /// root certificate (via [`RegistryClient::with_root_cert_pem`]), so
235    /// the resolver hits the mock instead of attempting a real network
236    /// call. The seeded client wins over the lazy pin-once
237    /// `RegistryClient::builder(..).pinned(true)` client that
238    /// [`Self::resolve`] would otherwise build on first access.
239    pub fn seed_client(&self, authority: impl Into<String>, client: RegistryClient) {
240        self.client_cache
241            .lock()
242            .unwrap()
243            .insert(authority.into(), client);
244    }
245
246    /// Restrict cross-registry resolution to a fixed set of authorities
247    /// (lowercase DNS hostnames). When set, any reference outside the
248    /// allowlist is rejected with [`AcdpError::CrossRegistryResolutionFailed`].
249    pub fn with_allowlist<I, S>(mut self, authorities: I) -> Self
250    where
251        I: IntoIterator<Item = S>,
252        S: Into<String>,
253    {
254        self.allowlist = Some(authorities.into_iter().map(Into::into).collect());
255        self
256    }
257
258    /// Resolve a single cross-registry [`CtxId`] end-to-end.
259    ///
260    /// Steps 1–7 of RFC-ACDP-0006 §4.1: parse, fetch capabilities,
261    /// verify the registry DID *and* its DID document's web binding,
262    /// retrieve, recompute hash, verify signature, and (step 7,
263    /// NORMATIVE) bind the resolved identity — reached through
264    /// `fetch_with_policy`, which refuses a served body whose `ctx_id`
265    /// is not the one requested. The [`SsrfPolicy`] is checked first so
266    /// a hostile authority cannot drive an internal-network request.
267    ///
268    /// Applies [`Self::revocation_policy`] (issue #260). If a
269    /// [`RevocationCache`] was injected via [`Self::with_revocation_cache`],
270    /// it is attached to the per-authority client used here,
271    /// fill-if-absent: a client that already carries its own cache (e.g.
272    /// via [`Self::seed_client`]) keeps it. Called directly, outside a
273    /// [`Self::walk_derived_from`] call, there is no walk-scoped cache to
274    /// fall back on — a bare `resolve()` gets seeding/suppression only
275    /// when [`Self::with_revocation_cache`] was called explicitly, and in
276    /// that case the caller's own `RevocationDiscovery::freshness` governs
277    /// unmodified (never the derived-from-`total_timeout` value
278    /// [`Self::walk_derived_from`] applies to its own walk-scoped cache —
279    /// see that method's doc). It is also bounded only by
280    /// `RevocationDiscovery::total_timeout` (`crate::RevocationDiscovery`)
281    /// when `discover` is set — [`ResolverOptions::total_timeout`] wraps
282    /// [`Self::walk_derived_from`], not this method.
283    pub async fn resolve(&self, ctx_id: &CtxId) -> Result<VerifiedContext, AcdpError> {
284        self.resolve_inner(ctx_id, self.revocation_cache.as_ref(), None)
285            .await
286    }
287
288    /// Shared implementation behind [`Self::resolve`] and the per-node
289    /// calls [`Self::walk_derived_from_inner`] makes. `cache` is either
290    /// the resolver-wide handle ([`Self::with_revocation_cache`]) or a
291    /// fresh, walk-scoped one built once per [`Self::walk_derived_from`]
292    /// call — see that method's doc.
293    ///
294    /// `walk_scoped_freshness`, when `Some`, overrides
295    /// `self.revocation_policy.discover`'s `freshness` for this call only
296    /// (MATERIAL-3, fresh-Opus whole-wave review). It is `Some` ONLY when
297    /// `walk_derived_from` built its own, resolver-constructed walk-scoped
298    /// cache (i.e. the caller did not supply one via
299    /// [`Self::with_revocation_cache`]) — never for a caller-supplied
300    /// cache, and never for a bare [`Self::resolve`] call, both of which
301    /// pass `None` and so leave the caller's own `freshness` (including
302    /// `Duration::ZERO`) untouched.
303    async fn resolve_inner(
304        &self,
305        ctx_id: &CtxId,
306        cache: Option<&RevocationCache>,
307        walk_scoped_freshness: Option<Duration>,
308    ) -> Result<VerifiedContext, AcdpError> {
309        let parsed = CtxId::parse(ctx_id.as_str())?;
310        let authority = parsed.authority().to_string();
311        self.check_allowlist(&authority)?;
312
313        // RFC-ACDP-0006 §7: SSRF policy on the outbound base URL.
314        let base = format!("https://{authority}");
315        self.ssrf_policy
316            .check_url(&base)
317            .map_err(|e| AcdpError::CrossRegistryResolutionFailed(format!("SSRF policy: {e}")))?;
318
319        // Cached client (and capabilities) per authority.
320        let registry = self.client_for(&authority, &base).await?;
321        // Issue #260: fill-if-absent. A client returned by `client_for`
322        // (built, cached, or seeded via `Self::seed_client`) that carries
323        // no `RevocationCache` of its own gets this call's `cache`
324        // attached; one that already carries a cache (a caller-seeded
325        // client wiring its own) keeps it unchanged. Precedence:
326        // explicit-on-client > explicit-on-resolver/walk > none. This is
327        // what makes vantage binding fall out for free: each authority's
328        // client is attached (or already carries) a cache independently,
329        // matching `RevocationCache`'s own per-origin scoping
330        // (RFC-ACDP-0014 §6).
331        let registry = match cache {
332            Some(cache) if registry.revocation_cache().is_none() => {
333                registry.with_revocation_cache(cache.clone())
334            }
335            _ => registry,
336        };
337        let caps = self.capabilities_for(&authority, &registry).await?;
338
339        // Step 3a: capabilities.registry_did MUST be `did:web:<authority>`.
340        // BUG-06: percent-encode `:` for host:port authorities so the
341        // expected DID round-trips with `authority_to_did_web`.
342        let expected_did = acdp_did::authority_to_did_web(&authority);
343        if caps.registry_did != expected_did {
344            return Err(AcdpError::CrossRegistryResolutionFailed(format!(
345                "registry DID '{}' does not match expected '{expected_did}'",
346                caps.registry_did
347            )));
348        }
349
350        // Step 3b (RFC-ACDP-0006 §4.1 step 3): resolve the registry's
351        // DID document and confirm the web binding matches `<authority>`.
352        let registry_doc = self
353            .did_resolver
354            .resolve(&caps.registry_did)
355            .await
356            .map_err(|e| {
357                AcdpError::CrossRegistryResolutionFailed(format!(
358                    "could not resolve registry DID document for '{}': {e}",
359                    caps.registry_did
360                ))
361            })?;
362        if registry_doc.id != caps.registry_did {
363            return Err(AcdpError::CrossRegistryResolutionFailed(format!(
364                "registry DID document `id` '{}' does not match capabilities.registry_did '{}'",
365                registry_doc.id, caps.registry_did
366            )));
367        }
368
369        // Steps 4–6: retrieve + verify. fed-009 / RFC-ACDP-0010 §7+§11:
370        // an upstream advertising `acdp-registry-receipts` MUST always
371        // serve a receipt — absence is a registry fault (`invalid_receipt`),
372        // not a degraded mode — so the policy escalates to `Require` for
373        // such upstreams. Receipt-less upstreams proceed under the
374        // v0.1.0 trust model (receipt verified only if one is present).
375        //
376        // Issue #260: `revocations` is injected from `self.revocation_policy`
377        // — never a caller-supplied `VerificationPolicy` (no injection
378        // point accepts one; see `Self::with_revocation_policy`'s doc for
379        // why `receipts`, derived per-node just below, is the one field
380        // that stays off-limits).
381        //
382        // MATERIAL-3: when this node's cache is the resolver's own
383        // walk-scoped one (`walk_scoped_freshness: Some(..)`), override
384        // `discover.freshness` to the value `walk_derived_from` derived
385        // from `ResolverOptions::total_timeout`, regardless of whatever
386        // `freshness` the caller's `RevocationPolicy` otherwise carries.
387        // This is safe specifically because the walk-scoped cache dies
388        // with this call: no marker minted under the derived freshness
389        // can outlive a window `total_timeout` does not already bound.
390        // A caller-supplied cache (`walk_scoped_freshness: None`) is
391        // untouched — its own `freshness` (including `Duration::ZERO`)
392        // governs, because that handle may outlive this walk and its
393        // staleness exposure is the caller's to choose.
394        let mut revocations = self.revocation_policy.clone();
395        if let Some(freshness) = walk_scoped_freshness {
396            if let Some(discovery) = revocations.discover.as_mut() {
397                discovery.freshness = freshness;
398            }
399        }
400        let mut policy = VerificationPolicy {
401            revocations,
402            ..VerificationPolicy::default()
403        };
404        if caps.claims_profile(acdp_types::profile::Profile::RegistryReceipts) {
405            policy.receipts = ReceiptPolicy::Require;
406        }
407        VerifiedContext::fetch_with_policy(&registry, &self.did_resolver, &parsed, &policy).await
408    }
409
410    /// Walk the `derived_from` graph rooted at `body` with cycle detection,
411    /// a per-edge depth cap of [`ResolverOptions::max_depth`], a total-
412    /// nodes cap of `max_nodes`, a per-context fanout cap of `max_fanout`,
413    /// and a wall-clock `total_timeout`. Returns each verified ancestor
414    /// (excluding the root). Breadth-first; closer ancestors are returned
415    /// first.
416    ///
417    /// # Revocation discovery is walk-scoped by default (issue #260)
418    ///
419    /// When [`Self::revocation_policy`] has `discover` set and no
420    /// [`RevocationCache`] was injected via [`Self::with_revocation_cache`],
421    /// this call creates a **fresh cache for this call only** and shares
422    /// it across every node the walk visits — so discovery for a given
423    /// `(authority, trust class)` runs at most once per walk regardless
424    /// of `max_nodes`, rather than once per node, **on default
425    /// configuration**. No cached absence outlives the call.
426    ///
427    /// The effective marker freshness differs by cache origin
428    /// (MATERIAL-3, fresh-Opus whole-wave review):
429    ///
430    /// - **This resolver-built, walk-scoped cache:** the effective
431    ///   `RevocationDiscovery::freshness` for every node in this walk is
432    ///   derived from [`ResolverOptions::total_timeout`], overriding
433    ///   whatever `freshness` [`Self::revocation_policy`] otherwise
434    ///   carries (including the type default `Duration::ZERO`). This is
435    ///   what makes the "runs at most once per walk" guarantee above
436    ///   hold on genuine defaults, with no caller action required. It is
437    ///   safe because the cache — and so any marker minted into it —
438    ///   cannot outlive this call, and this call's own duration is
439    ///   already bounded by `total_timeout`.
440    /// - **A caller-supplied cache** ([`Self::with_revocation_cache`]):
441    ///   the caller's own `RevocationDiscovery::freshness` governs,
442    ///   unmodified. In particular `Duration::ZERO` (the type default)
443    ///   suppresses nothing — discovery still runs once per node. This
444    ///   handle may outlive any single walk (that is the point of
445    ///   supplying one explicitly), so its staleness exposure is the
446    ///   caller's to choose, never derived for it.
447    ///
448    /// A caller who explicitly wants discovery to stay warm ACROSS
449    /// separate walks (at the cost of a marker that can outlive any one
450    /// of them, governed by their own `freshness`) opts in via
451    /// [`Self::with_revocation_cache`], which is then reused here
452    /// instead of a fresh per-call cache.
453    ///
454    /// **The 30 s / 30 s default collision.** `RevocationDiscovery`'s
455    /// `total_timeout` defaults to 30 s, matching
456    /// [`ResolverOptions::total_timeout`]'s own default — but the two are
457    /// nested: this method wraps the whole walk in
458    /// `ResolverOptions::total_timeout`, and revocation discovery for
459    /// EACH node re-applies its own `total_timeout` inside that. On an
460    /// all-defaults configuration, one slow-but-not-yet-failed node's
461    /// discovery can consume the entire walk's budget. This fails
462    /// closed (the walk simply times out), so it is safe, but it is
463    /// surprising — set `discovery.total_timeout` well below
464    /// `ResolverOptions::total_timeout`, or raise the latter, if you
465    /// enable discovery here. The walk-scoped cache substantially
466    /// mitigates this in practice, since a repeat node at the same
467    /// authority/class no longer re-runs discovery at all.
468    pub async fn walk_derived_from(&self, body: &Body) -> Result<Vec<VerifiedContext>, AcdpError> {
469        let total_timeout = self.options.total_timeout;
470        // Issue #260: walk-scoped by default. `self.revocation_cache` is
471        // the caller's explicit opt-in to cross-walk sharing
472        // (`Self::with_revocation_cache`); absent that, build one fresh
473        // `RevocationCache` here, alive only for this call, and thread it
474        // into every node this walk resolves.
475        //
476        // MATERIAL-3: `walk_scoped_freshness` is `Some(total_timeout)`
477        // ONLY when this call built its own cache (no caller-supplied
478        // one) — that `Some` is what tells `resolve_inner` to override
479        // this walk's effective `RevocationDiscovery::freshness`. A
480        // caller-supplied cache gets `None` here, leaving its own
481        // `freshness` (governed entirely by the caller) untouched.
482        let (walk_cache, walk_scoped_freshness) = match &self.revocation_cache {
483            Some(cache) => (cache.clone(), None),
484            None => (RevocationCache::default(), Some(total_timeout)),
485        };
486        let fut = self.walk_derived_from_inner(body, &walk_cache, walk_scoped_freshness);
487        match tokio::time::timeout(total_timeout, fut).await {
488            Ok(res) => res,
489            Err(_) => Err(AcdpError::CrossRegistryResolutionFailed(format!(
490                "derived_from walk exceeded total_timeout={:?}",
491                total_timeout
492            ))),
493        }
494    }
495
496    async fn walk_derived_from_inner(
497        &self,
498        body: &Body,
499        cache: &RevocationCache,
500        walk_scoped_freshness: Option<Duration>,
501    ) -> Result<Vec<VerifiedContext>, AcdpError> {
502        let mut seen: HashSet<String> = HashSet::new();
503        seen.insert(body.ctx_id.0.clone());
504
505        if body.derived_from.len() > self.options.max_fanout {
506            return Err(AcdpError::CrossRegistryResolutionFailed(format!(
507                "root context {} has derived_from fanout {} > max_fanout={}",
508                body.ctx_id.0,
509                body.derived_from.len(),
510                self.options.max_fanout
511            )));
512        }
513
514        let mut results: Vec<VerifiedContext> = Vec::new();
515        let mut frontier: VecDeque<(CtxId, usize)> = body
516            .derived_from
517            .iter()
518            .map(|c| (c.clone(), 1usize))
519            .collect();
520
521        while let Some((next, depth)) = frontier.pop_front() {
522            if !seen.insert(next.0.clone()) {
523                continue; // cycle
524            }
525            if depth > self.options.max_depth {
526                return Err(AcdpError::CrossRegistryResolutionFailed(format!(
527                    "derived_from walk exceeded max_depth={} at {}",
528                    self.options.max_depth, next.0
529                )));
530            }
531            if results.len() >= self.options.max_nodes {
532                return Err(AcdpError::CrossRegistryResolutionFailed(format!(
533                    "derived_from walk exceeded max_nodes={} (last attempted: {})",
534                    self.options.max_nodes, next.0
535                )));
536            }
537            let verified = self
538                .resolve_inner(&next, Some(cache), walk_scoped_freshness)
539                .await?;
540            let parents = &verified.body().derived_from;
541            if parents.len() > self.options.max_fanout {
542                return Err(AcdpError::CrossRegistryResolutionFailed(format!(
543                    "context {} has derived_from fanout {} > max_fanout={}",
544                    next.0,
545                    parents.len(),
546                    self.options.max_fanout
547                )));
548            }
549            for parent in parents {
550                if !seen.contains(parent.as_str()) {
551                    frontier.push_back((parent.clone(), depth + 1));
552                }
553            }
554            results.push(verified);
555        }
556        Ok(results)
557    }
558
559    fn check_allowlist(&self, authority: &str) -> Result<(), AcdpError> {
560        if let Some(list) = &self.allowlist {
561            if !list.contains(authority) {
562                return Err(AcdpError::CrossRegistryResolutionFailed(format!(
563                    "authority '{authority}' is not on the resolver allowlist"
564                )));
565            }
566        }
567        Ok(())
568    }
569
570    /// Return a cached `RegistryClient` for the authority, building one
571    /// on first use. Reuse across hops avoids per-hop reqwest
572    /// connection-pool churn.
573    ///
574    /// SEC-01: the client is built via
575    /// `RegistryClient::builder(base).pinned(true)`, which resolves the
576    /// authority's DNS up-front, filters every resolved IP through the
577    /// resolver's [`SsrfPolicy`], and pins the connection to that
578    /// address. Without pinning a hostile `ctx_id`
579    /// authority (e.g. `internal-host.example.com` resolving to
580    /// `10.0.0.1` or `169.254.169.254`) would slip past the URL-syntax
581    /// `check_url` gate and reach an internal target. The seeded test
582    /// path ([`Self::seed_client`]) bypasses this constructor.
583    async fn client_for(&self, authority: &str, base: &str) -> Result<RegistryClient, AcdpError> {
584        {
585            let cache = self.client_cache.lock().unwrap();
586            if let Some(c) = cache.get(authority) {
587                return Ok(c.clone());
588            }
589        }
590        // Build with pin-once DNS resolution before taking the cache
591        // lock — the builder's `.build()` is async (it resolves the
592        // authority up front) and the cache mutex must not be held
593        // across the await.
594        let client = RegistryClient::builder(base)
595            .pinned(true)
596            .ssrf_policy(self.ssrf_policy.clone())
597            .build()
598            .await?;
599        let mut cache = self.client_cache.lock().unwrap();
600        Ok(cache.entry(authority.to_string()).or_insert(client).clone())
601    }
602
603    /// Return the cached capabilities for `authority`, fetching when
604    /// the entry is missing or its per-response TTL has elapsed.
605    ///
606    /// BUG-09: TTL comes from the response's `Cache-Control: max-age=N`
607    /// (clamped to `[1s, ResolverOptions::capabilities_ttl]` so the
608    /// resolver-wide ceiling still applies) rather than a fixed value.
609    /// A registry serving `Cache-Control: max-age=60` is honored; one
610    /// serving no `Cache-Control` falls back to the
611    /// [`RegistryClient::capabilities_with_ttl`] default (300s).
612    async fn capabilities_for(
613        &self,
614        authority: &str,
615        registry: &RegistryClient,
616    ) -> Result<CapabilitiesDocument, AcdpError> {
617        // Fast path: cache hit + within per-response TTL.
618        {
619            let cache = self.caps_cache.lock().unwrap();
620            if let Some((caps, fetched_at, ttl)) = cache.get(authority) {
621                if fetched_at.elapsed() < *ttl {
622                    return Ok(caps.clone());
623                }
624            }
625        }
626        let (caps, response_ttl) = registry
627            .capabilities_with_ttl()
628            .await
629            .map_err(|e| match e {
630                AcdpError::Http(_) | AcdpError::KeyResolutionUnreachable(_) => {
631                    AcdpError::CrossRegistryResolutionFailed(format!(
632                        "could not reach registry '{authority}': {e}"
633                    ))
634                }
635                other => other,
636            })?;
637        // Clamp to the resolver-wide ceiling so a registry advertising
638        // an absurd `max-age` can't pin a stale doc indefinitely.
639        let ttl = response_ttl.min(self.options.capabilities_ttl);
640        let mut cache = self.caps_cache.lock().unwrap();
641        cache.insert(authority.to_string(), (caps.clone(), Instant::now(), ttl));
642        Ok(caps)
643    }
644
645    /// Return the capabilities document already cached for `authority`
646    /// from a prior walk, without fetching.
647    ///
648    /// `resolve`/`walk_derived_from` fetch and cache a foreign registry's
649    /// capabilities internally (via the private `capabilities_for`) but
650    /// never exposed the result, so a caller that also needs that document
651    /// (e.g. to check a profile the resolver itself didn't need) had no
652    /// way to read it back and had to issue a second, duplicate fetch.
653    /// Returns `None` if the resolver has never cached an entry for this
654    /// authority, or if the cached entry's per-response TTL has elapsed —
655    /// this is a cache peek, not a fetch-or-refresh, so a stale entry is
656    /// reported as absent rather than silently returned.
657    pub fn cached_capabilities(&self, authority: &str) -> Option<CapabilitiesDocument> {
658        let cache = self.caps_cache.lock().unwrap();
659        cache
660            .get(authority)
661            .and_then(|(caps, fetched_at, ttl)| (fetched_at.elapsed() < *ttl).then(|| caps.clone()))
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    fn test_caps() -> CapabilitiesDocument {
670        serde_json::from_value(serde_json::json!({
671            "acdp_version": "0.4.0",
672            "registry_did": "did:web:registry.example.com",
673            "supported_signature_algorithms": ["ed25519"],
674            "supported_did_methods": ["did:web"],
675            "profiles": ["acdp-registry-core"],
676            "limits": {"max_payload_bytes": 1_048_576, "max_embedded_bytes": 65536},
677        }))
678        .unwrap()
679    }
680
681    #[test]
682    fn cached_capabilities_returns_none_when_never_fetched() {
683        let resolver = CrossRegistryResolver::new();
684        assert!(resolver
685            .cached_capabilities("registry.example.com")
686            .is_none());
687    }
688
689    #[test]
690    fn cached_capabilities_returns_fresh_entry_without_fetching() {
691        let resolver = CrossRegistryResolver::new();
692        resolver.caps_cache.lock().unwrap().insert(
693            "registry.example.com".to_string(),
694            (test_caps(), Instant::now(), Duration::from_secs(300)),
695        );
696        let caps = resolver
697            .cached_capabilities("registry.example.com")
698            .expect("entry was just seeded fresh");
699        assert_eq!(caps.registry_did, "did:web:registry.example.com");
700    }
701
702    #[test]
703    fn cached_capabilities_reports_expired_entry_as_absent() {
704        let resolver = CrossRegistryResolver::new();
705        // `checked_sub` avoids a debug-mode underflow panic if the test
706        // runs within 60s of process start.
707        let long_ago = Instant::now()
708            .checked_sub(Duration::from_secs(60))
709            .expect("test host uptime exceeds 60s");
710        resolver.caps_cache.lock().unwrap().insert(
711            "registry.example.com".to_string(),
712            (test_caps(), long_ago, Duration::from_secs(1)),
713        );
714        assert!(resolver
715            .cached_capabilities("registry.example.com")
716            .is_none());
717    }
718
719    #[test]
720    fn allowlist_rejects_outside_authorities() {
721        let resolver =
722            CrossRegistryResolver::new().with_allowlist(["registry.example.com".to_string()]);
723        let err = resolver.check_allowlist("evil.com").unwrap_err();
724        assert!(matches!(err, AcdpError::CrossRegistryResolutionFailed(_)));
725        resolver.check_allowlist("registry.example.com").unwrap();
726    }
727
728    #[test]
729    fn options_default_values_match_doc() {
730        let o = ResolverOptions::default();
731        assert_eq!(o.max_depth, 10);
732        assert_eq!(o.max_nodes, 100);
733        assert_eq!(o.max_fanout, 32);
734        assert_eq!(o.total_timeout, Duration::from_secs(30));
735        assert_eq!(o.capabilities_ttl, Duration::from_secs(300));
736    }
737
738    #[test]
739    fn with_options_replaces_full_struct() {
740        let r = CrossRegistryResolver::new().with_options(ResolverOptions {
741            max_depth: 3,
742            max_nodes: 7,
743            max_fanout: 2,
744            total_timeout: Duration::from_secs(5),
745            capabilities_ttl: Duration::from_secs(60),
746        });
747        assert_eq!(r.options().max_depth, 3);
748        assert_eq!(r.options().max_nodes, 7);
749        assert_eq!(r.options().max_fanout, 2);
750    }
751
752    #[test]
753    fn cycle_detection_short_circuits() {
754        let _resolver = CrossRegistryResolver::new();
755        let mut seen: HashSet<String> = HashSet::new();
756        let id = "acdp://r/12345678-1234-4321-8123-123456781234".to_string();
757        assert!(seen.insert(id.clone()));
758        assert!(!seen.insert(id));
759    }
760}