Skip to main content

acdp_client/
registry.rs

1//! HTTP client for ACDP registries (feature = "client").
2
3use std::time::Duration;
4
5use acdp_primitives::error::AcdpError;
6use acdp_primitives::limits::{
7    CONNECT_TIMEOUT, MAX_CONTEXT_BYTES, MAX_METADATA_BYTES, MAX_REDIRECTS, REQUEST_TIMEOUT,
8};
9use acdp_safe_http::SsrfPolicy;
10use acdp_types::{
11    body::FullContext,
12    capabilities::CapabilitiesDocument,
13    primitives::{CtxId, LineageId},
14    publish::{PublishRequest, PublishResponse, WireError},
15    search::{SearchParams, SearchResponse},
16};
17use chrono::{DateTime, Utc};
18use reqwest::{redirect, Client};
19
20/// HTTP client for a single ACDP registry.
21///
22/// `reqwest::Client` clones cheaply (it's an `Arc` internally), so this
23/// struct is `Clone` to enable per-authority caching in
24/// [`crate::CrossRegistryResolver`] without re-wiring HTTP+TLS
25/// state on every hop.
26#[derive(Clone)]
27pub struct RegistryClient {
28    base: String,
29    http: Client,
30}
31
32/// Cache and integrity headers returned alongside a retrieved body.
33///
34/// `etag` is the body's `content_hash` (immutable; ideal cache key).
35/// `cache_control` and `last_modified` are reported verbatim from the
36/// upstream registry.
37#[derive(Debug, Clone, Default)]
38pub struct RetrievalMetadata {
39    /// Strong validator for conditional retrieval (`If-None-Match`).
40    pub etag: Option<String>,
41    /// Raw `Cache-Control` header value, if any.
42    pub cache_control: Option<String>,
43    /// Parsed `Last-Modified` header, if any.
44    pub last_modified: Option<DateTime<Utc>>,
45}
46
47impl RegistryClient {
48    /// The authority (host, plus port when non-default) of the registry
49    /// this client talks to — the value a receipt's `registry_did`
50    /// must match (RFC-ACDP-0010 serving-authority cross-check).
51    pub fn authority(&self) -> Option<String> {
52        url::Url::parse(&self.base).ok().and_then(|u| {
53            let host = u.host_str()?.to_string();
54            Some(match u.port() {
55                Some(p) => format!("{host}:{p}"),
56                None => host,
57            })
58        })
59    }
60
61    /// Connect to a registry at `base_url` (e.g. `https://registry.example.com`).
62    ///
63    /// Uses `rustls` for TLS; does not use the system OpenSSL. Applies
64    /// the RFC-ACDP-0006 §7.4 default timeouts (5s connect, 30s total)
65    /// and §7.5 redirect policy (max 3 follows, same authority only).
66    pub fn new(base_url: &str) -> Result<Self, AcdpError> {
67        Self::build(base_url, None, None, SsrfPolicy::default())
68    }
69
70    /// Connect to a registry that trusts the given PEM-encoded root
71    /// certificate in addition to the system roots.
72    ///
73    /// Primary use is the in-process self-signed HTTPS server in the
74    /// crate's `tests/helpers/tls_did_server.rs` harness so the spec
75    /// fixtures `fed-001..006` can drive `CrossRegistryResolver`
76    /// end-to-end without going over the network.
77    #[cfg_attr(
78        not(feature = "test-transport"),
79        deprecated(
80            note = "SSRF-relaxed test-only constructor: enable the `test-transport` feature to use it without this warning; the ungated fallback is removed in 0.4.0"
81        )
82    )]
83    #[allow(deprecated)] // test-transport constructors; gated in 0.4.0
84    pub fn with_root_cert_pem(base_url: &str, pem: &[u8]) -> Result<Self, AcdpError> {
85        // Drives an in-process HTTPS server on loopback, so the SSRF
86        // policy must permit a loopback-resolved answer. All other
87        // forbidden ranges (RFC 1918, IMDS, …) still apply.
88        Self::build(base_url, Some(pem), None, SsrfPolicy::allow_test_loopback())
89    }
90
91    /// Test-only permissive transport: allows `http://`, IP-literal hosts,
92    /// and loopback so the crate's in-process mock HTTP servers (e.g.
93    /// `wiremock`, which binds `http://127.0.0.1:<port>`) can be driven.
94    ///
95    /// Production MUST use [`Self::new`], which applies the full
96    /// RFC-ACDP-0006 §7 / RFC-ACDP-0008 SSRF + HTTPS-only + DNS-rebinding
97    /// posture. This constructor exists solely to keep the test harness on
98    /// loopback HTTP.
99    #[doc(hidden)]
100    #[cfg_attr(
101        not(feature = "test-transport"),
102        deprecated(
103            note = "SSRF-relaxed test-only constructor: enable the `test-transport` feature to use it without this warning; the ungated fallback is removed in 0.4.0"
104        )
105    )]
106    pub fn with_test_transport(base_url: &str) -> Result<Self, AcdpError> {
107        let policy = SsrfPolicy {
108            reject_ip_literals: false,
109            allow_http: true,
110            allow_loopback_resolved: true,
111        };
112        Self::build(base_url, None, None, policy)
113    }
114
115    /// Connect to a registry whose `<authority>` in `base_url` is routed
116    /// to a fixed socket address. Trusts the given PEM-encoded root
117    /// certificate in addition to the system roots.
118    ///
119    /// Use only in tests: a `CrossRegistryResolver` test that wants to
120    /// drive `acdp://<host>/<uuid>` references requires `<host>` to be
121    /// a valid lowercase DNS label (per `is_valid_dns_authority` in
122    /// `types::primitives`), which precludes embedding the port in the
123    /// `ctx_id`. This factory accepts a logical hostname (e.g.
124    /// `localhost`) and pins it to the test server's actual
125    /// `127.0.0.1:<port>` via reqwest's `.resolve()` hook.
126    #[doc(hidden)]
127    #[cfg_attr(
128        not(feature = "test-transport"),
129        deprecated(
130            note = "SSRF-relaxed test-only constructor: enable the `test-transport` feature to use it without this warning; the ungated fallback is removed in 0.4.0"
131        )
132    )]
133    #[allow(deprecated)] // test-transport constructors; gated in 0.4.0
134    pub fn with_test_endpoint(
135        base_url: &str,
136        target: std::net::SocketAddr,
137        pem: &[u8],
138    ) -> Result<Self, AcdpError> {
139        // Pins a logical hostname to a loopback test endpoint; permit the
140        // loopback answer while keeping every other forbidden range live.
141        Self::build(
142            base_url,
143            Some(pem),
144            Some(target),
145            SsrfPolicy::allow_test_loopback(),
146        )
147    }
148
149    fn build(
150        base_url: &str,
151        extra_root_pem: Option<&[u8]>,
152        resolve_target: Option<std::net::SocketAddr>,
153        policy_ssrf: SsrfPolicy,
154    ) -> Result<Self, AcdpError> {
155        let base = base_url.trim_end_matches('/').to_string();
156        // RFC-ACDP-0006 §7 / RFC-ACDP-0008 §4.8–4.9: reject non-HTTPS,
157        // IP-literal, and malformed base URLs up front, then filter every
158        // resolved IP at DNS time (below) so DNS-rebinding answers in
159        // forbidden ranges are refused before connect. Previously `new()`
160        // applied neither, contradicting the documented "applied
161        // automatically by the public client APIs" guarantee (P0-1).
162        policy_ssrf.check_url(&base)?;
163        let original_authority = url::Url::parse(&base)
164            .ok()
165            .and_then(|u| u.host_str().map(str::to_string));
166
167        let policy = redirect::Policy::custom(move |attempt| {
168            if attempt.previous().len() >= MAX_REDIRECTS {
169                return attempt.error(format!(
170                    "exceeded {MAX_REDIRECTS} redirects per RFC-ACDP-0006 §7.5"
171                ));
172            }
173            // Same-authority enforcement (scheme + host + port) against the
174            // original request URL. RFC-ACDP-0008 §4.8.
175            let cross = attempt
176                .previous()
177                .first()
178                .filter(|orig| !acdp_safe_http::same_fetch_authority(orig, attempt.url()))
179                .map(|orig| (orig.to_string(), attempt.url().to_string()));
180            if let Some((from, to)) = cross {
181                return attempt.error(format!(
182                    "cross-authority redirect rejected ({from} -> {to})"
183                ));
184            }
185            attempt.follow()
186        });
187
188        let mut builder = Client::builder()
189            .use_rustls_tls()
190            .connect_timeout(CONNECT_TIMEOUT)
191            .timeout(REQUEST_TIMEOUT)
192            .redirect(policy)
193            // DNS-time SSRF filtering for every connection (incl. redirects
194            // and reconnects), defeating DNS rebinding — RFC-ACDP-0006 §7.6.
195            // Mirrors `WebResolver::build_http_client` / `HttpsDataRefFetcher`.
196            .dns_resolver(acdp_safe_http::SafeDnsResolver::arc(policy_ssrf));
197
198        if let Some(pem) = extra_root_pem {
199            let cert = reqwest::Certificate::from_pem(pem)
200                .map_err(|e| AcdpError::Http(format!("invalid root cert PEM: {e}")))?;
201            builder = builder.add_root_certificate(cert);
202        }
203
204        if let (Some(target), Some(host)) = (resolve_target, original_authority) {
205            builder = builder.resolve(&host, target);
206        }
207
208        let http = builder
209            .build()
210            .map_err(|e| AcdpError::Http(e.to_string()))?;
211
212        Ok(Self { base, http })
213    }
214
215    /// Connect to a registry with DNS-rebinding protection
216    /// (RFC-ACDP-0006 §7.6).
217    ///
218    /// Resolves the hostname once, validates the resolved IP against
219    /// `policy`, then pins that IP into the HTTP client so every
220    /// connection uses the address that was filtered. Use this in
221    /// server-side cross-registry contexts where a hostile authoritative
222    /// DNS server could otherwise flip the answer between the SSRF
223    /// filter check and the actual connect.
224    ///
225    /// Returns the same [`AcdpError`] variants as
226    /// [`SsrfPolicy::pin_resolved_ip`] when the host cannot be safely
227    /// resolved.
228    pub async fn new_pinned(base_url: &str, policy: &SsrfPolicy) -> Result<Self, AcdpError> {
229        let base = base_url.trim_end_matches('/').to_string();
230        let parsed = url::Url::parse(&base)
231            .map_err(|e| AcdpError::SchemaViolation(format!("invalid base URL: {e}")))?;
232        // Pre-flight: scheme + host range checks via the same policy.
233        policy.check_url(&base)?;
234        let host = parsed
235            .host_str()
236            .ok_or_else(|| AcdpError::SchemaViolation(format!("base URL has no host: {base}")))?
237            .to_string();
238        let port = parsed
239            .port_or_known_default()
240            .unwrap_or(if parsed.scheme() == "http" { 80 } else { 443 });
241
242        let pinned = policy.pin_resolved_ip(&host, port).await?;
243
244        let policy_redirect = redirect::Policy::custom(move |attempt| {
245            if attempt.previous().len() >= MAX_REDIRECTS {
246                return attempt.error(format!(
247                    "exceeded {MAX_REDIRECTS} redirects per RFC-ACDP-0006 §7.5"
248                ));
249            }
250            // Same-authority enforcement (scheme + host + port) against the
251            // original request URL. RFC-ACDP-0008 §4.8.
252            let cross = attempt
253                .previous()
254                .first()
255                .filter(|orig| !acdp_safe_http::same_fetch_authority(orig, attempt.url()))
256                .map(|orig| (orig.to_string(), attempt.url().to_string()));
257            if let Some((from, to)) = cross {
258                return attempt.error(format!(
259                    "cross-authority redirect rejected ({from} -> {to})"
260                ));
261            }
262            attempt.follow()
263        });
264
265        let http = Client::builder()
266            .use_rustls_tls()
267            .connect_timeout(CONNECT_TIMEOUT)
268            .timeout(REQUEST_TIMEOUT)
269            .redirect(policy_redirect)
270            .resolve(&host, pinned)
271            .build()
272            .map_err(|e| AcdpError::Http(e.to_string()))?;
273
274        Ok(Self { base, http })
275    }
276
277    // ── Capabilities ────────────────────────────────────────────────────────
278
279    /// Fetch the registry's capabilities document and run the
280    /// RFC-ACDP-0007 §3 runtime validation
281    /// ([`acdp_validation::validate_capabilities`]).
282    ///
283    /// Body capped at 64 KB per RFC-ACDP-0006 §7.3.
284    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
285    pub async fn capabilities(&self) -> Result<CapabilitiesDocument, AcdpError> {
286        Ok(self.capabilities_with_ttl().await?.0)
287    }
288
289    /// Like [`Self::capabilities`] but also returns the cache TTL
290    /// derived from the response's `Cache-Control: max-age=N` header.
291    ///
292    /// Per RFC-ACDP-0006 §4.2, consumers SHOULD cache the capabilities
293    /// document for `min(max-age, 3600s)` seconds. When no
294    /// `Cache-Control` (or no parseable `max-age`) is returned, the
295    /// fallback is `300s` — a conservative middle-ground that matches
296    /// [`crate::ResolverOptions::capabilities_ttl`]'s default.
297    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
298    pub async fn capabilities_with_ttl(
299        &self,
300    ) -> Result<(CapabilitiesDocument, std::time::Duration), AcdpError> {
301        let url = format!("{}/.well-known/acdp.json", self.base);
302        let resp = self.http.get(&url).send().await?;
303        let ttl = cache_ttl_from_response(&resp);
304        let caps: CapabilitiesDocument = self.parse_success(resp, MAX_METADATA_BYTES).await?;
305        acdp_validation::validate_capabilities(&caps)?;
306        Ok((caps, ttl))
307    }
308
309    // ── Publish ─────────────────────────────────────────────────────────────
310
311    /// Publish a context.  Returns the registry-assigned identifiers.
312    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, req)))]
313    pub async fn publish(&self, req: &PublishRequest) -> Result<PublishResponse, AcdpError> {
314        let url = format!("{}/contexts", self.base);
315        let resp = self
316            .http
317            .post(&url)
318            .header("Content-Type", "application/acdp+json")
319            .json(req)
320            .send()
321            .await?;
322        self.parse_success(resp, MAX_METADATA_BYTES).await
323    }
324
325    /// Publish with an idempotency key for safe retries.
326    pub async fn publish_idempotent(
327        &self,
328        req: &PublishRequest,
329        idempotency_key: &str,
330    ) -> Result<PublishResponse, AcdpError> {
331        let url = format!("{}/contexts", self.base);
332        let resp = self
333            .http
334            .post(&url)
335            .header("Content-Type", "application/acdp+json")
336            .header("Idempotency-Key", idempotency_key)
337            .json(req)
338            .send()
339            .await?;
340        self.parse_success(resp, MAX_METADATA_BYTES).await
341    }
342
343    /// Publish with bounded retry for transient failures.
344    ///
345    /// Reuses `idempotency_key` across attempts so the registry can
346    /// dedupe (RFC-ACDP-0003 §6). Retries only when the error is
347    /// transient per [`AcdpError::is_transient`]. Bounded backoff:
348    /// 250 ms, 500 ms, 1 s, 2 s.
349    pub async fn publish_with_retry(
350        &self,
351        req: &PublishRequest,
352        idempotency_key: &str,
353        max_attempts: u32,
354    ) -> Result<PublishResponse, AcdpError> {
355        let attempts = max_attempts.max(1);
356        let mut last_err: Option<AcdpError> = None;
357        for attempt in 0..attempts {
358            match self.publish_idempotent(req, idempotency_key).await {
359                Ok(resp) => return Ok(resp),
360                Err(e) if e.is_transient() && attempt + 1 < attempts => {
361                    let backoff_ms = 250u64 * (1 << attempt.min(3));
362                    last_err = Some(e);
363                    #[cfg(feature = "tracing")]
364                    tracing::debug!(
365                        attempt = attempt + 1,
366                        backoff_ms,
367                        "publish transient failure; retrying"
368                    );
369                    tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
370                }
371                Err(e) => return Err(e),
372            }
373        }
374        Err(last_err
375            .unwrap_or_else(|| AcdpError::Http("publish_with_retry exhausted attempts".into())))
376    }
377
378    // ── Retrieval ────────────────────────────────────────────────────────────
379
380    /// Retrieve a full context (body + registry_state) by ctx_id.
381    ///
382    /// Body capped at 1 MB per RFC-ACDP-0006 §7.3.
383    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(ctx_id = %ctx_id)))]
384    pub async fn retrieve(&self, ctx_id: &CtxId) -> Result<FullContext, AcdpError> {
385        let encoded = urlencoding::encode(ctx_id.as_str());
386        let url = format!("{}/contexts/{}", self.base, encoded);
387        let resp = self.http.get(&url).send().await?;
388        self.parse_success(resp, MAX_CONTEXT_BYTES).await
389    }
390
391    /// Retrieve a full context plus cache / integrity headers.
392    pub async fn retrieve_with_metadata(
393        &self,
394        ctx_id: &CtxId,
395    ) -> Result<(FullContext, RetrievalMetadata), AcdpError> {
396        let encoded = urlencoding::encode(ctx_id.as_str());
397        let url = format!("{}/contexts/{}", self.base, encoded);
398        let resp = self.http.get(&url).send().await?;
399        let metadata = parse_retrieval_metadata(&resp);
400        let body = self.parse_success(resp, MAX_CONTEXT_BYTES).await?;
401        Ok((body, metadata))
402    }
403
404    /// Conditional retrieval using `If-None-Match`.
405    ///
406    /// Returns `Ok(None)` when the registry responds 304 Not Modified.
407    /// Returns `Ok(Some((body, metadata)))` for a fresh retrieval.
408    pub async fn retrieve_if_none_match(
409        &self,
410        ctx_id: &CtxId,
411        etag: &str,
412    ) -> Result<Option<(FullContext, RetrievalMetadata)>, AcdpError> {
413        let encoded = urlencoding::encode(ctx_id.as_str());
414        let url = format!("{}/contexts/{}", self.base, encoded);
415        let resp = self
416            .http
417            .get(&url)
418            .header("If-None-Match", etag)
419            .send()
420            .await?;
421        if resp.status() == reqwest::StatusCode::NOT_MODIFIED {
422            return Ok(None);
423        }
424        let metadata = parse_retrieval_metadata(&resp);
425        let body = self.parse_success(resp, MAX_CONTEXT_BYTES).await?;
426        Ok(Some((body, metadata)))
427    }
428
429    /// Retrieve just the body (immutable, highly cacheable).
430    pub async fn retrieve_body(&self, ctx_id: &CtxId) -> Result<acdp_types::body::Body, AcdpError> {
431        let encoded = urlencoding::encode(ctx_id.as_str());
432        let url = format!("{}/contexts/{}/body", self.base, encoded);
433        let resp = self.http.get(&url).send().await?;
434        self.parse_success(resp, MAX_CONTEXT_BYTES).await
435    }
436
437    // ── Lineage ──────────────────────────────────────────────────────────────
438
439    /// Retrieve all contexts in a lineage (oldest to newest).
440    pub async fn lineage(&self, lineage_id: &LineageId) -> Result<Vec<FullContext>, AcdpError> {
441        let encoded = urlencoding::encode(lineage_id.as_str());
442        let url = format!("{}/lineages/{}", self.base, encoded);
443        let resp = self.http.get(&url).send().await?;
444        self.parse_success::<serde_json::Value>(resp, MAX_CONTEXT_BYTES)
445            .await
446            .and_then(|v| {
447                serde_json::from_value(v).map_err(|e| AcdpError::Serialization(e.to_string()))
448            })
449    }
450
451    /// Retrieve the current (latest) context in a lineage.
452    pub async fn current(&self, lineage_id: &LineageId) -> Result<FullContext, AcdpError> {
453        let encoded = urlencoding::encode(lineage_id.as_str());
454        let url = format!("{}/lineages/{}/current", self.base, encoded);
455        let resp = self.http.get(&url).send().await?;
456        self.parse_success(resp, MAX_CONTEXT_BYTES).await
457    }
458
459    // ── Discovery ────────────────────────────────────────────────────────────
460
461    /// Keyword search across the registry.
462    ///
463    /// Body capped at 64 KB (search responses are projection-summaries —
464    /// IMP-03: not the 1 MB context cap).
465    pub async fn search(&self, params: &SearchParams) -> Result<SearchResponse, AcdpError> {
466        let url = format!("{}/contexts/search", self.base);
467        let resp = self.http.get(&url).query(params).send().await?;
468        self.parse_success(resp, MAX_METADATA_BYTES).await
469    }
470
471    /// Begin a fluent search via [`RegistrySearch`]. Chains parameters
472    /// with strong typing, then `.send().await` issues the request.
473    ///
474    /// ```no_run
475    /// # async fn ex(client: &acdp_client::RegistryClient) -> Result<(), acdp_primitives::AcdpError> {
476    /// let resp = client
477    ///     .search_builder()
478    ///     .q("market risk")
479    ///     .tag("risk")
480    ///     .tag("portfolio")
481    ///     .limit(50)
482    ///     .send()
483    ///     .await?;
484    /// # let _ = resp; Ok(()) }
485    /// ```
486    pub fn search_builder(&self) -> RegistrySearch<'_> {
487        RegistrySearch::new(self)
488    }
489}
490
491/// Fluent search builder bound to a [`RegistryClient`]. See
492/// [`RegistryClient::search_builder`].
493pub struct RegistrySearch<'a> {
494    client: &'a RegistryClient,
495    inner: acdp_types::search::SearchParamsBuilder,
496}
497
498impl<'a> RegistrySearch<'a> {
499    fn new(client: &'a RegistryClient) -> Self {
500        Self {
501            client,
502            inner: acdp_types::search::SearchParamsBuilder::new(),
503        }
504    }
505
506    /// Issue the search.
507    pub async fn send(self) -> Result<SearchResponse, AcdpError> {
508        let params = self.inner.build();
509        self.client.search(&params).await
510    }
511    /// Full-text query.
512    pub fn q(mut self, q: impl Into<String>) -> Self {
513        self.inner = self.inner.q(q);
514        self
515    }
516    /// Filter on `type`.
517    pub fn context_type(mut self, t: impl Into<String>) -> Self {
518        self.inner = self.inner.context_type(t);
519        self
520    }
521    /// Filter on `domain`.
522    pub fn domain(mut self, d: impl Into<String>) -> Self {
523        self.inner = self.inner.domain(d);
524        self
525    }
526    /// Accumulate a tag.
527    pub fn tag(mut self, t: impl Into<String>) -> Self {
528        self.inner = self.inner.tag(t);
529        self
530    }
531    /// Filter on `agent_id`.
532    pub fn agent_id(mut self, a: impl Into<String>) -> Self {
533        self.inner = self.inner.agent_id(a);
534        self
535    }
536    /// Filter on `derived_from` (strongly typed).
537    pub fn derived_from(mut self, c: &acdp_types::CtxId) -> Self {
538        self.inner = self.inner.derived_from_ctx_id(c);
539        self
540    }
541    /// Lower bound on `created_at`.
542    pub fn created_after(mut self, dt: chrono::DateTime<chrono::Utc>) -> Self {
543        self.inner = self.inner.created_after(dt);
544        self
545    }
546    /// Upper bound on `created_at`.
547    pub fn created_before(mut self, dt: chrono::DateTime<chrono::Utc>) -> Self {
548        self.inner = self.inner.created_before(dt);
549        self
550    }
551    /// Status filter.
552    pub fn status(mut self, s: impl Into<String>) -> Self {
553        self.inner = self.inner.status(s);
554        self
555    }
556    /// Result page size cap.
557    pub fn limit(mut self, l: u32) -> Self {
558        self.inner = self.inner.limit(l);
559        self
560    }
561    /// Pagination cursor.
562    pub fn cursor(mut self, c: impl Into<String>) -> Self {
563        self.inner = self.inner.cursor(c);
564        self
565    }
566}
567
568// ── Internal helpers on RegistryClient ───────────────────────────────────────
569
570impl RegistryClient {
571    async fn parse_success<T: serde::de::DeserializeOwned>(
572        &self,
573        resp: reqwest::Response,
574        max_bytes: usize,
575    ) -> Result<T, AcdpError> {
576        if resp.status().is_success() {
577            let bytes = read_body_capped(resp, max_bytes).await?;
578            serde_json::from_slice(&bytes).map_err(|e| AcdpError::Serialization(e.to_string()))
579        } else {
580            // Error envelopes are tiny — apply the metadata cap so a
581            // hostile registry can't exhaust memory via the error path.
582            let bytes = match read_body_capped(resp, MAX_METADATA_BYTES).await {
583                Ok(b) => b,
584                Err(_) => {
585                    return Err(AcdpError::from_wire_error(WireError {
586                        error: acdp_types::publish::WireErrorBody {
587                            code: "unknown".into(),
588                            message: "could not read registry error response".into(),
589                            details: None,
590                        },
591                    }));
592                }
593            };
594            let wire: WireError = serde_json::from_slice(&bytes).unwrap_or_else(|_| WireError {
595                error: acdp_types::publish::WireErrorBody {
596                    code: "unknown".into(),
597                    message: "could not parse registry error response".into(),
598                    details: None,
599                },
600            });
601            Err(AcdpError::from_wire_error(wire))
602        }
603    }
604}
605
606/// Extract the cache TTL for a capabilities response per
607/// RFC-ACDP-0006 §4.2 — `min(Cache-Control: max-age=N, 3600s)`.
608///
609/// Falls back to a conservative 300s when no parseable `max-age`
610/// directive is present (matches [`crate::ResolverOptions::capabilities_ttl`]'s
611/// default so behavior is identical to the pre-BUG-09 code path on
612/// silent registries).
613fn cache_ttl_from_response(resp: &reqwest::Response) -> std::time::Duration {
614    const MAX_CAPS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
615    const DEFAULT_CAPS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
616
617    let Some(cc) = resp
618        .headers()
619        .get(reqwest::header::CACHE_CONTROL)
620        .and_then(|v| v.to_str().ok())
621    else {
622        return DEFAULT_CAPS_CACHE_TTL;
623    };
624    for directive in cc.split(',') {
625        let directive = directive.trim();
626        if let Some(value) = directive
627            .strip_prefix("max-age=")
628            .or_else(|| directive.strip_prefix("s-maxage="))
629        {
630            if let Ok(secs) = value.parse::<u64>() {
631                return std::time::Duration::from_secs(secs).min(MAX_CAPS_CACHE_TTL);
632            }
633        }
634    }
635    DEFAULT_CAPS_CACHE_TTL
636}
637
638fn parse_retrieval_metadata(resp: &reqwest::Response) -> RetrievalMetadata {
639    let headers = resp.headers();
640    let etag = headers
641        .get(reqwest::header::ETAG)
642        .and_then(|v| v.to_str().ok())
643        .map(|s| s.to_string());
644    let cache_control = headers
645        .get(reqwest::header::CACHE_CONTROL)
646        .and_then(|v| v.to_str().ok())
647        .map(|s| s.to_string());
648    let last_modified = headers
649        .get(reqwest::header::LAST_MODIFIED)
650        .and_then(|v| v.to_str().ok())
651        .and_then(|s| {
652            DateTime::parse_from_rfc2822(s)
653                .ok()
654                .map(|dt| dt.with_timezone(&Utc))
655        });
656    RetrievalMetadata {
657        etag,
658        cache_control,
659        last_modified,
660    }
661}
662
663/// Read the response body, aborting if the running total exceeds
664/// `max_bytes`. Returns [`AcdpError::PayloadTooLarge`] on overflow.
665async fn read_body_capped(
666    mut resp: reqwest::Response,
667    max_bytes: usize,
668) -> Result<Vec<u8>, AcdpError> {
669    if let Some(len) = resp.content_length() {
670        if len as usize > max_bytes {
671            return Err(AcdpError::PayloadTooLarge(format!(
672                "response Content-Length {len} exceeds cap {max_bytes}"
673            )));
674        }
675    }
676    let mut buf = Vec::with_capacity(8 * 1024);
677    while let Some(chunk) = resp
678        .chunk()
679        .await
680        .map_err(|e| AcdpError::Http(e.to_string()))?
681    {
682        if buf.len() + chunk.len() > max_bytes {
683            return Err(AcdpError::PayloadTooLarge(format!(
684                "response body exceeded {max_bytes} bytes"
685            )));
686        }
687        buf.extend_from_slice(&chunk);
688    }
689    Ok(buf)
690}