Skip to main content

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/// Classify a registry document that does not deserialize (not JSON, a
743/// closed-shape object with an undefined field, a `null` in a non-nullable
744/// slot, a missing required field) as `schema_violation`. RFC-ACDP-0007 §3.3.1:
745/// "Conformant consumers MUST reject deserializing a closed-schema object that
746/// contains fields not defined in the schema (schema_violation)", and the
747/// `schema-*` fixtures pin `consumer_error: schema_violation`. Refusing at
748/// serde time is conformant; the surfaced category must still be
749/// `SchemaViolation`, never the generic `Serialization` (reserved for local
750/// encoding failures).
751fn malformed_response(e: serde_json::Error) -> AcdpError {
752    AcdpError::SchemaViolation(format!("malformed registry response: {e}"))
753}
754
755/// `Accept` sent on every registry request: the canonical ACDP media
756/// type (RFC-ACDP-0001 §5.1, `registries/media-types.md`), with
757/// `application/json` as the lower-preference legacy fallback that
758/// registries may also emit. Per-request headers (e.g. `Content-Type`,
759/// `If-None-Match`) are added on top.
760pub const ACDP_ACCEPT: &str = "application/acdp+json, application/json;q=0.9";
761
762fn acdp_default_headers() -> reqwest::header::HeaderMap {
763    let mut h = reqwest::header::HeaderMap::new();
764    h.insert(
765        reqwest::header::ACCEPT,
766        reqwest::header::HeaderValue::from_static(ACDP_ACCEPT),
767    );
768    h
769}
770
771/// Same-authority + redirect-cap policy shared by every
772/// [`RegistryClient`] HTTP client (RFC-ACDP-0006 §7.5 / RFC-ACDP-0008
773/// §4.8): at most [`MAX_REDIRECTS`] follows, and each follow must stay
774/// on the original request's scheme + host + port.
775fn redirect_policy() -> redirect::Policy {
776    redirect::Policy::custom(move |attempt| {
777        if attempt.previous().len() >= MAX_REDIRECTS {
778            return attempt.error(format!(
779                "exceeded {MAX_REDIRECTS} redirects per RFC-ACDP-0006 §7.5"
780            ));
781        }
782        // Same-authority enforcement (scheme + host + port) against the
783        // original request URL. RFC-ACDP-0008 §4.8.
784        let cross = attempt
785            .previous()
786            .first()
787            .filter(|orig| !acdp_safe_http::same_fetch_authority(orig, attempt.url()))
788            .map(|orig| (orig.to_string(), attempt.url().to_string()));
789        if let Some((from, to)) = cross {
790            return attempt.error(format!(
791                "cross-authority redirect rejected ({from} -> {to})"
792            ));
793        }
794        attempt.follow()
795    })
796}
797
798/// Builder for the non-default [`RegistryClient`] connection postures.
799///
800/// Start from [`RegistryClient::builder`]. The defaults match
801/// [`RegistryClient::new`] exactly — the stronger `SafeDnsResolver`
802/// DNS-hook posture (RFC-ACDP-0006 §7.6), the default [`SsrfPolicy`],
803/// and the RFC-ACDP-0006 §7.4 timeouts (5s connect, 30s total) — so a
804/// bare `builder(url).build().await` is equivalent to `new(url)`. Each
805/// setter changes exactly one knob:
806///
807/// - [`Self::pinned`] — pin-once resolution instead of the DNS hook.
808/// - [`Self::ssrf_policy`] — a custom SSRF policy (e.g. a test policy).
809/// - [`Self::root_cert_pem`] — trust an extra PEM root (private CA).
810/// - [`Self::connect_timeout`] / [`Self::request_timeout`] — override
811///   the RFC default timeouts.
812pub struct RegistryClientBuilder {
813    base_url: String,
814    pinned: bool,
815    ssrf_policy: SsrfPolicy,
816    root_cert_pem: Option<Vec<u8>>,
817    /// Test-only: pin `<authority>` to a fixed socket via reqwest's
818    /// `.resolve()`. Only set through the private `RegistryClient::build`
819    /// shim (never via the public `builder()` surface).
820    resolve_target: Option<std::net::SocketAddr>,
821    connect_timeout: Duration,
822    request_timeout: Duration,
823    auth: Option<Arc<dyn AuthProvider>>,
824}
825
826impl RegistryClientBuilder {
827    fn new(base_url: &str) -> Self {
828        Self {
829            base_url: base_url.to_string(),
830            pinned: false,
831            ssrf_policy: SsrfPolicy::default(),
832            root_cert_pem: None,
833            resolve_target: None,
834            connect_timeout: CONNECT_TIMEOUT,
835            request_timeout: REQUEST_TIMEOUT,
836            auth: None,
837        }
838    }
839
840    /// Present read credentials from `provider` (RFC-ACDP-0008 §6.2). See
841    /// [`RegistryClient::with_auth_provider`].
842    pub fn auth_provider(mut self, provider: Arc<dyn AuthProvider>) -> Self {
843        self.auth = Some(provider);
844        self
845    }
846
847    /// Select **pin-once** resolution (RFC-ACDP-0006 §7.6): resolve the
848    /// authority's DNS a single time, validate that answer against the
849    /// SSRF policy, and pin the connection to it. Weaker than the
850    /// default DNS-hook posture (which re-validates every connection) —
851    /// prefer the default unless you specifically need pin-once.
852    pub fn pinned(mut self, pinned: bool) -> Self {
853        self.pinned = pinned;
854        self
855    }
856
857    /// Override the [`SsrfPolicy`] applied to the base URL and to every
858    /// resolved IP. Defaults to [`SsrfPolicy::default`].
859    pub fn ssrf_policy(mut self, policy: SsrfPolicy) -> Self {
860        self.ssrf_policy = policy;
861        self
862    }
863
864    /// Trust an additional PEM-encoded root certificate in addition to
865    /// the system roots (e.g. a private/corporate CA).
866    pub fn root_cert_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
867        self.root_cert_pem = Some(pem.into());
868        self
869    }
870
871    /// Override the connect timeout (default: RFC-ACDP-0006 §7.4, 5s).
872    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
873        self.connect_timeout = timeout;
874        self
875    }
876
877    /// Override the total request timeout (default: RFC-ACDP-0006 §7.4,
878    /// 30s).
879    pub fn request_timeout(mut self, timeout: Duration) -> Self {
880        self.request_timeout = timeout;
881        self
882    }
883
884    /// Build the [`RegistryClient`].
885    ///
886    /// Async because [`Self::pinned`] mode resolves DNS up front. The
887    /// default (DNS-hook) mode does no async work — it is constructed
888    /// synchronously internally.
889    pub async fn build(self) -> Result<RegistryClient, AcdpError> {
890        if !self.pinned {
891            return self.build_blocking();
892        }
893
894        // ── Pin-once mode (RFC-ACDP-0006 §7.6) ──────────────────────
895        let base = self.base_url.trim_end_matches('/').to_string();
896        let parsed = url::Url::parse(&base)
897            .map_err(|e| AcdpError::SchemaViolation(format!("invalid base URL: {e}")))?;
898        // Pre-flight: scheme + host range checks via the same policy.
899        self.ssrf_policy.check_url(&base)?;
900        let host = parsed
901            .host_str()
902            .ok_or_else(|| AcdpError::SchemaViolation(format!("base URL has no host: {base}")))?
903            .to_string();
904        let port = parsed
905            .port_or_known_default()
906            .unwrap_or(if parsed.scheme() == "http" { 80 } else { 443 });
907        let pinned = self.ssrf_policy.pin_resolved_ip(&host, port).await?;
908
909        let mut builder = Client::builder()
910            .use_rustls_tls()
911            .connect_timeout(self.connect_timeout)
912            .timeout(self.request_timeout)
913            .redirect(redirect_policy())
914            .default_headers(acdp_default_headers())
915            .resolve(&host, pinned);
916        builder = Self::apply_root_cert(builder, self.root_cert_pem.as_deref())?;
917
918        let http = builder
919            .build()
920            .map_err(|e| AcdpError::Http(e.to_string()))?;
921        Ok(RegistryClient {
922            base,
923            http,
924            budget: None,
925            revocation_cache: None,
926            revocation_freshness: Duration::ZERO,
927            auth: self.auth,
928        })
929    }
930
931    /// Synchronous build for the default DNS-hook posture (no pinning).
932    /// The public sync constructors ([`RegistryClient::new`] and the
933    /// test-transport factories) route through here.
934    fn build_blocking(self) -> Result<RegistryClient, AcdpError> {
935        debug_assert!(
936            !self.pinned,
937            "build_blocking is DNS-hook only; pinned mode must use the async build()"
938        );
939        let base = self.base_url.trim_end_matches('/').to_string();
940        // RFC-ACDP-0006 §7 / RFC-ACDP-0008 §4.8–4.9: reject non-HTTPS,
941        // IP-literal, and malformed base URLs up front, then filter every
942        // resolved IP at DNS time (below) so DNS-rebinding answers in
943        // forbidden ranges are refused before connect.
944        self.ssrf_policy.check_url(&base)?;
945        let original_authority = url::Url::parse(&base)
946            .ok()
947            .and_then(|u| u.host_str().map(str::to_string));
948
949        let mut builder = Client::builder()
950            .use_rustls_tls()
951            .connect_timeout(self.connect_timeout)
952            .timeout(self.request_timeout)
953            .redirect(redirect_policy())
954            .default_headers(acdp_default_headers())
955            // DNS-time SSRF filtering for every connection (incl. redirects
956            // and reconnects), defeating DNS rebinding — RFC-ACDP-0006 §7.6.
957            // Mirrors `WebResolver::build_http_client` / `HttpsDataRefFetcher`.
958            .dns_resolver(acdp_safe_http::SafeDnsResolver::arc(self.ssrf_policy));
959        builder = Self::apply_root_cert(builder, self.root_cert_pem.as_deref())?;
960
961        if let (Some(target), Some(host)) = (self.resolve_target, original_authority) {
962            builder = builder.resolve(&host, target);
963        }
964
965        let http = builder
966            .build()
967            .map_err(|e| AcdpError::Http(e.to_string()))?;
968        Ok(RegistryClient {
969            base,
970            http,
971            budget: None,
972            revocation_cache: None,
973            revocation_freshness: Duration::ZERO,
974            auth: self.auth,
975        })
976    }
977
978    fn apply_root_cert(
979        builder: reqwest::ClientBuilder,
980        pem: Option<&[u8]>,
981    ) -> Result<reqwest::ClientBuilder, AcdpError> {
982        let Some(pem) = pem else {
983            return Ok(builder);
984        };
985        let cert = reqwest::Certificate::from_pem(pem)
986            .map_err(|e| AcdpError::Http(format!("invalid root cert PEM: {e}")))?;
987        Ok(builder.add_root_certificate(cert))
988    }
989}
990
991/// Fluent search builder bound to a [`RegistryClient`]. See
992/// [`RegistryClient::search_builder`].
993pub struct RegistrySearch<'a> {
994    client: &'a RegistryClient,
995    inner: acdp_types::search::SearchParamsBuilder,
996}
997
998impl<'a> RegistrySearch<'a> {
999    fn new(client: &'a RegistryClient) -> Self {
1000        Self {
1001            client,
1002            inner: acdp_types::search::SearchParamsBuilder::new(),
1003        }
1004    }
1005
1006    /// Issue the search.
1007    pub async fn send(self) -> Result<SearchResponse, AcdpError> {
1008        let params = self.inner.build();
1009        self.client.search(&params).await
1010    }
1011    /// Full-text query.
1012    pub fn q(mut self, q: impl Into<String>) -> Self {
1013        self.inner = self.inner.q(q);
1014        self
1015    }
1016    /// Filter on `type`.
1017    pub fn context_type(mut self, t: impl Into<String>) -> Self {
1018        self.inner = self.inner.context_type(t);
1019        self
1020    }
1021    /// Filter on `domain`.
1022    pub fn domain(mut self, d: impl Into<String>) -> Self {
1023        self.inner = self.inner.domain(d);
1024        self
1025    }
1026    /// Accumulate a tag.
1027    pub fn tag(mut self, t: impl Into<String>) -> Self {
1028        self.inner = self.inner.tag(t);
1029        self
1030    }
1031    /// Filter on `agent_id`.
1032    pub fn agent_id(mut self, a: impl Into<String>) -> Self {
1033        self.inner = self.inner.agent_id(a);
1034        self
1035    }
1036    /// Filter on `derived_from` (strongly typed).
1037    pub fn derived_from(mut self, c: &acdp_types::CtxId) -> Self {
1038        self.inner = self.inner.derived_from_ctx_id(c);
1039        self
1040    }
1041    /// Lower bound on `created_at`.
1042    pub fn created_after(mut self, dt: chrono::DateTime<chrono::Utc>) -> Self {
1043        self.inner = self.inner.created_after(dt);
1044        self
1045    }
1046    /// Upper bound on `created_at`.
1047    pub fn created_before(mut self, dt: chrono::DateTime<chrono::Utc>) -> Self {
1048        self.inner = self.inner.created_before(dt);
1049        self
1050    }
1051    /// Status filter.
1052    pub fn status(mut self, s: impl Into<String>) -> Self {
1053        self.inner = self.inner.status(s);
1054        self
1055    }
1056    /// Result page size cap.
1057    pub fn limit(mut self, l: u32) -> Self {
1058        self.inner = self.inner.limit(l);
1059        self
1060    }
1061    /// Pagination cursor.
1062    pub fn cursor(mut self, c: impl Into<String>) -> Self {
1063        self.inner = self.inner.cursor(c);
1064        self
1065    }
1066}
1067
1068// ── Internal helpers on RegistryClient ───────────────────────────────────────
1069
1070impl RegistryClient {
1071    /// Returns the parsed value alongside the exact byte count read
1072    /// from a *successful* response body (issue #258: this is what
1073    /// lets `RegistryClient`'s discovery-reachable methods charge the
1074    /// caller-configured byte budget the exact count `read_body_capped`
1075    /// already computes, instead of discarding it). The non-success
1076    /// branch's error-envelope read is never counted — see
1077    /// `DiscoveryBudget`'s doc.
1078    async fn parse_success<T: serde::de::DeserializeOwned>(
1079        &self,
1080        resp: reqwest::Response,
1081        max_bytes: usize,
1082    ) -> Result<(T, usize), AcdpError> {
1083        if resp.status().is_success() {
1084            let bytes = read_body_capped(resp, max_bytes).await?;
1085            let value = serde_json::from_slice(&bytes).map_err(malformed_response)?;
1086            Ok((value, bytes.len()))
1087        } else {
1088            // Error envelopes are tiny — apply the metadata cap so a
1089            // hostile registry can't exhaust memory via the error path.
1090            let bytes = match read_body_capped(resp, MAX_METADATA_BYTES).await {
1091                Ok(b) => b,
1092                Err(_) => {
1093                    return Err(AcdpError::from_wire_error(WireError {
1094                        error: acdp_types::publish::WireErrorBody {
1095                            code: "unknown".into(),
1096                            message: "could not read registry error response".into(),
1097                            details: None,
1098                        },
1099                    }));
1100                }
1101            };
1102            let wire: WireError = serde_json::from_slice(&bytes).unwrap_or_else(|_| WireError {
1103                error: acdp_types::publish::WireErrorBody {
1104                    code: "unknown".into(),
1105                    message: "could not parse registry error response".into(),
1106                    details: None,
1107                },
1108            });
1109            Err(AcdpError::from_wire_error(wire))
1110        }
1111    }
1112}
1113
1114/// Extract the cache TTL for a capabilities response per
1115/// RFC-ACDP-0006 §4.2 — `min(Cache-Control: max-age=N, 3600s)`.
1116///
1117/// Falls back to a conservative 300s when no parseable `max-age`
1118/// directive is present (matches [`crate::ResolverOptions::capabilities_ttl`]'s
1119/// default so behavior is identical to the pre-BUG-09 code path on
1120/// silent registries).
1121fn cache_ttl_from_response(resp: &reqwest::Response) -> std::time::Duration {
1122    const MAX_CAPS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
1123    const DEFAULT_CAPS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
1124
1125    let Some(cc) = resp
1126        .headers()
1127        .get(reqwest::header::CACHE_CONTROL)
1128        .and_then(|v| v.to_str().ok())
1129    else {
1130        return DEFAULT_CAPS_CACHE_TTL;
1131    };
1132    for directive in cc.split(',') {
1133        let directive = directive.trim();
1134        if let Some(value) = directive
1135            .strip_prefix("max-age=")
1136            .or_else(|| directive.strip_prefix("s-maxage="))
1137        {
1138            if let Ok(secs) = value.parse::<u64>() {
1139                return std::time::Duration::from_secs(secs).min(MAX_CAPS_CACHE_TTL);
1140            }
1141        }
1142    }
1143    DEFAULT_CAPS_CACHE_TTL
1144}
1145
1146fn parse_retrieval_metadata(resp: &reqwest::Response) -> RetrievalMetadata {
1147    let headers = resp.headers();
1148    let etag = headers
1149        .get(reqwest::header::ETAG)
1150        .and_then(|v| v.to_str().ok())
1151        .map(|s| s.to_string());
1152    let cache_control = headers
1153        .get(reqwest::header::CACHE_CONTROL)
1154        .and_then(|v| v.to_str().ok())
1155        .map(|s| s.to_string());
1156    let last_modified = headers
1157        .get(reqwest::header::LAST_MODIFIED)
1158        .and_then(|v| v.to_str().ok())
1159        .and_then(|s| {
1160            DateTime::parse_from_rfc2822(s)
1161                .ok()
1162                .map(|dt| dt.with_timezone(&Utc))
1163        });
1164    RetrievalMetadata {
1165        etag,
1166        cache_control,
1167        last_modified,
1168    }
1169}
1170
1171/// Read the response body, aborting if the running total exceeds
1172/// `max_bytes`. Returns [`AcdpError::PayloadTooLarge`] on overflow.
1173async fn read_body_capped(
1174    mut resp: reqwest::Response,
1175    max_bytes: usize,
1176) -> Result<Vec<u8>, AcdpError> {
1177    if let Some(len) = resp.content_length() {
1178        if len as usize > max_bytes {
1179            return Err(AcdpError::PayloadTooLarge(format!(
1180                "response Content-Length {len} exceeds cap {max_bytes}"
1181            )));
1182        }
1183    }
1184    let mut buf = Vec::with_capacity(8 * 1024);
1185    while let Some(chunk) = resp
1186        .chunk()
1187        .await
1188        .map_err(|e| AcdpError::Http(e.to_string()))?
1189    {
1190        if buf.len() + chunk.len() > max_bytes {
1191            return Err(AcdpError::PayloadTooLarge(format!(
1192                "response body exceeded {max_bytes} bytes"
1193            )));
1194        }
1195        buf.extend_from_slice(&chunk);
1196    }
1197    Ok(buf)
1198}