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, ®istry).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(®istry, &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}