acdp_client/registry.rs
1//! HTTP client for ACDP registries (feature = "client").
2
3use std::num::NonZeroUsize;
4use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
5use std::sync::Arc;
6use std::time::Duration;
7
8use acdp_primitives::error::AcdpError;
9use acdp_primitives::limits::{
10 CONNECT_TIMEOUT, MAX_CONTEXT_BYTES, MAX_METADATA_BYTES, MAX_REDIRECTS, REQUEST_TIMEOUT,
11};
12use acdp_safe_http::SsrfPolicy;
13use acdp_types::{
14 body::FullContext,
15 capabilities::CapabilitiesDocument,
16 primitives::{CtxId, LineageId},
17 publish::{PublishRequest, PublishResponse, WireError},
18 search::{SearchParams, SearchResponse},
19};
20use chrono::{DateTime, Utc};
21use reqwest::{redirect, Client};
22
23use crate::auth::AuthProvider;
24use crate::revocation_cache::RevocationCache;
25
26/// HTTP client for a single ACDP registry.
27///
28/// `reqwest::Client` clones cheaply (it's an `Arc` internally), so this
29/// struct is `Clone` to enable per-authority caching in
30/// [`crate::CrossRegistryResolver`] without re-wiring HTTP+TLS
31/// state on every hop. The same is true of `budget` (issue #258): it is
32/// an `Option<DiscoveryBudget>`, and `DiscoveryBudget` is itself an
33/// `Arc` handle, so cloning a client that carries a budget shares the
34/// counters rather than resetting them — the shape
35/// [`RevocationDiscovery`](crate::verified::RevocationDiscovery)
36/// depends on: `verify_retrieved` attaches one `DiscoveryBudget` to a
37/// single client clone and hands that SAME clone to both trust-class
38/// lookups.
39///
40/// `revocation_cache` (issue #257) is the same shape again: an
41/// `Option<RevocationCache>`, itself an `Arc` handle, so cloning a client
42/// that carries a cache shares the underlying store rather than resetting
43/// it. Unlike `budget`, it is caller-injectable via
44/// [`Self::with_revocation_cache`] on ANY client, not only the
45/// discovery-scoped clone `verify_retrieved` builds internally — a caller
46/// verifying many contexts against the same producer attaches one cache up
47/// front and every subsequent discovery benefits. `revocation_freshness`
48/// travels alongside it: `verify_retrieved` overrides it per call from
49/// `RevocationDiscovery::freshness` (crate-private
50/// `with_revocation_freshness`, crate-private), while a cache attached directly
51/// via `with_revocation_cache` defaults to `Duration::ZERO` — pure seeding,
52/// no marker ever suppresses a lookup, until the caller explicitly opts in
53/// through a discovery configuration's `freshness` field.
54#[derive(Clone)]
55pub struct RegistryClient {
56 base: String,
57 http: Client,
58 budget: Option<DiscoveryBudget>,
59 revocation_cache: Option<RevocationCache>,
60 revocation_freshness: Duration,
61 /// RFC-ACDP-0008 §6.2 read credentials, asked per read request by
62 /// this client's authority. See [`crate::auth`].
63 auth: Option<Arc<dyn AuthProvider>>,
64}
65
66/// Combined request-count and cumulative-byte budget for RFC-ACDP-0014
67/// §8 revocation auto-discovery (issue #258, decision D-B).
68///
69/// Attached to a [`RegistryClient`] clone created once per discovery
70/// call (`acdp_client::verified::verify_retrieved`) via
71/// [`RegistryClient::with_discovery_budget`], and shared — via this
72/// type's own internal `Arc` — by both trust-class lookups running
73/// concurrently under `tokio::try_join!`, so the two lookups draw down
74/// ONE combined ceiling rather than a ceiling each.
75///
76/// `pub(crate)`: `verify_retrieved` is the only intended caller. There
77/// is deliberately no public `find_revocations_with_budget` or similar
78/// — a public budget type would be a permanent commitment to a shape
79/// nothing outside this crate needs (see the wave plan's D-B).
80///
81/// Bounds **registry** traffic only: DID-document fetches issued via
82/// `WebResolver` (inside `verify_revocation_body`) do not pass through
83/// `RegistryClient` and are not counted. Bounds **successfully-parsed
84/// response bodies** only: `parse_success`'s non-success branch reads
85/// up to 64 KB of an error envelope, and that read is never charged to
86/// the byte budget.
87#[derive(Clone)]
88pub(crate) struct DiscoveryBudget {
89 inner: Arc<DiscoveryBudgetInner>,
90}
91
92struct DiscoveryBudgetInner {
93 max_requests: Option<NonZeroUsize>,
94 max_bytes: Option<u64>,
95 requests_used: AtomicUsize,
96 bytes_used: AtomicU64,
97}
98
99impl DiscoveryBudget {
100 /// Build a budget from `RevocationDiscovery`'s two knobs. `None`
101 /// for either means that dimension is unbounded — passing `None`
102 /// for both makes every check a no-op, preserving pre-#258
103 /// behavior exactly.
104 pub(crate) fn new(max_requests: Option<NonZeroUsize>, max_bytes: Option<u64>) -> Self {
105 Self {
106 inner: Arc::new(DiscoveryBudgetInner {
107 max_requests,
108 max_bytes,
109 requests_used: AtomicUsize::new(0),
110 bytes_used: AtomicU64::new(0),
111 }),
112 }
113 }
114
115 /// Check-and-reserve, called at the top of each of
116 /// `RegistryClient`'s four discovery-reachable request methods
117 /// (`capabilities`/`capabilities_with_ttl`, `retrieve`, `lineage`,
118 /// `search`) — BEFORE the request is issued. This is what makes
119 /// check-before-issue structural rather than a discipline: a
120 /// budget error returned here can never be issued as a wasted
121 /// request the way `MAX_LINEAGE_WALKS`'s after-the-fact check can.
122 ///
123 /// The byte check runs first and is a plain load with no side
124 /// effect, so a budget that is already byte-exhausted never
125 /// consumes a request-count reservation it will not use.
126 ///
127 /// The request-count check is an atomic `fetch_update`
128 /// compare-exchange loop, not load-then-store: two lookups racing
129 /// under `try_join!` against the same remaining count can never
130 /// both observe room for the last slot.
131 fn check_before_request(&self) -> Result<(), AcdpError> {
132 if let Some(max_bytes) = self.inner.max_bytes {
133 if self.inner.bytes_used.load(Ordering::SeqCst) >= max_bytes {
134 return Err(AcdpError::RevocationDiscoveryBudgetExceeded(format!(
135 "revocation discovery exceeded max_bytes={max_bytes}"
136 )));
137 }
138 }
139 if let Some(max_requests) = self.inner.max_requests {
140 let max_requests = max_requests.get();
141 // N9 (fresh-Opus review of Phase 2): `fetch_update` was renamed
142 // `try_update` (rust-lang/rust#135894); nightly already flags
143 // the old name as deprecated, but `try_update` itself is behind
144 // the unstable `atomic_try_update` feature on this crate's
145 // MSRV (1.87) — confirmed by hand: `rustc +1.87.0` rejects
146 // `try_update` with E0658. Suppress narrowly here rather than
147 // rename, and do NOT raise MSRV to chase this.
148 #[allow(deprecated)]
149 let reserved =
150 self.inner
151 .requests_used
152 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |used| {
153 if used < max_requests {
154 Some(used + 1)
155 } else {
156 None
157 }
158 });
159 if reserved.is_err() {
160 return Err(AcdpError::RevocationDiscoveryBudgetExceeded(format!(
161 "revocation discovery exceeded max_requests={max_requests}"
162 )));
163 }
164 }
165 Ok(())
166 }
167
168 /// Add the bytes of a just-completed successful response to the
169 /// running total. Not a check — overrun is only observed by the
170 /// NEXT [`Self::check_before_request`] call, since the size of an
171 /// in-flight request cannot be known (and therefore reserved) in
172 /// advance.
173 fn record_bytes(&self, n: usize) {
174 self.inner.bytes_used.fetch_add(n as u64, Ordering::SeqCst);
175 }
176}
177
178/// Cache and integrity headers returned alongside a retrieved body.
179///
180/// `etag` is the body's `content_hash` (immutable; ideal cache key).
181/// `cache_control` and `last_modified` are reported verbatim from the
182/// upstream registry.
183#[derive(Debug, Clone, Default)]
184pub struct RetrievalMetadata {
185 /// Strong validator for conditional retrieval (`If-None-Match`).
186 pub etag: Option<String>,
187 /// Raw `Cache-Control` header value, if any.
188 pub cache_control: Option<String>,
189 /// Parsed `Last-Modified` header, if any.
190 pub last_modified: Option<DateTime<Utc>>,
191}
192
193impl RegistryClient {
194 /// The authority (host, plus port when non-default) of the registry
195 /// this client talks to — the value a receipt's `registry_did`
196 /// must match (RFC-ACDP-0010 serving-authority cross-check).
197 pub fn authority(&self) -> Option<String> {
198 url::Url::parse(&self.base).ok().and_then(|u| {
199 let host = u.host_str()?.to_string();
200 Some(match u.port() {
201 Some(p) => format!("{host}:{p}"),
202 None => host,
203 })
204 })
205 }
206
207 /// Return a clone of this client with `budget` attached, replacing
208 /// any budget the original carried. Issue #258: `verify_retrieved`
209 /// calls this exactly once per discovery, on a client clone it then
210 /// hands to BOTH trust-class lookups, so the two lookups share one
211 /// combined counter rather than getting one each. The original
212 /// client (and any other clone of it) is unaffected — traffic
213 /// issued through it is never charged to this budget.
214 pub(crate) fn with_discovery_budget(&self, budget: DiscoveryBudget) -> Self {
215 Self {
216 budget: Some(budget),
217 ..self.clone()
218 }
219 }
220
221 /// Check-and-reserve against this client's attached budget, if any.
222 /// A no-op `Ok(())` when no budget is attached (the common case —
223 /// budgets exist only on discovery-scoped clones).
224 fn check_discovery_budget(&self) -> Result<(), AcdpError> {
225 match &self.budget {
226 Some(budget) => budget.check_before_request(),
227 None => Ok(()),
228 }
229 }
230
231 /// Record a successful response's byte count against this client's
232 /// attached budget, if any.
233 fn record_discovery_bytes(&self, n: usize) {
234 if let Some(budget) = &self.budget {
235 budget.record_bytes(n);
236 }
237 }
238
239 /// Return a clone of this client with `cache` attached (issue #257),
240 /// replacing any cache the original carried, and resetting
241 /// `revocation_freshness` to `Duration::ZERO` — pure seeding by
242 /// default, following [`crate::verified::RevocationDiscovery`]'s own
243 /// zero default. Attach a cache once and reuse the returned client
244 /// across many `VerifiedContext::fetch*` calls against the same
245 /// producer(s) to amortize RFC-ACDP-0014 §8 discovery: facts persist
246 /// (indefinitely, per §7:114) across calls regardless of `freshness`;
247 /// setting a discovery configuration's `freshness` above zero
248 /// additionally lets a fresh marker skip a repeat lookup entirely — see
249 /// `crate::revocation_cache` for the two-object model.
250 pub fn with_revocation_cache(&self, cache: RevocationCache) -> Self {
251 Self {
252 revocation_cache: Some(cache),
253 revocation_freshness: Duration::ZERO,
254 ..self.clone()
255 }
256 }
257
258 /// Return a clone of this client that presents read credentials from
259 /// `provider` (RFC-ACDP-0008 §6.2, `bearer_jwt`), replacing any provider
260 /// the original carried. The provider is asked once per read request,
261 /// with this client's [`Self::authority`]; see [`crate::auth`] for which
262 /// requests carry a token and why writes never do.
263 ///
264 /// A registry with `anonymous_public_reads: false` answers anonymous
265 /// reads `not_authorized`, which makes revocation discovery against it
266 /// `unknown` (`unanswerable`); a configured token makes it answerable.
267 pub fn with_auth_provider(&self, provider: Arc<dyn AuthProvider>) -> Self {
268 Self {
269 auth: Some(provider),
270 ..self.clone()
271 }
272 }
273
274 /// Whether this client carries an [`AuthProvider`].
275 #[must_use]
276 pub fn has_auth_provider(&self) -> bool {
277 self.auth.is_some()
278 }
279
280 /// Send a read request, presenting this client's bearer token when
281 /// its provider has one for this authority. Tokens go over `https`
282 /// only (RFC-ACDP-0008 §6.2); a 401/403 answer to a request that
283 /// carried one is reported to the provider via
284 /// [`AuthProvider::token_rejected`].
285 async fn send_read(
286 &self,
287 req: reqwest::RequestBuilder,
288 ) -> Result<reqwest::Response, AcdpError> {
289 let (req, authority) = match (&self.auth, self.authority()) {
290 (Some(provider), Some(authority)) if self.base.starts_with("https://") => {
291 match provider.bearer_token(&authority).await? {
292 Some(token) => (req.bearer_auth(token.expose()), Some(authority)),
293 None => (req, None),
294 }
295 }
296 _ => (req, None),
297 };
298 let resp = req.send().await?;
299 if let (Some(provider), Some(authority)) = (&self.auth, authority) {
300 if matches!(
301 resp.status(),
302 reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
303 ) {
304 provider.token_rejected(&authority);
305 }
306 }
307 Ok(resp)
308 }
309
310 /// Return a clone of this client with `freshness` overriding whatever
311 /// `revocation_freshness` it carried, leaving `revocation_cache`
312 /// (attached or not) unchanged. `verify_retrieved` calls this on the
313 /// same discovery-scoped clone it already built via
314 /// [`Self::with_discovery_budget`], threading through
315 /// `RevocationDiscovery::freshness` — never a caller-facing knob on its
316 /// own, since freshness is a per-discovery-call policy, not a
317 /// per-client one.
318 pub(crate) fn with_revocation_freshness(&self, freshness: Duration) -> Self {
319 Self {
320 revocation_freshness: freshness,
321 ..self.clone()
322 }
323 }
324
325 /// This client's attached revocation cache and its current freshness
326 /// setting, if a cache is attached. `crate::revocation`'s two discovery
327 /// functions read this — never `&VerificationPolicy` — to check/mint
328 /// freshness markers and to record newly-discovered facts.
329 pub(crate) fn revocation_cache(&self) -> Option<(&RevocationCache, Duration)> {
330 self.revocation_cache
331 .as_ref()
332 .map(|cache| (cache, self.revocation_freshness))
333 }
334
335 /// Connect to a registry at `base_url` (e.g. `https://registry.example.com`).
336 ///
337 /// Uses `rustls` for TLS; does not use the system OpenSSL. Applies
338 /// the RFC-ACDP-0006 §7.4 default timeouts (5s connect, 30s total)
339 /// and §7.5 redirect policy (max 3 follows, same authority only).
340 ///
341 /// # DNS-rebinding posture (default)
342 ///
343 /// This constructor installs the **`SafeDnsResolver` DNS hook**
344 /// (RFC-ACDP-0006 §7.6): every hostname lookup — the first connect,
345 /// each redirect, and every reconnect the pool makes over the
346 /// client's lifetime — is filtered through the [`SsrfPolicy`] *at
347 /// DNS time, before any TCP connect*. This is **strictly stronger
348 /// than pin-once resolution** ([`Self::new_pinned`]): a pinned
349 /// client validates a single answer and reuses that address, so a
350 /// hostile authoritative DNS server that only later flips a name
351 /// into a forbidden range is still caught here but not there. The
352 /// DNS-hook posture is therefore the default for all callers; reach
353 /// for [`Self::builder`] only when you need a non-default knob (a
354 /// private root cert, a custom [`SsrfPolicy`], timeout overrides, or
355 /// the legacy pinned mode).
356 pub fn new(base_url: &str) -> Result<Self, AcdpError> {
357 Self::build(base_url, None, None, SsrfPolicy::default())
358 }
359
360 /// Start a [`RegistryClientBuilder`] for the non-default connection
361 /// postures — a private root certificate, a custom [`SsrfPolicy`],
362 /// timeout overrides, and the legacy pinned-resolution mode.
363 ///
364 /// The builder's *default* is identical to [`Self::new`]: the
365 /// stronger `SafeDnsResolver` DNS-hook posture with the default
366 /// SSRF policy and the RFC-ACDP-0006 §7.4 timeouts. Opt into
367 /// pin-once resolution with [`RegistryClientBuilder::pinned`].
368 pub fn builder(base_url: &str) -> RegistryClientBuilder {
369 RegistryClientBuilder::new(base_url)
370 }
371
372 /// Connect to a registry that trusts the given PEM-encoded root
373 /// certificate in addition to the system roots.
374 ///
375 /// Primary use is the in-process self-signed HTTPS server in the
376 /// crate's `tests/helpers/tls_did_server.rs` harness so the spec
377 /// fixtures `fed-001..006` can drive `CrossRegistryResolver`
378 /// end-to-end without going over the network.
379 #[cfg(feature = "test-transport")]
380 pub fn with_root_cert_pem(base_url: &str, pem: &[u8]) -> Result<Self, AcdpError> {
381 // Drives an in-process HTTPS server on loopback, so the SSRF
382 // policy must permit a loopback-resolved answer. All other
383 // forbidden ranges (RFC 1918, IMDS, …) still apply.
384 Self::build(base_url, Some(pem), None, SsrfPolicy::allow_test_loopback())
385 }
386
387 /// Test-only permissive transport: allows `http://`, IP-literal hosts,
388 /// and loopback so the crate's in-process mock HTTP servers (e.g.
389 /// `wiremock`, which binds `http://127.0.0.1:<port>`) can be driven.
390 ///
391 /// Production MUST use [`Self::new`], which applies the full
392 /// RFC-ACDP-0006 §7 / RFC-ACDP-0008 SSRF + HTTPS-only + DNS-rebinding
393 /// posture. This constructor exists solely to keep the test harness on
394 /// loopback HTTP.
395 #[doc(hidden)]
396 #[cfg(feature = "test-transport")]
397 pub fn with_test_transport(base_url: &str) -> Result<Self, AcdpError> {
398 let policy = SsrfPolicy {
399 reject_ip_literals: false,
400 allow_http: true,
401 allow_loopback_resolved: true,
402 };
403 Self::build(base_url, None, None, policy)
404 }
405
406 /// Connect to a registry whose `<authority>` in `base_url` is routed
407 /// to a fixed socket address. Trusts the given PEM-encoded root
408 /// certificate in addition to the system roots.
409 ///
410 /// Use only in tests: a `CrossRegistryResolver` test that wants to
411 /// drive `acdp://<host>/<uuid>` references requires `<host>` to be
412 /// a valid lowercase DNS label (per `is_valid_dns_authority` in
413 /// `types::primitives`), which precludes embedding the port in the
414 /// `ctx_id`. This factory accepts a logical hostname (e.g.
415 /// `localhost`) and pins it to the test server's actual
416 /// `127.0.0.1:<port>` via reqwest's `.resolve()` hook.
417 #[doc(hidden)]
418 #[cfg(feature = "test-transport")]
419 pub fn with_test_endpoint(
420 base_url: &str,
421 target: std::net::SocketAddr,
422 pem: &[u8],
423 ) -> Result<Self, AcdpError> {
424 // Pins a logical hostname to a loopback test endpoint; permit the
425 // loopback answer while keeping every other forbidden range live.
426 Self::build(
427 base_url,
428 Some(pem),
429 Some(target),
430 SsrfPolicy::allow_test_loopback(),
431 )
432 }
433
434 fn build(
435 base_url: &str,
436 extra_root_pem: Option<&[u8]>,
437 resolve_target: Option<std::net::SocketAddr>,
438 policy_ssrf: SsrfPolicy,
439 ) -> Result<Self, AcdpError> {
440 RegistryClientBuilder {
441 base_url: base_url.to_string(),
442 pinned: false,
443 ssrf_policy: policy_ssrf,
444 root_cert_pem: extra_root_pem.map(<[u8]>::to_vec),
445 resolve_target,
446 connect_timeout: CONNECT_TIMEOUT,
447 request_timeout: REQUEST_TIMEOUT,
448 auth: None,
449 }
450 .build_blocking()
451 }
452
453 /// Connect to a registry with pin-once DNS-rebinding protection
454 /// (RFC-ACDP-0006 §7.6).
455 ///
456 /// Resolves the hostname once, validates the resolved IP against
457 /// `policy`, then pins that IP into the HTTP client.
458 ///
459 /// **Deprecated:** the default [`Self::new`] posture installs the
460 /// `SafeDnsResolver` DNS hook, which validates the resolved IP on
461 /// *every* connection (including reconnects) rather than just once —
462 /// strictly stronger protection. For the rare case that still wants
463 /// pin-once semantics with a custom policy, use
464 /// `RegistryClient::builder(base_url).pinned(true).ssrf_policy(policy).build().await`.
465 #[deprecated(
466 since = "0.4.0",
467 note = "prefer `RegistryClient::new` (SafeDnsResolver DNS hook — validates every \
468 connection, strictly stronger than pin-once) or, for explicit pin-once mode, \
469 `RegistryClient::builder(base_url).pinned(true).ssrf_policy(policy).build().await`"
470 )]
471 pub async fn new_pinned(base_url: &str, policy: &SsrfPolicy) -> Result<Self, AcdpError> {
472 Self::builder(base_url)
473 .pinned(true)
474 .ssrf_policy(policy.clone())
475 .build()
476 .await
477 }
478
479 // ── Capabilities ────────────────────────────────────────────────────────
480
481 /// Fetch the registry's capabilities document and run the
482 /// RFC-ACDP-0007 §3 runtime validation
483 /// ([`acdp_validation::validate_capabilities`]).
484 ///
485 /// Body capped at 64 KB per RFC-ACDP-0006 §7.3.
486 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
487 pub async fn capabilities(&self) -> Result<CapabilitiesDocument, AcdpError> {
488 Ok(self.capabilities_with_ttl().await?.0)
489 }
490
491 /// Like [`Self::capabilities`] but also returns the cache TTL
492 /// derived from the response's `Cache-Control: max-age=N` header.
493 ///
494 /// Per RFC-ACDP-0006 §4.2, consumers SHOULD cache the capabilities
495 /// document for `min(max-age, 3600s)` seconds. When no
496 /// `Cache-Control` (or no parseable `max-age`) is returned, the
497 /// fallback is `300s` — a conservative middle-ground that matches
498 /// [`crate::ResolverOptions::capabilities_ttl`]'s default.
499 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
500 pub async fn capabilities_with_ttl(
501 &self,
502 ) -> Result<(CapabilitiesDocument, std::time::Duration), AcdpError> {
503 self.check_discovery_budget()?;
504 let url = format!("{}/.well-known/acdp.json", self.base);
505 let resp = self.http.get(&url).send().await?;
506 let ttl = cache_ttl_from_response(&resp);
507 let (caps, nbytes): (CapabilitiesDocument, usize) =
508 self.parse_success(resp, MAX_METADATA_BYTES).await?;
509 self.record_discovery_bytes(nbytes);
510 acdp_validation::validate_capabilities(&caps)?;
511 Ok((caps, ttl))
512 }
513
514 // ── Publish ─────────────────────────────────────────────────────────────
515
516 /// Publish a context. Returns the registry-assigned identifiers.
517 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, req)))]
518 pub async fn publish(&self, req: &PublishRequest) -> Result<PublishResponse, AcdpError> {
519 let url = format!("{}/contexts", self.base);
520 let resp = self
521 .http
522 .post(&url)
523 .header("Content-Type", "application/acdp+json")
524 .json(req)
525 .send()
526 .await?;
527 self.parse_success(resp, MAX_METADATA_BYTES)
528 .await
529 .map(|(v, _)| v)
530 }
531
532 /// Publish with an idempotency key for safe retries.
533 pub async fn publish_idempotent(
534 &self,
535 req: &PublishRequest,
536 idempotency_key: &str,
537 ) -> Result<PublishResponse, AcdpError> {
538 let url = format!("{}/contexts", self.base);
539 let resp = self
540 .http
541 .post(&url)
542 .header("Content-Type", "application/acdp+json")
543 .header("Idempotency-Key", idempotency_key)
544 .json(req)
545 .send()
546 .await?;
547 self.parse_success(resp, MAX_METADATA_BYTES)
548 .await
549 .map(|(v, _)| v)
550 }
551
552 /// Publish with bounded retry for transient failures.
553 ///
554 /// Reuses `idempotency_key` across attempts so the registry can
555 /// dedupe (RFC-ACDP-0003 §6). Retries only when the error is
556 /// transient per [`AcdpError::is_transient`]. Bounded backoff:
557 /// 250 ms, 500 ms, 1 s, 2 s.
558 pub async fn publish_with_retry(
559 &self,
560 req: &PublishRequest,
561 idempotency_key: &str,
562 max_attempts: u32,
563 ) -> Result<PublishResponse, AcdpError> {
564 let attempts = max_attempts.max(1);
565 let mut last_err: Option<AcdpError> = None;
566 for attempt in 0..attempts {
567 match self.publish_idempotent(req, idempotency_key).await {
568 Ok(resp) => return Ok(resp),
569 Err(e) if e.is_transient() && attempt + 1 < attempts => {
570 let backoff_ms = 250u64 * (1 << attempt.min(3));
571 last_err = Some(e);
572 #[cfg(feature = "tracing")]
573 tracing::debug!(
574 attempt = attempt + 1,
575 backoff_ms,
576 "publish transient failure; retrying"
577 );
578 tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
579 }
580 Err(e) => return Err(e),
581 }
582 }
583 Err(last_err
584 .unwrap_or_else(|| AcdpError::Http("publish_with_retry exhausted attempts".into())))
585 }
586
587 // ── Retrieval ────────────────────────────────────────────────────────────
588
589 /// Retrieve a full context (body + registry_state) by ctx_id.
590 ///
591 /// Body capped at 1 MB per RFC-ACDP-0006 §7.3.
592 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(ctx_id = %ctx_id)))]
593 pub async fn retrieve(&self, ctx_id: &CtxId) -> Result<FullContext, AcdpError> {
594 self.check_discovery_budget()?;
595 let encoded = urlencoding::encode(ctx_id.as_str());
596 let url = format!("{}/contexts/{}", self.base, encoded);
597 let resp = self.send_read(self.http.get(&url)).await?;
598 let (body, nbytes) = self.parse_success(resp, MAX_CONTEXT_BYTES).await?;
599 self.record_discovery_bytes(nbytes);
600 Ok(body)
601 }
602
603 /// Retrieve a full context plus cache / integrity headers.
604 pub async fn retrieve_with_metadata(
605 &self,
606 ctx_id: &CtxId,
607 ) -> Result<(FullContext, RetrievalMetadata), AcdpError> {
608 let encoded = urlencoding::encode(ctx_id.as_str());
609 let url = format!("{}/contexts/{}", self.base, encoded);
610 let resp = self.send_read(self.http.get(&url)).await?;
611 let metadata = parse_retrieval_metadata(&resp);
612 let (body, _) = self.parse_success(resp, MAX_CONTEXT_BYTES).await?;
613 Ok((body, metadata))
614 }
615
616 /// Conditional retrieval using `If-None-Match`.
617 ///
618 /// Returns `Ok(None)` when the registry responds 304 Not Modified.
619 /// Returns `Ok(Some((body, metadata)))` for a fresh retrieval.
620 pub async fn retrieve_if_none_match(
621 &self,
622 ctx_id: &CtxId,
623 etag: &str,
624 ) -> Result<Option<(FullContext, RetrievalMetadata)>, AcdpError> {
625 let encoded = urlencoding::encode(ctx_id.as_str());
626 let url = format!("{}/contexts/{}", self.base, encoded);
627 let resp = self
628 .send_read(self.http.get(&url).header("If-None-Match", etag))
629 .await?;
630 if resp.status() == reqwest::StatusCode::NOT_MODIFIED {
631 return Ok(None);
632 }
633 let metadata = parse_retrieval_metadata(&resp);
634 let (body, _) = self.parse_success(resp, MAX_CONTEXT_BYTES).await?;
635 Ok(Some((body, metadata)))
636 }
637
638 /// Retrieve just the body (immutable, highly cacheable).
639 pub async fn retrieve_body(&self, ctx_id: &CtxId) -> Result<acdp_types::body::Body, AcdpError> {
640 let encoded = urlencoding::encode(ctx_id.as_str());
641 let url = format!("{}/contexts/{}/body", self.base, encoded);
642 let resp = self.send_read(self.http.get(&url)).await?;
643 self.parse_success(resp, MAX_CONTEXT_BYTES)
644 .await
645 .map(|(v, _)| v)
646 }
647
648 // ── Lineage ──────────────────────────────────────────────────────────────
649
650 /// Retrieve all contexts in a lineage (oldest to newest).
651 pub async fn lineage(&self, lineage_id: &LineageId) -> Result<Vec<FullContext>, AcdpError> {
652 self.check_discovery_budget()?;
653 let encoded = urlencoding::encode(lineage_id.as_str());
654 let url = format!("{}/lineages/{}", self.base, encoded);
655 let resp = self.send_read(self.http.get(&url)).await?;
656 let (value, nbytes) = self
657 .parse_success::<serde_json::Value>(resp, MAX_CONTEXT_BYTES)
658 .await?;
659 self.record_discovery_bytes(nbytes);
660 serde_json::from_value(value).map_err(malformed_response)
661 }
662
663 /// Retrieve the current (latest) context in a lineage.
664 pub async fn current(&self, lineage_id: &LineageId) -> Result<FullContext, AcdpError> {
665 let encoded = urlencoding::encode(lineage_id.as_str());
666 let url = format!("{}/lineages/{}/current", self.base, encoded);
667 let resp = self.send_read(self.http.get(&url)).await?;
668 self.parse_success(resp, MAX_CONTEXT_BYTES)
669 .await
670 .map(|(v, _)| v)
671 }
672
673 // ── Changes feed (RFC-ACDP-0018) ─────────────────────────────────────────
674
675 /// One page of the registry's changes feed (`GET /changes`,
676 /// RFC-ACDP-0018 §5): the entries after `since` that match `filter`,
677 /// at most `limit` of them (the registry default, 100, when `None`).
678 ///
679 /// The response is checked against the closed schema (every entry,
680 /// cursor bounds, ascending `seq`), not verified: each entry is a
681 /// hint until [`crate::recall::verify_change`] passes. A cursor older
682 /// than the registry's retention is [`AcdpError::CursorExpired`]: the
683 /// consumer has missed an unknown set of entries, must re-verify every
684 /// watched context directly, then resume from [`crate::Since::Head`]
685 /// (§7 "Resync"). Body capped at 1 MB.
686 pub async fn changes(
687 &self,
688 since: &crate::recall::Since,
689 filter: &acdp_types::change::WatchFilter,
690 limit: Option<u32>,
691 ) -> Result<acdp_types::change::ChangesResponse, AcdpError> {
692 let url = format!("{}/changes", self.base);
693 let mut query = filter.query_pairs();
694 if let Some(s) = since.as_query() {
695 query.push(("since", s.to_owned()));
696 }
697 if let Some(l) = limit {
698 query.push(("limit", l.to_string()));
699 }
700 let resp = self.send_read(self.http.get(&url).query(&query)).await?;
701 let (value, _) = self
702 .parse_success::<serde_json::Value>(resp, MAX_CONTEXT_BYTES)
703 .await?;
704 acdp_types::change::ChangesResponse::from_value(&value)
705 }
706
707 // ── Discovery ────────────────────────────────────────────────────────────
708
709 /// Keyword search across the registry.
710 ///
711 /// Body capped at 64 KB (search responses are projection-summaries —
712 /// IMP-03: not the 1 MB context cap).
713 pub async fn search(&self, params: &SearchParams) -> Result<SearchResponse, AcdpError> {
714 self.check_discovery_budget()?;
715 let url = format!("{}/contexts/search", self.base);
716 let resp = self.send_read(self.http.get(&url).query(params)).await?;
717 let (result, nbytes) = self.parse_success(resp, MAX_METADATA_BYTES).await?;
718 self.record_discovery_bytes(nbytes);
719 Ok(result)
720 }
721
722 /// Begin a fluent search via [`RegistrySearch`]. Chains parameters
723 /// with strong typing, then `.send().await` issues the request.
724 ///
725 /// ```no_run
726 /// # async fn ex(client: &acdp_client::RegistryClient) -> Result<(), acdp_primitives::AcdpError> {
727 /// let resp = client
728 /// .search_builder()
729 /// .q("market risk")
730 /// .tag("risk")
731 /// .tag("portfolio")
732 /// .limit(50)
733 /// .send()
734 /// .await?;
735 /// # let _ = resp; Ok(()) }
736 /// ```
737 pub fn search_builder(&self) -> RegistrySearch<'_> {
738 RegistrySearch::new(self)
739 }
740}
741
742// ── Keyless signing (RFC-ACDP-0021, 0.5.0 Draft) ─────────────────────────────
743
744#[cfg(feature = "keyless")]
745impl RegistryClient {
746 /// `POST /key-attestations` (RFC-ACDP-0021 §5.1): ask the registry to
747 /// attest an ephemeral key. Returns the attestation's full retrieval
748 /// representation, including its `registry_receipt`; pass its body to
749 /// `acdp_producer::keyless::EphemeralKey::into_producer`.
750 ///
751 /// Refusals keep their `details.reason`: read it with
752 /// [`acdp_types::keyless::KeylessReason::of`].
753 pub async fn request_key_attestation(
754 &self,
755 req: &acdp_types::keyless::KeyAttestationRequest,
756 ) -> Result<FullContext, AcdpError> {
757 req.validate()?;
758 let url = format!("{}/key-attestations", self.base);
759 let resp = self
760 .http
761 .post(&url)
762 .header("Content-Type", "application/acdp+json")
763 .json(req)
764 .send()
765 .await?;
766 self.parse_success(resp, MAX_CONTEXT_BYTES)
767 .await
768 .map(|(v, _)| v)
769 }
770
771 /// `GET /key-attestations` (RFC-ACDP-0021 §5.4): the attestations the
772 /// registry minted for `controller`, newest first, optionally for one
773 /// identity. A controller uses it to find every key attested for a
774 /// leaked identity and revoke each (§9.3).
775 pub async fn list_key_attestations(
776 &self,
777 controller: &str,
778 identity: Option<&acdp_types::keyless::WorkloadIdentity>,
779 cursor: Option<&str>,
780 ) -> Result<acdp_types::keyless::KeyAttestationList, AcdpError> {
781 use acdp_types::keyless::WorkloadIdentity;
782 let mut query: Vec<(&str, &str)> = vec![("controller", controller)];
783 match identity {
784 Some(WorkloadIdentity::Oidc { issuer, subject }) => {
785 query.push(("issuer", issuer));
786 query.push(("subject", subject));
787 }
788 Some(WorkloadIdentity::Spiffe { subject }) => query.push(("subject", subject)),
789 None => {}
790 }
791 if let Some(c) = cursor {
792 query.push(("cursor", c));
793 }
794 let url = format!("{}/key-attestations", self.base);
795 let resp = self.send_read(self.http.get(&url).query(&query)).await?;
796 self.parse_success(resp, MAX_CONTEXT_BYTES)
797 .await
798 .map(|(v, _)| v)
799 }
800}
801
802/// Classify a registry document that does not deserialize (not JSON, a
803/// closed-shape object with an undefined field, a `null` in a non-nullable
804/// slot, a missing required field) as `schema_violation`. RFC-ACDP-0007 §3.3.1:
805/// "Conformant consumers MUST reject deserializing a closed-schema object that
806/// contains fields not defined in the schema (schema_violation)", and the
807/// `schema-*` fixtures pin `consumer_error: schema_violation`. Refusing at
808/// serde time is conformant; the surfaced category must still be
809/// `SchemaViolation`, never the generic `Serialization` (reserved for local
810/// encoding failures).
811fn malformed_response(e: serde_json::Error) -> AcdpError {
812 AcdpError::SchemaViolation(format!("malformed registry response: {e}"))
813}
814
815/// `Accept` sent on every registry request: the canonical ACDP media
816/// type (RFC-ACDP-0001 §5.1, `registries/media-types.md`), with
817/// `application/json` as the lower-preference legacy fallback that
818/// registries may also emit. Per-request headers (e.g. `Content-Type`,
819/// `If-None-Match`) are added on top.
820pub const ACDP_ACCEPT: &str = "application/acdp+json, application/json;q=0.9";
821
822fn acdp_default_headers() -> reqwest::header::HeaderMap {
823 let mut h = reqwest::header::HeaderMap::new();
824 h.insert(
825 reqwest::header::ACCEPT,
826 reqwest::header::HeaderValue::from_static(ACDP_ACCEPT),
827 );
828 h
829}
830
831/// Same-authority + redirect-cap policy shared by every
832/// [`RegistryClient`] HTTP client (RFC-ACDP-0006 §7.5 / RFC-ACDP-0008
833/// §4.8): at most [`MAX_REDIRECTS`] follows, and each follow must stay
834/// on the original request's scheme + host + port.
835fn redirect_policy() -> redirect::Policy {
836 redirect::Policy::custom(move |attempt| {
837 if attempt.previous().len() >= MAX_REDIRECTS {
838 return attempt.error(format!(
839 "exceeded {MAX_REDIRECTS} redirects per RFC-ACDP-0006 §7.5"
840 ));
841 }
842 // Same-authority enforcement (scheme + host + port) against the
843 // original request URL. RFC-ACDP-0008 §4.8.
844 let cross = attempt
845 .previous()
846 .first()
847 .filter(|orig| !acdp_safe_http::same_fetch_authority(orig, attempt.url()))
848 .map(|orig| (orig.to_string(), attempt.url().to_string()));
849 if let Some((from, to)) = cross {
850 return attempt.error(format!(
851 "cross-authority redirect rejected ({from} -> {to})"
852 ));
853 }
854 attempt.follow()
855 })
856}
857
858/// Builder for the non-default [`RegistryClient`] connection postures.
859///
860/// Start from [`RegistryClient::builder`]. The defaults match
861/// [`RegistryClient::new`] exactly — the stronger `SafeDnsResolver`
862/// DNS-hook posture (RFC-ACDP-0006 §7.6), the default [`SsrfPolicy`],
863/// and the RFC-ACDP-0006 §7.4 timeouts (5s connect, 30s total) — so a
864/// bare `builder(url).build().await` is equivalent to `new(url)`. Each
865/// setter changes exactly one knob:
866///
867/// - [`Self::pinned`] — pin-once resolution instead of the DNS hook.
868/// - [`Self::ssrf_policy`] — a custom SSRF policy (e.g. a test policy).
869/// - [`Self::root_cert_pem`] — trust an extra PEM root (private CA).
870/// - [`Self::connect_timeout`] / [`Self::request_timeout`] — override
871/// the RFC default timeouts.
872pub struct RegistryClientBuilder {
873 base_url: String,
874 pinned: bool,
875 ssrf_policy: SsrfPolicy,
876 root_cert_pem: Option<Vec<u8>>,
877 /// Test-only: pin `<authority>` to a fixed socket via reqwest's
878 /// `.resolve()`. Only set through the private `RegistryClient::build`
879 /// shim (never via the public `builder()` surface).
880 resolve_target: Option<std::net::SocketAddr>,
881 connect_timeout: Duration,
882 request_timeout: Duration,
883 auth: Option<Arc<dyn AuthProvider>>,
884}
885
886impl RegistryClientBuilder {
887 fn new(base_url: &str) -> Self {
888 Self {
889 base_url: base_url.to_string(),
890 pinned: false,
891 ssrf_policy: SsrfPolicy::default(),
892 root_cert_pem: None,
893 resolve_target: None,
894 connect_timeout: CONNECT_TIMEOUT,
895 request_timeout: REQUEST_TIMEOUT,
896 auth: None,
897 }
898 }
899
900 /// Present read credentials from `provider` (RFC-ACDP-0008 §6.2). See
901 /// [`RegistryClient::with_auth_provider`].
902 pub fn auth_provider(mut self, provider: Arc<dyn AuthProvider>) -> Self {
903 self.auth = Some(provider);
904 self
905 }
906
907 /// Select **pin-once** resolution (RFC-ACDP-0006 §7.6): resolve the
908 /// authority's DNS a single time, validate that answer against the
909 /// SSRF policy, and pin the connection to it. Weaker than the
910 /// default DNS-hook posture (which re-validates every connection) —
911 /// prefer the default unless you specifically need pin-once.
912 pub fn pinned(mut self, pinned: bool) -> Self {
913 self.pinned = pinned;
914 self
915 }
916
917 /// Override the [`SsrfPolicy`] applied to the base URL and to every
918 /// resolved IP. Defaults to [`SsrfPolicy::default`].
919 pub fn ssrf_policy(mut self, policy: SsrfPolicy) -> Self {
920 self.ssrf_policy = policy;
921 self
922 }
923
924 /// Trust an additional PEM-encoded root certificate in addition to
925 /// the system roots (e.g. a private/corporate CA).
926 pub fn root_cert_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
927 self.root_cert_pem = Some(pem.into());
928 self
929 }
930
931 /// Override the connect timeout (default: RFC-ACDP-0006 §7.4, 5s).
932 pub fn connect_timeout(mut self, timeout: Duration) -> Self {
933 self.connect_timeout = timeout;
934 self
935 }
936
937 /// Override the total request timeout (default: RFC-ACDP-0006 §7.4,
938 /// 30s).
939 pub fn request_timeout(mut self, timeout: Duration) -> Self {
940 self.request_timeout = timeout;
941 self
942 }
943
944 /// Build the [`RegistryClient`].
945 ///
946 /// Async because [`Self::pinned`] mode resolves DNS up front. The
947 /// default (DNS-hook) mode does no async work — it is constructed
948 /// synchronously internally.
949 pub async fn build(self) -> Result<RegistryClient, AcdpError> {
950 if !self.pinned {
951 return self.build_blocking();
952 }
953
954 // ── Pin-once mode (RFC-ACDP-0006 §7.6) ──────────────────────
955 let base = self.base_url.trim_end_matches('/').to_string();
956 let parsed = url::Url::parse(&base)
957 .map_err(|e| AcdpError::SchemaViolation(format!("invalid base URL: {e}")))?;
958 // Pre-flight: scheme + host range checks via the same policy.
959 self.ssrf_policy.check_url(&base)?;
960 let host = parsed
961 .host_str()
962 .ok_or_else(|| AcdpError::SchemaViolation(format!("base URL has no host: {base}")))?
963 .to_string();
964 let port = parsed
965 .port_or_known_default()
966 .unwrap_or(if parsed.scheme() == "http" { 80 } else { 443 });
967 let pinned = self.ssrf_policy.pin_resolved_ip(&host, port).await?;
968
969 let mut builder = Client::builder()
970 .use_rustls_tls()
971 .connect_timeout(self.connect_timeout)
972 .timeout(self.request_timeout)
973 .redirect(redirect_policy())
974 .default_headers(acdp_default_headers())
975 .resolve(&host, pinned);
976 builder = Self::apply_root_cert(builder, self.root_cert_pem.as_deref())?;
977
978 let http = builder
979 .build()
980 .map_err(|e| AcdpError::Http(e.to_string()))?;
981 Ok(RegistryClient {
982 base,
983 http,
984 budget: None,
985 revocation_cache: None,
986 revocation_freshness: Duration::ZERO,
987 auth: self.auth,
988 })
989 }
990
991 /// Synchronous build for the default DNS-hook posture (no pinning).
992 /// The public sync constructors ([`RegistryClient::new`] and the
993 /// test-transport factories) route through here.
994 fn build_blocking(self) -> Result<RegistryClient, AcdpError> {
995 debug_assert!(
996 !self.pinned,
997 "build_blocking is DNS-hook only; pinned mode must use the async build()"
998 );
999 let base = self.base_url.trim_end_matches('/').to_string();
1000 // RFC-ACDP-0006 §7 / RFC-ACDP-0008 §4.8–4.9: reject non-HTTPS,
1001 // IP-literal, and malformed base URLs up front, then filter every
1002 // resolved IP at DNS time (below) so DNS-rebinding answers in
1003 // forbidden ranges are refused before connect.
1004 self.ssrf_policy.check_url(&base)?;
1005 let original_authority = url::Url::parse(&base)
1006 .ok()
1007 .and_then(|u| u.host_str().map(str::to_string));
1008
1009 let mut builder = Client::builder()
1010 .use_rustls_tls()
1011 .connect_timeout(self.connect_timeout)
1012 .timeout(self.request_timeout)
1013 .redirect(redirect_policy())
1014 .default_headers(acdp_default_headers())
1015 // DNS-time SSRF filtering for every connection (incl. redirects
1016 // and reconnects), defeating DNS rebinding — RFC-ACDP-0006 §7.6.
1017 // Mirrors `WebResolver::build_http_client` / `HttpsDataRefFetcher`.
1018 .dns_resolver(acdp_safe_http::SafeDnsResolver::arc(self.ssrf_policy));
1019 builder = Self::apply_root_cert(builder, self.root_cert_pem.as_deref())?;
1020
1021 if let (Some(target), Some(host)) = (self.resolve_target, original_authority) {
1022 builder = builder.resolve(&host, target);
1023 }
1024
1025 let http = builder
1026 .build()
1027 .map_err(|e| AcdpError::Http(e.to_string()))?;
1028 Ok(RegistryClient {
1029 base,
1030 http,
1031 budget: None,
1032 revocation_cache: None,
1033 revocation_freshness: Duration::ZERO,
1034 auth: self.auth,
1035 })
1036 }
1037
1038 fn apply_root_cert(
1039 builder: reqwest::ClientBuilder,
1040 pem: Option<&[u8]>,
1041 ) -> Result<reqwest::ClientBuilder, AcdpError> {
1042 let Some(pem) = pem else {
1043 return Ok(builder);
1044 };
1045 let cert = reqwest::Certificate::from_pem(pem)
1046 .map_err(|e| AcdpError::Http(format!("invalid root cert PEM: {e}")))?;
1047 Ok(builder.add_root_certificate(cert))
1048 }
1049}
1050
1051/// Fluent search builder bound to a [`RegistryClient`]. See
1052/// [`RegistryClient::search_builder`].
1053pub struct RegistrySearch<'a> {
1054 client: &'a RegistryClient,
1055 inner: acdp_types::search::SearchParamsBuilder,
1056}
1057
1058impl<'a> RegistrySearch<'a> {
1059 fn new(client: &'a RegistryClient) -> Self {
1060 Self {
1061 client,
1062 inner: acdp_types::search::SearchParamsBuilder::new(),
1063 }
1064 }
1065
1066 /// Issue the search.
1067 pub async fn send(self) -> Result<SearchResponse, AcdpError> {
1068 let params = self.inner.build();
1069 self.client.search(¶ms).await
1070 }
1071 /// Full-text query.
1072 pub fn q(mut self, q: impl Into<String>) -> Self {
1073 self.inner = self.inner.q(q);
1074 self
1075 }
1076 /// Filter on `type`.
1077 pub fn context_type(mut self, t: impl Into<String>) -> Self {
1078 self.inner = self.inner.context_type(t);
1079 self
1080 }
1081 /// Filter on `domain`.
1082 pub fn domain(mut self, d: impl Into<String>) -> Self {
1083 self.inner = self.inner.domain(d);
1084 self
1085 }
1086 /// Accumulate a tag.
1087 pub fn tag(mut self, t: impl Into<String>) -> Self {
1088 self.inner = self.inner.tag(t);
1089 self
1090 }
1091 /// Filter on `agent_id`.
1092 pub fn agent_id(mut self, a: impl Into<String>) -> Self {
1093 self.inner = self.inner.agent_id(a);
1094 self
1095 }
1096 /// Filter on `derived_from` (strongly typed).
1097 pub fn derived_from(mut self, c: &acdp_types::CtxId) -> Self {
1098 self.inner = self.inner.derived_from_ctx_id(c);
1099 self
1100 }
1101 /// Lower bound on `created_at`.
1102 pub fn created_after(mut self, dt: chrono::DateTime<chrono::Utc>) -> Self {
1103 self.inner = self.inner.created_after(dt);
1104 self
1105 }
1106 /// Upper bound on `created_at`.
1107 pub fn created_before(mut self, dt: chrono::DateTime<chrono::Utc>) -> Self {
1108 self.inner = self.inner.created_before(dt);
1109 self
1110 }
1111 /// Status filter.
1112 pub fn status(mut self, s: impl Into<String>) -> Self {
1113 self.inner = self.inner.status(s);
1114 self
1115 }
1116 /// Result page size cap.
1117 pub fn limit(mut self, l: u32) -> Self {
1118 self.inner = self.inner.limit(l);
1119 self
1120 }
1121 /// Pagination cursor.
1122 pub fn cursor(mut self, c: impl Into<String>) -> Self {
1123 self.inner = self.inner.cursor(c);
1124 self
1125 }
1126}
1127
1128// ── Internal helpers on RegistryClient ───────────────────────────────────────
1129
1130impl RegistryClient {
1131 /// Returns the parsed value alongside the exact byte count read
1132 /// from a *successful* response body (issue #258: this is what
1133 /// lets `RegistryClient`'s discovery-reachable methods charge the
1134 /// caller-configured byte budget the exact count `read_body_capped`
1135 /// already computes, instead of discarding it). The non-success
1136 /// branch's error-envelope read is never counted — see
1137 /// `DiscoveryBudget`'s doc.
1138 async fn parse_success<T: serde::de::DeserializeOwned>(
1139 &self,
1140 resp: reqwest::Response,
1141 max_bytes: usize,
1142 ) -> Result<(T, usize), AcdpError> {
1143 if resp.status().is_success() {
1144 let bytes = read_body_capped(resp, max_bytes).await?;
1145 let value = serde_json::from_slice(&bytes).map_err(malformed_response)?;
1146 Ok((value, bytes.len()))
1147 } else {
1148 // Error envelopes are tiny — apply the metadata cap so a
1149 // hostile registry can't exhaust memory via the error path.
1150 let bytes = match read_body_capped(resp, MAX_METADATA_BYTES).await {
1151 Ok(b) => b,
1152 Err(_) => {
1153 return Err(AcdpError::from_wire_error(WireError {
1154 error: acdp_types::publish::WireErrorBody {
1155 code: "unknown".into(),
1156 message: "could not read registry error response".into(),
1157 details: None,
1158 },
1159 }));
1160 }
1161 };
1162 let wire: WireError = serde_json::from_slice(&bytes).unwrap_or_else(|_| WireError {
1163 error: acdp_types::publish::WireErrorBody {
1164 code: "unknown".into(),
1165 message: "could not parse registry error response".into(),
1166 details: None,
1167 },
1168 });
1169 Err(AcdpError::from_wire_error(wire))
1170 }
1171 }
1172}
1173
1174/// Extract the cache TTL for a capabilities response per
1175/// RFC-ACDP-0006 §4.2 — `min(Cache-Control: max-age=N, 3600s)`.
1176///
1177/// Falls back to a conservative 300s when no parseable `max-age`
1178/// directive is present (matches [`crate::ResolverOptions::capabilities_ttl`]'s
1179/// default so behavior is identical to the pre-BUG-09 code path on
1180/// silent registries).
1181fn cache_ttl_from_response(resp: &reqwest::Response) -> std::time::Duration {
1182 const MAX_CAPS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
1183 const DEFAULT_CAPS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
1184
1185 let Some(cc) = resp
1186 .headers()
1187 .get(reqwest::header::CACHE_CONTROL)
1188 .and_then(|v| v.to_str().ok())
1189 else {
1190 return DEFAULT_CAPS_CACHE_TTL;
1191 };
1192 for directive in cc.split(',') {
1193 let directive = directive.trim();
1194 if let Some(value) = directive
1195 .strip_prefix("max-age=")
1196 .or_else(|| directive.strip_prefix("s-maxage="))
1197 {
1198 if let Ok(secs) = value.parse::<u64>() {
1199 return std::time::Duration::from_secs(secs).min(MAX_CAPS_CACHE_TTL);
1200 }
1201 }
1202 }
1203 DEFAULT_CAPS_CACHE_TTL
1204}
1205
1206fn parse_retrieval_metadata(resp: &reqwest::Response) -> RetrievalMetadata {
1207 let headers = resp.headers();
1208 let etag = headers
1209 .get(reqwest::header::ETAG)
1210 .and_then(|v| v.to_str().ok())
1211 .map(|s| s.to_string());
1212 let cache_control = headers
1213 .get(reqwest::header::CACHE_CONTROL)
1214 .and_then(|v| v.to_str().ok())
1215 .map(|s| s.to_string());
1216 let last_modified = headers
1217 .get(reqwest::header::LAST_MODIFIED)
1218 .and_then(|v| v.to_str().ok())
1219 .and_then(|s| {
1220 DateTime::parse_from_rfc2822(s)
1221 .ok()
1222 .map(|dt| dt.with_timezone(&Utc))
1223 });
1224 RetrievalMetadata {
1225 etag,
1226 cache_control,
1227 last_modified,
1228 }
1229}
1230
1231/// Read the response body, aborting if the running total exceeds
1232/// `max_bytes`. Returns [`AcdpError::PayloadTooLarge`] on overflow.
1233async fn read_body_capped(
1234 mut resp: reqwest::Response,
1235 max_bytes: usize,
1236) -> Result<Vec<u8>, AcdpError> {
1237 if let Some(len) = resp.content_length() {
1238 if len as usize > max_bytes {
1239 return Err(AcdpError::PayloadTooLarge(format!(
1240 "response Content-Length {len} exceeds cap {max_bytes}"
1241 )));
1242 }
1243 }
1244 let mut buf = Vec::with_capacity(8 * 1024);
1245 while let Some(chunk) = resp
1246 .chunk()
1247 .await
1248 .map_err(|e| AcdpError::Http(e.to_string()))?
1249 {
1250 if buf.len() + chunk.len() > max_bytes {
1251 return Err(AcdpError::PayloadTooLarge(format!(
1252 "response body exceeded {max_bytes} bytes"
1253 )));
1254 }
1255 buf.extend_from_slice(&chunk);
1256 }
1257 Ok(buf)
1258}