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::{Arc, Mutex};
20use std::time::{Duration, Instant};
21
22use crate::{
23    AuthProvider, ReceiptPolicy, RegistryClient, RevocationCache, RevocationPolicy,
24    VerificationPolicy, 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/// Upper bound on the per-authority [`RegistryClient`]s a
71/// [`CrossRegistryResolver`] keeps. `derived_from` edges are
72/// producer-chosen, so a long-lived resolver walking hostile graphs would
73/// otherwise grow without bound. At capacity the least recently built
74/// client is dropped (a seeded client never is); losing one costs only a
75/// rebuild on the next hop to that authority.
76pub const MAX_CACHED_CLIENTS: usize = 256;
77
78/// One cached per-authority client.
79struct CachedClient {
80    client: RegistryClient,
81    /// Insertion order, for eviction at [`MAX_CACHED_CLIENTS`].
82    built: u64,
83    /// Supplied through [`CrossRegistryResolver::seed_client`]: the
84    /// caller's explicit wiring, never evicted.
85    seeded: bool,
86}
87
88/// Resolver for cross-registry references.
89///
90/// Holds a [`WebResolver`] for DID lookups and caches a [`RegistryClient`]
91/// + capabilities document per authority for the lifetime of the resolver.
92///
93/// The [`SsrfPolicy`] is consulted on every URL the resolver constructs
94/// (RFC-ACDP-0006 §7.1, §7.2).
95///
96/// # Revocation discovery (issue #260)
97///
98/// [`Self::with_revocation_policy`] injects a [`RevocationPolicy`] into
99/// every node this resolver verifies, closing the LIM-1 gap recorded in
100/// `crate::verified`'s `RevocationPolicy` rustdoc: before this, neither a
101/// caller-supplied `known` set nor `discover` could reach a
102/// cross-registry walk at all. [`Self::with_revocation_cache`] additionally
103/// shares one [`RevocationCache`] handle across every per-authority
104/// client the resolver builds (or is seeded with) — see
105/// [`Self::walk_derived_from`]'s doc for the walk-scoped default this
106/// replaces.
107pub struct CrossRegistryResolver {
108    did_resolver: WebResolver,
109    options: ResolverOptions,
110    allowlist: Option<HashSet<String>>,
111    ssrf_policy: SsrfPolicy,
112    // Per-authority caches. Mutex-guarded for interior mutability across
113    // the immutable `&self` API surface; contention is low since
114    // authorities are few per walk.
115    client_cache: Mutex<ClientCache>,
116    /// Per-authority capabilities cache. The `Duration` is the
117    /// per-response TTL parsed from `Cache-Control: max-age=N` (capped
118    /// at 3600s per RFC-ACDP-0006 §4.2). Replaces an earlier shape
119    /// that used the resolver-wide `capabilities_ttl` for every entry,
120    /// ignoring the registry's own cache hint (BUG-09).
121    caps_cache: Mutex<HashMap<String, (CapabilitiesDocument, Instant, Duration)>>,
122    /// Injected via [`Self::with_revocation_policy`]. Default
123    /// [`RevocationPolicy::default`] (empty `known`, `discover: None`) is
124    /// inert, so a resolver built without calling this setter behaves
125    /// byte-identically to before this field existed (issue #260 AC7).
126    /// Deliberately `RevocationPolicy`, never `VerificationPolicy` — see
127    /// [`Self::with_revocation_policy`]'s doc for why `receipts` is the
128    /// one field this injection point can never carry.
129    revocation_policy: RevocationPolicy,
130    /// Injected via [`Self::with_revocation_cache`]. `None` (the default)
131    /// means [`Self::walk_derived_from`] creates a fresh, walk-scoped
132    /// cache for each call instead of reusing one across walks — see
133    /// that method's doc.
134    revocation_cache: Option<RevocationCache>,
135    /// Injected via [`Self::with_auth_provider`]: read credentials per
136    /// authority (RFC-ACDP-0008 §6.2), attached fill-if-absent to every
137    /// per-authority client.
138    auth: Option<Arc<dyn AuthProvider>>,
139}
140
141/// The per-authority client cache, bounded at [`MAX_CACHED_CLIENTS`].
142#[derive(Default)]
143struct ClientCache {
144    entries: HashMap<String, CachedClient>,
145    next: u64,
146}
147
148impl ClientCache {
149    fn get(&self, authority: &str) -> Option<RegistryClient> {
150        self.entries.get(authority).map(|e| e.client.clone())
151    }
152
153    /// Insert unless present (a racing build loses to the first); a
154    /// `seeded` insert always replaces. Returns the client now cached.
155    fn insert(
156        &mut self,
157        authority: String,
158        client: RegistryClient,
159        seeded: bool,
160    ) -> RegistryClient {
161        if !seeded {
162            if let Some(existing) = self.entries.get(&authority) {
163                return existing.client.clone();
164            }
165            if self.entries.len() >= MAX_CACHED_CLIENTS {
166                let oldest = self
167                    .entries
168                    .iter()
169                    .filter(|(_, e)| !e.seeded)
170                    .min_by_key(|(_, e)| e.built)
171                    .map(|(k, _)| k.clone());
172                if let Some(oldest) = oldest {
173                    self.entries.remove(&oldest);
174                }
175            }
176        }
177        self.next += 1;
178        self.entries.insert(
179            authority,
180            CachedClient {
181                client: client.clone(),
182                built: self.next,
183                seeded,
184            },
185        );
186        client
187    }
188}
189
190impl Default for CrossRegistryResolver {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196impl CrossRegistryResolver {
197    /// Build a resolver with default settings: no allowlist, depth 10,
198    /// HTTPS-only / no IP literals SSRF policy.
199    pub fn new() -> Self {
200        Self {
201            did_resolver: WebResolver::new(),
202            options: ResolverOptions::default(),
203            allowlist: None,
204            ssrf_policy: SsrfPolicy::default(),
205            client_cache: Mutex::new(ClientCache::default()),
206            caps_cache: Mutex::new(HashMap::new()),
207            revocation_policy: RevocationPolicy::default(),
208            revocation_cache: None,
209            auth: None,
210        }
211    }
212
213    /// Override the [`SsrfPolicy`] applied to outbound URLs.
214    ///
215    /// Useful for test environments that need to allow `http://` or
216    /// IP-literal hosts. Production deployments SHOULD keep the default.
217    pub fn with_ssrf_policy(mut self, policy: SsrfPolicy) -> Self {
218        self.ssrf_policy = policy;
219        self
220    }
221
222    /// Cap the number of `derived_from` hops walked in a single
223    /// [`Self::walk_derived_from`] call.
224    pub fn with_max_depth(mut self, depth: usize) -> Self {
225        self.options.max_depth = depth;
226        self
227    }
228
229    /// Replace the complete options struct (overrides every individual
230    /// `with_*` setter that wasn't already applied).
231    pub fn with_options(mut self, options: ResolverOptions) -> Self {
232        self.options = options;
233        self
234    }
235
236    /// Borrow the active options. Useful for tests + telemetry.
237    pub fn options(&self) -> &ResolverOptions {
238        &self.options
239    }
240
241    /// Inject a [`RevocationPolicy`] into every node this resolver
242    /// verifies (issue #260, closing LIM-1). Default
243    /// [`RevocationPolicy::default`] (empty `known`, `discover: None`) is
244    /// inert — this method is the opt-in.
245    ///
246    /// Deliberately `RevocationPolicy`, never `VerificationPolicy`: this
247    /// resolver derives [`VerificationPolicy::receipts`] per node from
248    /// that node's upstream-advertised capabilities (`Require` iff the
249    /// upstream claims `acdp-registry-receipts`) — a capability-dependent
250    /// escalation a caller cannot express statically, since a walk can
251    /// visit authorities it does not know in advance. Accepting a full
252    /// `VerificationPolicy` here would have no coherent semantics:
253    /// honoring it verbatim would let a caller unknowingly strip
254    /// `Require` on a receipts-capable upstream (a downgrade primitive),
255    /// while silently overriding it would violate the "uniform policy"
256    /// contract every other policy-taking entry point upholds. `known`
257    /// travels with this policy, so a caller can enforce a
258    /// pre-discovered revocation set across a whole walk without
259    /// enabling live discovery at all.
260    ///
261    /// Not consulted by [`Self::resolve`]/[`Self::walk_derived_from`]'s
262    /// safety limits ([`ResolverOptions`]) — see that struct's doc for
263    /// why revocation configuration does not live there either.
264    pub fn with_revocation_policy(mut self, policy: RevocationPolicy) -> Self {
265        self.revocation_policy = policy;
266        self
267    }
268
269    /// Inject a [`RevocationCache`] to share across every per-authority
270    /// client this resolver builds or is seeded with (issue #260),
271    /// instead of the fresh, walk-scoped cache
272    /// [`Self::walk_derived_from`] otherwise creates for each call. Use
273    /// this when discovery should stay warm ACROSS separate walks
274    /// against a long-lived resolver, at the cost of cached absence
275    /// (a freshness marker) potentially outliving any single walk — see
276    /// [`Self::walk_derived_from`]'s doc for the default this replaces
277    /// and the exposure it carries. This cache's effective
278    /// `RevocationDiscovery::freshness` is always the caller's own
279    /// (MATERIAL-3, fresh-Opus whole-wave review) — `walk_derived_from`
280    /// never derives a freshness value for a caller-supplied cache.
281    pub fn with_revocation_cache(mut self, cache: RevocationCache) -> Self {
282        self.revocation_cache = Some(cache);
283        self
284    }
285
286    /// Present read credentials from `provider` on every hop
287    /// (RFC-ACDP-0008 §6.2, `bearer_jwt`). The provider is asked per
288    /// request with the hop's authority, so give it one token per registry
289    /// — [`crate::PerAuthorityTokens`] — and a registry's token is never
290    /// presented to another. Attached fill-if-absent: a client passed to
291    /// [`Self::seed_client`] that already carries a provider keeps its own.
292    ///
293    /// This is what makes revocation discovery answerable against a
294    /// registry with `anonymous_public_reads: false` during a walk.
295    pub fn with_auth_provider(mut self, provider: Arc<dyn AuthProvider>) -> Self {
296        self.auth = Some(provider);
297        self
298    }
299
300    /// Borrow the active revocation policy. Useful for tests + telemetry.
301    pub fn revocation_policy(&self) -> &RevocationPolicy {
302        &self.revocation_policy
303    }
304
305    /// Override the [`WebResolver`] used for DID document lookups.
306    ///
307    /// Primary use is supplying a `WebResolver::with_root_cert_pem`
308    /// instance in tests so a self-signed mock can answer DID-document
309    /// requests for `did:web:localhost%3A<port>`. Production callers do
310    /// not need this — the default resolver trusts the system CA bundle.
311    pub fn with_did_resolver(mut self, resolver: WebResolver) -> Self {
312        self.did_resolver = resolver;
313        self
314    }
315
316    /// Pre-populate the per-authority [`RegistryClient`] cache.
317    ///
318    /// Primary use is the conformance harness: tests supply a client
319    /// whose HTTP layer trusts the in-process TLS server's self-signed
320    /// root certificate (via [`RegistryClient::with_root_cert_pem`]), so
321    /// the resolver hits the mock instead of attempting a real network
322    /// call. The seeded client wins over the lazily built client that
323    /// [`Self::resolve`] would otherwise create on first access, and is
324    /// never evicted.
325    pub fn seed_client(&self, authority: impl Into<String>, client: RegistryClient) {
326        self.client_cache
327            .lock()
328            .unwrap()
329            .insert(authority.into(), client, true);
330    }
331
332    /// Restrict cross-registry resolution to a fixed set of authorities
333    /// (lowercase DNS hostnames). When set, any reference outside the
334    /// allowlist is rejected with [`AcdpError::CrossRegistryResolutionFailed`].
335    pub fn with_allowlist<I, S>(mut self, authorities: I) -> Self
336    where
337        I: IntoIterator<Item = S>,
338        S: Into<String>,
339    {
340        self.allowlist = Some(authorities.into_iter().map(Into::into).collect());
341        self
342    }
343
344    /// Resolve a single cross-registry [`CtxId`] end-to-end.
345    ///
346    /// Steps 1–7 of RFC-ACDP-0006 §4.1: parse, fetch capabilities,
347    /// verify the registry DID *and* its DID document's web binding,
348    /// retrieve, recompute hash, verify signature, and (step 7,
349    /// NORMATIVE) bind the resolved identity — reached through
350    /// `fetch_with_policy`, which refuses a served body whose `ctx_id`
351    /// is not the one requested. The [`SsrfPolicy`] is checked first so
352    /// a hostile authority cannot drive an internal-network request.
353    ///
354    /// Applies [`Self::revocation_policy`] (issue #260). If a
355    /// [`RevocationCache`] was injected via [`Self::with_revocation_cache`],
356    /// it is attached to the per-authority client used here,
357    /// fill-if-absent: a client that already carries its own cache (e.g.
358    /// via [`Self::seed_client`]) keeps it. Called directly, outside a
359    /// [`Self::walk_derived_from`] call, there is no walk-scoped cache to
360    /// fall back on — a bare `resolve()` gets seeding/suppression only
361    /// when [`Self::with_revocation_cache`] was called explicitly, and in
362    /// that case the caller's own `RevocationDiscovery::freshness` governs
363    /// unmodified (never the derived-from-`total_timeout` value
364    /// [`Self::walk_derived_from`] applies to its own walk-scoped cache —
365    /// see that method's doc). It is also bounded only by
366    /// `RevocationDiscovery::total_timeout` (`crate::RevocationDiscovery`)
367    /// when `discover` is set — [`ResolverOptions::total_timeout`] wraps
368    /// [`Self::walk_derived_from`], not this method.
369    pub async fn resolve(&self, ctx_id: &CtxId) -> Result<VerifiedContext, AcdpError> {
370        self.resolve_inner(ctx_id, self.revocation_cache.as_ref(), None)
371            .await
372    }
373
374    /// Shared implementation behind [`Self::resolve`] and the per-node
375    /// calls [`Self::walk_derived_from_inner`] makes. `cache` is either
376    /// the resolver-wide handle ([`Self::with_revocation_cache`]) or a
377    /// fresh, walk-scoped one built once per [`Self::walk_derived_from`]
378    /// call — see that method's doc.
379    ///
380    /// `walk_scoped_freshness`, when `Some`, overrides
381    /// `self.revocation_policy.discover`'s `freshness` for this call only
382    /// (MATERIAL-3, fresh-Opus whole-wave review). It is `Some` ONLY when
383    /// `walk_derived_from` built its own, resolver-constructed walk-scoped
384    /// cache (i.e. the caller did not supply one via
385    /// [`Self::with_revocation_cache`]) — never for a caller-supplied
386    /// cache, and never for a bare [`Self::resolve`] call, both of which
387    /// pass `None` and so leave the caller's own `freshness` (including
388    /// `Duration::ZERO`) untouched.
389    async fn resolve_inner(
390        &self,
391        ctx_id: &CtxId,
392        cache: Option<&RevocationCache>,
393        walk_scoped_freshness: Option<Duration>,
394    ) -> Result<VerifiedContext, AcdpError> {
395        let parsed = CtxId::parse(ctx_id.as_str())?;
396        let authority = parsed.authority().to_string();
397        self.check_allowlist(&authority)?;
398
399        // RFC-ACDP-0006 §7: SSRF policy on the outbound base URL.
400        let base = format!("https://{authority}");
401        self.ssrf_policy
402            .check_url(&base)
403            .map_err(|e| AcdpError::CrossRegistryResolutionFailed(format!("SSRF policy: {e}")))?;
404
405        // Cached client (and capabilities) per authority.
406        let registry = self.client_for(&authority, &base).await?;
407        // Issue #260: fill-if-absent. A client returned by `client_for`
408        // (built, cached, or seeded via `Self::seed_client`) that carries
409        // no `RevocationCache` of its own gets this call's `cache`
410        // attached; one that already carries a cache (a caller-seeded
411        // client wiring its own) keeps it unchanged. Precedence:
412        // explicit-on-client > explicit-on-resolver/walk > none. This is
413        // what makes vantage binding fall out for free: each authority's
414        // client is attached (or already carries) a cache independently,
415        // matching `RevocationCache`'s own per-origin scoping
416        // (RFC-ACDP-0014 §6).
417        let registry = match cache {
418            Some(cache) if registry.revocation_cache().is_none() => {
419                registry.with_revocation_cache(cache.clone())
420            }
421            _ => registry,
422        };
423        // RFC-ACDP-0008 §6.2: same fill-if-absent precedence for read
424        // credentials.
425        let registry = match &self.auth {
426            Some(provider) if !registry.has_auth_provider() => {
427                registry.with_auth_provider(provider.clone())
428            }
429            _ => registry,
430        };
431        let caps = self.capabilities_for(&authority, &registry).await?;
432
433        // Step 3a: capabilities.registry_did MUST be `did:web:<authority>`.
434        // BUG-06: percent-encode `:` for host:port authorities so the
435        // expected DID round-trips with `authority_to_did_web`.
436        let expected_did = acdp_did::authority_to_did_web(&authority);
437        if caps.registry_did != expected_did {
438            return Err(AcdpError::CrossRegistryResolutionFailed(format!(
439                "registry DID '{}' does not match expected '{expected_did}'",
440                caps.registry_did
441            )));
442        }
443
444        // Step 3b (RFC-ACDP-0006 §4.1 step 3): resolve the registry's
445        // DID document and confirm the web binding matches `<authority>`.
446        let registry_doc = self
447            .did_resolver
448            .resolve(&caps.registry_did)
449            .await
450            .map_err(|e| {
451                AcdpError::CrossRegistryResolutionFailed(format!(
452                    "could not resolve registry DID document for '{}': {e}",
453                    caps.registry_did
454                ))
455            })?;
456        if registry_doc.id != caps.registry_did {
457            return Err(AcdpError::CrossRegistryResolutionFailed(format!(
458                "registry DID document `id` '{}' does not match capabilities.registry_did '{}'",
459                registry_doc.id, caps.registry_did
460            )));
461        }
462
463        // Steps 4–6: retrieve + verify. fed-009 / RFC-ACDP-0010 §7+§11:
464        // an upstream advertising `acdp-registry-receipts` MUST always
465        // serve a receipt — absence is a registry fault (`invalid_receipt`),
466        // not a degraded mode — so the policy escalates to `Require` for
467        // such upstreams. Receipt-less upstreams proceed under the
468        // v0.1.0 trust model (receipt verified only if one is present).
469        //
470        // Issue #260: `revocations` is injected from `self.revocation_policy`
471        // — never a caller-supplied `VerificationPolicy` (no injection
472        // point accepts one; see `Self::with_revocation_policy`'s doc for
473        // why `receipts`, derived per-node just below, is the one field
474        // that stays off-limits).
475        //
476        // MATERIAL-3: when this node's cache is the resolver's own
477        // walk-scoped one (`walk_scoped_freshness: Some(..)`), override
478        // `discover.freshness` to the value `walk_derived_from` derived
479        // from `ResolverOptions::total_timeout`, regardless of whatever
480        // `freshness` the caller's `RevocationPolicy` otherwise carries.
481        // This is safe specifically because the walk-scoped cache dies
482        // with this call: no marker minted under the derived freshness
483        // can outlive a window `total_timeout` does not already bound.
484        // A caller-supplied cache (`walk_scoped_freshness: None`) is
485        // untouched — its own `freshness` (including `Duration::ZERO`)
486        // governs, because that handle may outlive this walk and its
487        // staleness exposure is the caller's to choose.
488        let mut revocations = self.revocation_policy.clone();
489        if let Some(freshness) = walk_scoped_freshness {
490            if let Some(discovery) = revocations.discover.as_mut() {
491                discovery.freshness = freshness;
492            }
493        }
494        let mut policy = VerificationPolicy {
495            revocations,
496            ..VerificationPolicy::default()
497        };
498        if caps.claims_profile(acdp_types::profile::Profile::RegistryReceipts) {
499            policy.receipts = ReceiptPolicy::Require;
500        }
501        VerifiedContext::fetch_with_policy(&registry, &self.did_resolver, &parsed, &policy)
502            .await
503            .map_err(|e| ssrf_refusal_to_cross_registry(&authority, e))
504    }
505
506    /// Walk the `derived_from` graph rooted at `body` with cycle detection,
507    /// a per-edge depth cap of [`ResolverOptions::max_depth`], a total-
508    /// nodes cap of `max_nodes`, a per-context fanout cap of `max_fanout`,
509    /// and a wall-clock `total_timeout`. Returns each verified ancestor
510    /// (excluding the root). Breadth-first; closer ancestors are returned
511    /// first.
512    ///
513    /// # Revocation discovery is walk-scoped by default (issue #260)
514    ///
515    /// When [`Self::revocation_policy`] has `discover` set and no
516    /// [`RevocationCache`] was injected via [`Self::with_revocation_cache`],
517    /// this call creates a **fresh cache for this call only** and shares
518    /// it across every node the walk visits — so discovery for a given
519    /// `(authority, trust class)` runs at most once per walk regardless
520    /// of `max_nodes`, rather than once per node, **on default
521    /// configuration**. No cached absence outlives the call.
522    ///
523    /// The effective marker freshness differs by cache origin
524    /// (MATERIAL-3, fresh-Opus whole-wave review):
525    ///
526    /// - **This resolver-built, walk-scoped cache:** the effective
527    ///   `RevocationDiscovery::freshness` for every node in this walk is
528    ///   derived from [`ResolverOptions::total_timeout`], overriding
529    ///   whatever `freshness` [`Self::revocation_policy`] otherwise
530    ///   carries (including the type default `Duration::ZERO`). This is
531    ///   what makes the "runs at most once per walk" guarantee above
532    ///   hold on genuine defaults, with no caller action required. It is
533    ///   safe because the cache — and so any marker minted into it —
534    ///   cannot outlive this call, and this call's own duration is
535    ///   already bounded by `total_timeout`.
536    /// - **A caller-supplied cache** ([`Self::with_revocation_cache`]):
537    ///   the caller's own `RevocationDiscovery::freshness` governs,
538    ///   unmodified. In particular `Duration::ZERO` (the type default)
539    ///   suppresses nothing — discovery still runs once per node. This
540    ///   handle may outlive any single walk (that is the point of
541    ///   supplying one explicitly), so its staleness exposure is the
542    ///   caller's to choose, never derived for it.
543    ///
544    /// A caller who explicitly wants discovery to stay warm ACROSS
545    /// separate walks (at the cost of a marker that can outlive any one
546    /// of them, governed by their own `freshness`) opts in via
547    /// [`Self::with_revocation_cache`], which is then reused here
548    /// instead of a fresh per-call cache.
549    ///
550    /// **The 30 s / 30 s default collision.** `RevocationDiscovery`'s
551    /// `total_timeout` defaults to 30 s, matching
552    /// [`ResolverOptions::total_timeout`]'s own default — but the two are
553    /// nested: this method wraps the whole walk in
554    /// `ResolverOptions::total_timeout`, and revocation discovery for
555    /// EACH node re-applies its own `total_timeout` inside that. On an
556    /// all-defaults configuration, one slow-but-not-yet-failed node's
557    /// discovery can consume the entire walk's budget. This fails
558    /// closed (the walk simply times out), so it is safe, but it is
559    /// surprising — set `discovery.total_timeout` well below
560    /// `ResolverOptions::total_timeout`, or raise the latter, if you
561    /// enable discovery here. The walk-scoped cache substantially
562    /// mitigates this in practice, since a repeat node at the same
563    /// authority/class no longer re-runs discovery at all.
564    pub async fn walk_derived_from(&self, body: &Body) -> Result<Vec<VerifiedContext>, AcdpError> {
565        let total_timeout = self.options.total_timeout;
566        // Issue #260: walk-scoped by default. `self.revocation_cache` is
567        // the caller's explicit opt-in to cross-walk sharing
568        // (`Self::with_revocation_cache`); absent that, build one fresh
569        // `RevocationCache` here, alive only for this call, and thread it
570        // into every node this walk resolves.
571        //
572        // MATERIAL-3: `walk_scoped_freshness` is `Some(total_timeout)`
573        // ONLY when this call built its own cache (no caller-supplied
574        // one) — that `Some` is what tells `resolve_inner` to override
575        // this walk's effective `RevocationDiscovery::freshness`. A
576        // caller-supplied cache gets `None` here, leaving its own
577        // `freshness` (governed entirely by the caller) untouched.
578        let (walk_cache, walk_scoped_freshness) = match &self.revocation_cache {
579            Some(cache) => (cache.clone(), None),
580            None => (RevocationCache::default(), Some(total_timeout)),
581        };
582        let fut = self.walk_derived_from_inner(body, &walk_cache, walk_scoped_freshness);
583        match tokio::time::timeout(total_timeout, fut).await {
584            Ok(res) => res,
585            Err(_) => Err(AcdpError::CrossRegistryResolutionFailed(format!(
586                "derived_from walk exceeded total_timeout={:?}",
587                total_timeout
588            ))),
589        }
590    }
591
592    async fn walk_derived_from_inner(
593        &self,
594        body: &Body,
595        cache: &RevocationCache,
596        walk_scoped_freshness: Option<Duration>,
597    ) -> Result<Vec<VerifiedContext>, AcdpError> {
598        let mut seen: HashSet<String> = HashSet::new();
599        seen.insert(body.ctx_id.0.clone());
600
601        if body.derived_from.len() > self.options.max_fanout {
602            return Err(AcdpError::CrossRegistryResolutionFailed(format!(
603                "root context {} has derived_from fanout {} > max_fanout={}",
604                body.ctx_id.0,
605                body.derived_from.len(),
606                self.options.max_fanout
607            )));
608        }
609
610        let mut results: Vec<VerifiedContext> = Vec::new();
611        let mut frontier: VecDeque<(CtxId, usize)> = body
612            .derived_from
613            .iter()
614            .map(|c| (c.clone(), 1usize))
615            .collect();
616
617        while let Some((next, depth)) = frontier.pop_front() {
618            if !seen.insert(next.0.clone()) {
619                continue; // cycle
620            }
621            if depth > self.options.max_depth {
622                return Err(AcdpError::CrossRegistryResolutionFailed(format!(
623                    "derived_from walk exceeded max_depth={} at {}",
624                    self.options.max_depth, next.0
625                )));
626            }
627            if results.len() >= self.options.max_nodes {
628                return Err(AcdpError::CrossRegistryResolutionFailed(format!(
629                    "derived_from walk exceeded max_nodes={} (last attempted: {})",
630                    self.options.max_nodes, next.0
631                )));
632            }
633            let verified = self
634                .resolve_inner(&next, Some(cache), walk_scoped_freshness)
635                .await?;
636            let parents = &verified.body().derived_from;
637            if parents.len() > self.options.max_fanout {
638                return Err(AcdpError::CrossRegistryResolutionFailed(format!(
639                    "context {} has derived_from fanout {} > max_fanout={}",
640                    next.0,
641                    parents.len(),
642                    self.options.max_fanout
643                )));
644            }
645            for parent in parents {
646                if !seen.contains(parent.as_str()) {
647                    frontier.push_back((parent.clone(), depth + 1));
648                }
649            }
650            results.push(verified);
651        }
652        Ok(results)
653    }
654
655    fn check_allowlist(&self, authority: &str) -> Result<(), AcdpError> {
656        if let Some(list) = &self.allowlist {
657            if !list.contains(authority) {
658                return Err(AcdpError::CrossRegistryResolutionFailed(format!(
659                    "authority '{authority}' is not on the resolver allowlist"
660                )));
661            }
662        }
663        Ok(())
664    }
665
666    /// Return a cached `RegistryClient` for the authority, building one
667    /// on first use. Reuse across hops avoids per-hop reqwest
668    /// connection-pool churn. The cache is bounded at
669    /// [`MAX_CACHED_CLIENTS`].
670    ///
671    /// SEC-01 / #385 item 9: the client uses the default `SafeDnsResolver`
672    /// DNS-hook posture (RFC-ACDP-0006 §7.6) under the resolver's
673    /// [`SsrfPolicy`]. Every DNS answer — first connect, redirect, and
674    /// every reconnect over the client's lifetime — is filtered before any
675    /// TCP connect, so a hostile `ctx_id` authority resolving to
676    /// `10.0.0.1` or `169.254.169.254` is refused (surfacing as
677    /// `cross_registry_resolution_failed`, fed-007), and a registry whose
678    /// IP legitimately changes is followed rather than stranded. Before
679    /// 0.15.0 this was a pin-once client cached for the resolver's
680    /// lifetime: weaker (one answer validated, then reused) and broken by
681    /// an IP change until restart. The seeded path ([`Self::seed_client`])
682    /// bypasses this constructor.
683    async fn client_for(&self, authority: &str, base: &str) -> Result<RegistryClient, AcdpError> {
684        if let Some(c) = self.client_cache.lock().unwrap().get(authority) {
685            return Ok(c);
686        }
687        let client = RegistryClient::builder(base)
688            .ssrf_policy(self.ssrf_policy.clone())
689            .build()
690            .await
691            .map_err(|e| ssrf_refusal_to_cross_registry(authority, e))?;
692        Ok(self
693            .client_cache
694            .lock()
695            .unwrap()
696            .insert(authority.to_string(), client, false))
697    }
698
699    /// Return the cached capabilities for `authority`, fetching when
700    /// the entry is missing or its per-response TTL has elapsed.
701    ///
702    /// BUG-09: TTL comes from the response's `Cache-Control: max-age=N`
703    /// (clamped to `[1s, ResolverOptions::capabilities_ttl]` so the
704    /// resolver-wide ceiling still applies) rather than a fixed value.
705    /// A registry serving `Cache-Control: max-age=60` is honored; one
706    /// serving no `Cache-Control` falls back to the
707    /// [`RegistryClient::capabilities_with_ttl`] default (300s).
708    async fn capabilities_for(
709        &self,
710        authority: &str,
711        registry: &RegistryClient,
712    ) -> Result<CapabilitiesDocument, AcdpError> {
713        // Fast path: cache hit + within per-response TTL.
714        {
715            let cache = self.caps_cache.lock().unwrap();
716            if let Some((caps, fetched_at, ttl)) = cache.get(authority) {
717                if fetched_at.elapsed() < *ttl {
718                    return Ok(caps.clone());
719                }
720            }
721        }
722        let (caps, response_ttl) = registry
723            .capabilities_with_ttl()
724            .await
725            .map_err(|e| match e {
726                AcdpError::Http(_) | AcdpError::KeyResolutionUnreachable(_) => {
727                    AcdpError::CrossRegistryResolutionFailed(format!(
728                        "could not reach registry '{authority}': {e}"
729                    ))
730                }
731                other => ssrf_refusal_to_cross_registry(authority, other),
732            })?;
733        // Clamp to the resolver-wide ceiling so a registry advertising
734        // an absurd `max-age` can't pin a stale doc indefinitely.
735        let ttl = response_ttl.min(self.options.capabilities_ttl);
736        let mut cache = self.caps_cache.lock().unwrap();
737        cache.insert(authority.to_string(), (caps.clone(), Instant::now(), ttl));
738        Ok(caps)
739    }
740
741    /// Return the capabilities document already cached for `authority`
742    /// from a prior walk, without fetching.
743    ///
744    /// `resolve`/`walk_derived_from` fetch and cache a foreign registry's
745    /// capabilities internally (via the private `capabilities_for`) but
746    /// never exposed the result, so a caller that also needs that document
747    /// (e.g. to check a profile the resolver itself didn't need) had no
748    /// way to read it back and had to issue a second, duplicate fetch.
749    /// Returns `None` if the resolver has never cached an entry for this
750    /// authority, or if the cached entry's per-response TTL has elapsed —
751    /// this is a cache peek, not a fetch-or-refresh, so a stale entry is
752    /// reported as absent rather than silently returned.
753    pub fn cached_capabilities(&self, authority: &str) -> Option<CapabilitiesDocument> {
754        let cache = self.caps_cache.lock().unwrap();
755        cache
756            .get(authority)
757            .and_then(|(caps, fetched_at, ttl)| (fetched_at.elapsed() < *ttl).then(|| caps.clone()))
758    }
759}
760
761/// Re-map an SSRF-policy refusal raised while talking to a foreign
762/// registry to `cross_registry_resolution_failed`, leaving every other
763/// error untouched.
764///
765/// `RegistryClient` reports such a refusal as the permanent
766/// [`AcdpError::SchemaViolation`] (issue #321), which is right for a
767/// direct caller. Inside cross-registry resolution, though, RFC-ACDP-0007
768/// §5 lists "DNS resolution refused" (and redirect-policy violations)
769/// under `cross_registry_resolution_failed` (HTTP 502), and fixture
770/// `fed-007` pins that code for a refused authority, so it is re-mapped
771/// here. This matches the URL-time SSRF check at the top of
772/// `resolve_inner`, which already reports `CrossRegistryResolutionFailed`.
773fn ssrf_refusal_to_cross_registry(authority: &str, e: AcdpError) -> AcdpError {
774    if e.is_ssrf_policy_refusal() {
775        AcdpError::CrossRegistryResolutionFailed(format!(
776            "could not reach registry '{authority}': {e}"
777        ))
778    } else {
779        e
780    }
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786
787    const SSRF_CTX: &str = "acdp://localhost/550e8400-e29b-41d4-a716-446655440000";
788
789    /// `true` when `e` is the typed form of the wire code
790    /// `cross_registry_resolution_failed`.
791    fn is_cross_registry_wire_code(e: &AcdpError) -> bool {
792        let wire = acdp_primitives::WireError {
793            error: acdp_primitives::WireErrorBody {
794                code: "cross_registry_resolution_failed".into(),
795                message: String::new(),
796                details: None,
797            },
798        };
799        std::mem::discriminant(e) == std::mem::discriminant(&AcdpError::from_wire_error(wire))
800    }
801
802    /// fed-007 / RFC-ACDP-0007 §5 ("DNS resolution refused"): an
803    /// authority that resolves into a forbidden range is refused at the
804    /// built client's DNS hook and surfaces as
805    /// `cross_registry_resolution_failed`, not `schema_violation`.
806    #[tokio::test]
807    async fn resolve_maps_built_client_ssrf_refusal_to_cross_registry_failure() {
808        let resolver = CrossRegistryResolver::new();
809        let err = resolver
810            .resolve(&CtxId::parse(SSRF_CTX).unwrap())
811            .await
812            .expect_err("localhost authority MUST be refused");
813        assert!(
814            is_cross_registry_wire_code(&err),
815            "expected cross_registry_resolution_failed, got {err:?}"
816        );
817        assert!(err.to_string().contains("SSRF policy"), "{err}");
818    }
819
820    /// Issue #321: the DNS-time `SafeDnsResolver` refusal, which a direct
821    /// `RegistryClient` caller sees as the permanent `SchemaViolation`, is
822    /// re-mapped to `cross_registry_resolution_failed` when it happens
823    /// inside the resolver (here on the capabilities fetch of a seeded
824    /// client).
825    #[tokio::test]
826    async fn resolve_maps_dns_time_ssrf_refusal_to_cross_registry_failure() {
827        let direct = RegistryClient::new("https://localhost").unwrap();
828        let direct_err = direct.capabilities().await.unwrap_err();
829        assert!(
830            direct_err.is_ssrf_policy_refusal(),
831            "direct caller sees the permanent SSRF SchemaViolation, got {direct_err:?}"
832        );
833
834        let resolver = CrossRegistryResolver::new();
835        resolver.seed_client("localhost", direct);
836        let err = resolver
837            .resolve(&CtxId::parse(SSRF_CTX).unwrap())
838            .await
839            .expect_err("localhost authority MUST be refused");
840        assert!(
841            is_cross_registry_wire_code(&err),
842            "expected cross_registry_resolution_failed, got {err:?}"
843        );
844        assert!(err.to_string().contains("SSRF policy"), "{err}");
845    }
846
847    /// Non-SSRF errors pass through the re-map untouched.
848    #[test]
849    fn ssrf_remap_leaves_other_errors_alone() {
850        let e = ssrf_refusal_to_cross_registry("r", AcdpError::SchemaViolation("bad".into()));
851        assert!(matches!(e, AcdpError::SchemaViolation(m) if m == "bad"));
852    }
853
854    fn test_caps() -> CapabilitiesDocument {
855        serde_json::from_value(serde_json::json!({
856            "acdp_version": "0.4.0",
857            "registry_did": "did:web:registry.example.com",
858            "supported_signature_algorithms": ["ed25519"],
859            "supported_did_methods": ["did:web"],
860            "profiles": ["acdp-registry-core"],
861            "limits": {"max_payload_bytes": 1_048_576, "max_embedded_bytes": 65536},
862        }))
863        .unwrap()
864    }
865
866    #[test]
867    fn cached_capabilities_returns_none_when_never_fetched() {
868        let resolver = CrossRegistryResolver::new();
869        assert!(resolver
870            .cached_capabilities("registry.example.com")
871            .is_none());
872    }
873
874    #[test]
875    fn cached_capabilities_returns_fresh_entry_without_fetching() {
876        let resolver = CrossRegistryResolver::new();
877        resolver.caps_cache.lock().unwrap().insert(
878            "registry.example.com".to_string(),
879            (test_caps(), Instant::now(), Duration::from_secs(300)),
880        );
881        let caps = resolver
882            .cached_capabilities("registry.example.com")
883            .expect("entry was just seeded fresh");
884        assert_eq!(caps.registry_did, "did:web:registry.example.com");
885    }
886
887    #[test]
888    fn cached_capabilities_reports_expired_entry_as_absent() {
889        let resolver = CrossRegistryResolver::new();
890        // `checked_sub` avoids a debug-mode underflow panic if the test
891        // runs within 60s of process start.
892        let long_ago = Instant::now()
893            .checked_sub(Duration::from_secs(60))
894            .expect("test host uptime exceeds 60s");
895        resolver.caps_cache.lock().unwrap().insert(
896            "registry.example.com".to_string(),
897            (test_caps(), long_ago, Duration::from_secs(1)),
898        );
899        assert!(resolver
900            .cached_capabilities("registry.example.com")
901            .is_none());
902    }
903
904    #[test]
905    fn allowlist_rejects_outside_authorities() {
906        let resolver =
907            CrossRegistryResolver::new().with_allowlist(["registry.example.com".to_string()]);
908        let err = resolver.check_allowlist("evil.com").unwrap_err();
909        assert!(matches!(err, AcdpError::CrossRegistryResolutionFailed(_)));
910        resolver.check_allowlist("registry.example.com").unwrap();
911    }
912
913    fn dummy_client(authority: &str) -> RegistryClient {
914        RegistryClient::new(&format!("https://{authority}")).unwrap()
915    }
916
917    /// #385 item 9: the per-authority client cache is bounded; at capacity
918    /// the oldest built client goes, a seeded one never does.
919    #[test]
920    fn client_cache_is_bounded_and_keeps_seeded_clients() {
921        let mut cache = ClientCache::default();
922        cache.insert(
923            "seeded.example".into(),
924            dummy_client("seeded.example"),
925            true,
926        );
927        for i in 0..MAX_CACHED_CLIENTS + 10 {
928            let a = format!("r{i}.example");
929            cache.insert(a.clone(), dummy_client(&a), false);
930        }
931        assert_eq!(cache.entries.len(), MAX_CACHED_CLIENTS);
932        assert!(
933            cache.get("seeded.example").is_some(),
934            "seeded never evicted"
935        );
936        assert!(cache.get("r0.example").is_none(), "oldest built evicted");
937        let newest = format!("r{}.example", MAX_CACHED_CLIENTS + 9);
938        assert!(cache.get(&newest).is_some());
939    }
940
941    /// A racing build loses to the client already cached; a seed replaces.
942    #[test]
943    fn client_cache_first_build_wins_seed_replaces() {
944        let mut cache = ClientCache::default();
945        let first = cache.insert("a.example".into(), dummy_client("a.example"), false);
946        let second = cache.insert("a.example".into(), dummy_client("b.example"), false);
947        assert_eq!(first.authority(), second.authority());
948        let seeded = cache.insert("a.example".into(), dummy_client("c.example"), true);
949        assert_eq!(seeded.authority().as_deref(), Some("c.example"));
950        assert_eq!(
951            cache.get("a.example").unwrap().authority().as_deref(),
952            Some("c.example")
953        );
954    }
955
956    #[test]
957    fn options_default_values_match_doc() {
958        let o = ResolverOptions::default();
959        assert_eq!(o.max_depth, 10);
960        assert_eq!(o.max_nodes, 100);
961        assert_eq!(o.max_fanout, 32);
962        assert_eq!(o.total_timeout, Duration::from_secs(30));
963        assert_eq!(o.capabilities_ttl, Duration::from_secs(300));
964    }
965
966    #[test]
967    fn with_options_replaces_full_struct() {
968        let r = CrossRegistryResolver::new().with_options(ResolverOptions {
969            max_depth: 3,
970            max_nodes: 7,
971            max_fanout: 2,
972            total_timeout: Duration::from_secs(5),
973            capabilities_ttl: Duration::from_secs(60),
974        });
975        assert_eq!(r.options().max_depth, 3);
976        assert_eq!(r.options().max_nodes, 7);
977        assert_eq!(r.options().max_fanout, 2);
978    }
979
980    #[test]
981    fn cycle_detection_short_circuits() {
982        let _resolver = CrossRegistryResolver::new();
983        let mut seen: HashSet<String> = HashSet::new();
984        let id = "acdp://r/12345678-1234-4321-8123-123456781234".to_string();
985        assert!(seen.insert(id.clone()));
986        assert!(!seen.insert(id));
987    }
988}