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