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    ///
67    /// # DNS-rebinding posture (default)
68    ///
69    /// This constructor installs the **`SafeDnsResolver` DNS hook**
70    /// (RFC-ACDP-0006 §7.6): every hostname lookup — the first connect,
71    /// each redirect, and every reconnect the pool makes over the
72    /// client's lifetime — is filtered through the [`SsrfPolicy`] *at
73    /// DNS time, before any TCP connect*. This is **strictly stronger
74    /// than pin-once resolution** ([`Self::new_pinned`]): a pinned
75    /// client validates a single answer and reuses that address, so a
76    /// hostile authoritative DNS server that only later flips a name
77    /// into a forbidden range is still caught here but not there. The
78    /// DNS-hook posture is therefore the default for all callers; reach
79    /// for [`Self::builder`] only when you need a non-default knob (a
80    /// private root cert, a custom [`SsrfPolicy`], timeout overrides, or
81    /// the legacy pinned mode).
82    pub fn new(base_url: &str) -> Result<Self, AcdpError> {
83        Self::build(base_url, None, None, SsrfPolicy::default())
84    }
85
86    /// Start a [`RegistryClientBuilder`] for the non-default connection
87    /// postures — a private root certificate, a custom [`SsrfPolicy`],
88    /// timeout overrides, and the legacy pinned-resolution mode.
89    ///
90    /// The builder's *default* is identical to [`Self::new`]: the
91    /// stronger `SafeDnsResolver` DNS-hook posture with the default
92    /// SSRF policy and the RFC-ACDP-0006 §7.4 timeouts. Opt into
93    /// pin-once resolution with [`RegistryClientBuilder::pinned`].
94    pub fn builder(base_url: &str) -> RegistryClientBuilder {
95        RegistryClientBuilder::new(base_url)
96    }
97
98    /// Connect to a registry that trusts the given PEM-encoded root
99    /// certificate in addition to the system roots.
100    ///
101    /// Primary use is the in-process self-signed HTTPS server in the
102    /// crate's `tests/helpers/tls_did_server.rs` harness so the spec
103    /// fixtures `fed-001..006` can drive `CrossRegistryResolver`
104    /// end-to-end without going over the network.
105    #[cfg(feature = "test-transport")]
106    pub fn with_root_cert_pem(base_url: &str, pem: &[u8]) -> Result<Self, AcdpError> {
107        // Drives an in-process HTTPS server on loopback, so the SSRF
108        // policy must permit a loopback-resolved answer. All other
109        // forbidden ranges (RFC 1918, IMDS, …) still apply.
110        Self::build(base_url, Some(pem), None, SsrfPolicy::allow_test_loopback())
111    }
112
113    /// Test-only permissive transport: allows `http://`, IP-literal hosts,
114    /// and loopback so the crate's in-process mock HTTP servers (e.g.
115    /// `wiremock`, which binds `http://127.0.0.1:<port>`) can be driven.
116    ///
117    /// Production MUST use [`Self::new`], which applies the full
118    /// RFC-ACDP-0006 §7 / RFC-ACDP-0008 SSRF + HTTPS-only + DNS-rebinding
119    /// posture. This constructor exists solely to keep the test harness on
120    /// loopback HTTP.
121    #[doc(hidden)]
122    #[cfg(feature = "test-transport")]
123    pub fn with_test_transport(base_url: &str) -> Result<Self, AcdpError> {
124        let policy = SsrfPolicy {
125            reject_ip_literals: false,
126            allow_http: true,
127            allow_loopback_resolved: true,
128        };
129        Self::build(base_url, None, None, policy)
130    }
131
132    /// Connect to a registry whose `<authority>` in `base_url` is routed
133    /// to a fixed socket address. Trusts the given PEM-encoded root
134    /// certificate in addition to the system roots.
135    ///
136    /// Use only in tests: a `CrossRegistryResolver` test that wants to
137    /// drive `acdp://<host>/<uuid>` references requires `<host>` to be
138    /// a valid lowercase DNS label (per `is_valid_dns_authority` in
139    /// `types::primitives`), which precludes embedding the port in the
140    /// `ctx_id`. This factory accepts a logical hostname (e.g.
141    /// `localhost`) and pins it to the test server's actual
142    /// `127.0.0.1:<port>` via reqwest's `.resolve()` hook.
143    #[doc(hidden)]
144    #[cfg(feature = "test-transport")]
145    pub fn with_test_endpoint(
146        base_url: &str,
147        target: std::net::SocketAddr,
148        pem: &[u8],
149    ) -> Result<Self, AcdpError> {
150        // Pins a logical hostname to a loopback test endpoint; permit the
151        // loopback answer while keeping every other forbidden range live.
152        Self::build(
153            base_url,
154            Some(pem),
155            Some(target),
156            SsrfPolicy::allow_test_loopback(),
157        )
158    }
159
160    fn build(
161        base_url: &str,
162        extra_root_pem: Option<&[u8]>,
163        resolve_target: Option<std::net::SocketAddr>,
164        policy_ssrf: SsrfPolicy,
165    ) -> Result<Self, AcdpError> {
166        RegistryClientBuilder {
167            base_url: base_url.to_string(),
168            pinned: false,
169            ssrf_policy: policy_ssrf,
170            root_cert_pem: extra_root_pem.map(<[u8]>::to_vec),
171            resolve_target,
172            connect_timeout: CONNECT_TIMEOUT,
173            request_timeout: REQUEST_TIMEOUT,
174        }
175        .build_blocking()
176    }
177
178    /// Connect to a registry with pin-once DNS-rebinding protection
179    /// (RFC-ACDP-0006 §7.6).
180    ///
181    /// Resolves the hostname once, validates the resolved IP against
182    /// `policy`, then pins that IP into the HTTP client.
183    ///
184    /// **Deprecated:** the default [`Self::new`] posture installs the
185    /// `SafeDnsResolver` DNS hook, which validates the resolved IP on
186    /// *every* connection (including reconnects) rather than just once —
187    /// strictly stronger protection. For the rare case that still wants
188    /// pin-once semantics with a custom policy, use
189    /// `RegistryClient::builder(base_url).pinned(true).ssrf_policy(policy).build().await`.
190    #[deprecated(
191        since = "0.4.0",
192        note = "prefer `RegistryClient::new` (SafeDnsResolver DNS hook — validates every \
193                connection, strictly stronger than pin-once) or, for explicit pin-once mode, \
194                `RegistryClient::builder(base_url).pinned(true).ssrf_policy(policy).build().await`"
195    )]
196    pub async fn new_pinned(base_url: &str, policy: &SsrfPolicy) -> Result<Self, AcdpError> {
197        Self::builder(base_url)
198            .pinned(true)
199            .ssrf_policy(policy.clone())
200            .build()
201            .await
202    }
203
204    // ── Capabilities ────────────────────────────────────────────────────────
205
206    /// Fetch the registry's capabilities document and run the
207    /// RFC-ACDP-0007 §3 runtime validation
208    /// ([`acdp_validation::validate_capabilities`]).
209    ///
210    /// Body capped at 64 KB per RFC-ACDP-0006 §7.3.
211    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
212    pub async fn capabilities(&self) -> Result<CapabilitiesDocument, AcdpError> {
213        Ok(self.capabilities_with_ttl().await?.0)
214    }
215
216    /// Like [`Self::capabilities`] but also returns the cache TTL
217    /// derived from the response's `Cache-Control: max-age=N` header.
218    ///
219    /// Per RFC-ACDP-0006 §4.2, consumers SHOULD cache the capabilities
220    /// document for `min(max-age, 3600s)` seconds. When no
221    /// `Cache-Control` (or no parseable `max-age`) is returned, the
222    /// fallback is `300s` — a conservative middle-ground that matches
223    /// [`crate::ResolverOptions::capabilities_ttl`]'s default.
224    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
225    pub async fn capabilities_with_ttl(
226        &self,
227    ) -> Result<(CapabilitiesDocument, std::time::Duration), AcdpError> {
228        let url = format!("{}/.well-known/acdp.json", self.base);
229        let resp = self.http.get(&url).send().await?;
230        let ttl = cache_ttl_from_response(&resp);
231        let caps: CapabilitiesDocument = self.parse_success(resp, MAX_METADATA_BYTES).await?;
232        acdp_validation::validate_capabilities(&caps)?;
233        Ok((caps, ttl))
234    }
235
236    // ── Publish ─────────────────────────────────────────────────────────────
237
238    /// Publish a context.  Returns the registry-assigned identifiers.
239    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, req)))]
240    pub async fn publish(&self, req: &PublishRequest) -> Result<PublishResponse, AcdpError> {
241        let url = format!("{}/contexts", self.base);
242        let resp = self
243            .http
244            .post(&url)
245            .header("Content-Type", "application/acdp+json")
246            .json(req)
247            .send()
248            .await?;
249        self.parse_success(resp, MAX_METADATA_BYTES).await
250    }
251
252    /// Publish with an idempotency key for safe retries.
253    pub async fn publish_idempotent(
254        &self,
255        req: &PublishRequest,
256        idempotency_key: &str,
257    ) -> Result<PublishResponse, AcdpError> {
258        let url = format!("{}/contexts", self.base);
259        let resp = self
260            .http
261            .post(&url)
262            .header("Content-Type", "application/acdp+json")
263            .header("Idempotency-Key", idempotency_key)
264            .json(req)
265            .send()
266            .await?;
267        self.parse_success(resp, MAX_METADATA_BYTES).await
268    }
269
270    /// Publish with bounded retry for transient failures.
271    ///
272    /// Reuses `idempotency_key` across attempts so the registry can
273    /// dedupe (RFC-ACDP-0003 §6). Retries only when the error is
274    /// transient per [`AcdpError::is_transient`]. Bounded backoff:
275    /// 250 ms, 500 ms, 1 s, 2 s.
276    pub async fn publish_with_retry(
277        &self,
278        req: &PublishRequest,
279        idempotency_key: &str,
280        max_attempts: u32,
281    ) -> Result<PublishResponse, AcdpError> {
282        let attempts = max_attempts.max(1);
283        let mut last_err: Option<AcdpError> = None;
284        for attempt in 0..attempts {
285            match self.publish_idempotent(req, idempotency_key).await {
286                Ok(resp) => return Ok(resp),
287                Err(e) if e.is_transient() && attempt + 1 < attempts => {
288                    let backoff_ms = 250u64 * (1 << attempt.min(3));
289                    last_err = Some(e);
290                    #[cfg(feature = "tracing")]
291                    tracing::debug!(
292                        attempt = attempt + 1,
293                        backoff_ms,
294                        "publish transient failure; retrying"
295                    );
296                    tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
297                }
298                Err(e) => return Err(e),
299            }
300        }
301        Err(last_err
302            .unwrap_or_else(|| AcdpError::Http("publish_with_retry exhausted attempts".into())))
303    }
304
305    // ── Retrieval ────────────────────────────────────────────────────────────
306
307    /// Retrieve a full context (body + registry_state) by ctx_id.
308    ///
309    /// Body capped at 1 MB per RFC-ACDP-0006 §7.3.
310    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(ctx_id = %ctx_id)))]
311    pub async fn retrieve(&self, ctx_id: &CtxId) -> Result<FullContext, AcdpError> {
312        let encoded = urlencoding::encode(ctx_id.as_str());
313        let url = format!("{}/contexts/{}", self.base, encoded);
314        let resp = self.http.get(&url).send().await?;
315        self.parse_success(resp, MAX_CONTEXT_BYTES).await
316    }
317
318    /// Retrieve a full context plus cache / integrity headers.
319    pub async fn retrieve_with_metadata(
320        &self,
321        ctx_id: &CtxId,
322    ) -> Result<(FullContext, RetrievalMetadata), AcdpError> {
323        let encoded = urlencoding::encode(ctx_id.as_str());
324        let url = format!("{}/contexts/{}", self.base, encoded);
325        let resp = self.http.get(&url).send().await?;
326        let metadata = parse_retrieval_metadata(&resp);
327        let body = self.parse_success(resp, MAX_CONTEXT_BYTES).await?;
328        Ok((body, metadata))
329    }
330
331    /// Conditional retrieval using `If-None-Match`.
332    ///
333    /// Returns `Ok(None)` when the registry responds 304 Not Modified.
334    /// Returns `Ok(Some((body, metadata)))` for a fresh retrieval.
335    pub async fn retrieve_if_none_match(
336        &self,
337        ctx_id: &CtxId,
338        etag: &str,
339    ) -> Result<Option<(FullContext, RetrievalMetadata)>, AcdpError> {
340        let encoded = urlencoding::encode(ctx_id.as_str());
341        let url = format!("{}/contexts/{}", self.base, encoded);
342        let resp = self
343            .http
344            .get(&url)
345            .header("If-None-Match", etag)
346            .send()
347            .await?;
348        if resp.status() == reqwest::StatusCode::NOT_MODIFIED {
349            return Ok(None);
350        }
351        let metadata = parse_retrieval_metadata(&resp);
352        let body = self.parse_success(resp, MAX_CONTEXT_BYTES).await?;
353        Ok(Some((body, metadata)))
354    }
355
356    /// Retrieve just the body (immutable, highly cacheable).
357    pub async fn retrieve_body(&self, ctx_id: &CtxId) -> Result<acdp_types::body::Body, AcdpError> {
358        let encoded = urlencoding::encode(ctx_id.as_str());
359        let url = format!("{}/contexts/{}/body", self.base, encoded);
360        let resp = self.http.get(&url).send().await?;
361        self.parse_success(resp, MAX_CONTEXT_BYTES).await
362    }
363
364    // ── Lineage ──────────────────────────────────────────────────────────────
365
366    /// Retrieve all contexts in a lineage (oldest to newest).
367    pub async fn lineage(&self, lineage_id: &LineageId) -> Result<Vec<FullContext>, AcdpError> {
368        let encoded = urlencoding::encode(lineage_id.as_str());
369        let url = format!("{}/lineages/{}", self.base, encoded);
370        let resp = self.http.get(&url).send().await?;
371        self.parse_success::<serde_json::Value>(resp, MAX_CONTEXT_BYTES)
372            .await
373            .and_then(|v| {
374                serde_json::from_value(v).map_err(|e| AcdpError::Serialization(e.to_string()))
375            })
376    }
377
378    /// Retrieve the current (latest) context in a lineage.
379    pub async fn current(&self, lineage_id: &LineageId) -> Result<FullContext, AcdpError> {
380        let encoded = urlencoding::encode(lineage_id.as_str());
381        let url = format!("{}/lineages/{}/current", self.base, encoded);
382        let resp = self.http.get(&url).send().await?;
383        self.parse_success(resp, MAX_CONTEXT_BYTES).await
384    }
385
386    // ── Discovery ────────────────────────────────────────────────────────────
387
388    /// Keyword search across the registry.
389    ///
390    /// Body capped at 64 KB (search responses are projection-summaries —
391    /// IMP-03: not the 1 MB context cap).
392    pub async fn search(&self, params: &SearchParams) -> Result<SearchResponse, AcdpError> {
393        let url = format!("{}/contexts/search", self.base);
394        let resp = self.http.get(&url).query(params).send().await?;
395        self.parse_success(resp, MAX_METADATA_BYTES).await
396    }
397
398    /// Begin a fluent search via [`RegistrySearch`]. Chains parameters
399    /// with strong typing, then `.send().await` issues the request.
400    ///
401    /// ```no_run
402    /// # async fn ex(client: &acdp_client::RegistryClient) -> Result<(), acdp_primitives::AcdpError> {
403    /// let resp = client
404    ///     .search_builder()
405    ///     .q("market risk")
406    ///     .tag("risk")
407    ///     .tag("portfolio")
408    ///     .limit(50)
409    ///     .send()
410    ///     .await?;
411    /// # let _ = resp; Ok(()) }
412    /// ```
413    pub fn search_builder(&self) -> RegistrySearch<'_> {
414        RegistrySearch::new(self)
415    }
416}
417
418/// Same-authority + redirect-cap policy shared by every
419/// [`RegistryClient`] HTTP client (RFC-ACDP-0006 §7.5 / RFC-ACDP-0008
420/// §4.8): at most [`MAX_REDIRECTS`] follows, and each follow must stay
421/// on the original request's scheme + host + port.
422fn redirect_policy() -> redirect::Policy {
423    redirect::Policy::custom(move |attempt| {
424        if attempt.previous().len() >= MAX_REDIRECTS {
425            return attempt.error(format!(
426                "exceeded {MAX_REDIRECTS} redirects per RFC-ACDP-0006 §7.5"
427            ));
428        }
429        // Same-authority enforcement (scheme + host + port) against the
430        // original request URL. RFC-ACDP-0008 §4.8.
431        let cross = attempt
432            .previous()
433            .first()
434            .filter(|orig| !acdp_safe_http::same_fetch_authority(orig, attempt.url()))
435            .map(|orig| (orig.to_string(), attempt.url().to_string()));
436        if let Some((from, to)) = cross {
437            return attempt.error(format!(
438                "cross-authority redirect rejected ({from} -> {to})"
439            ));
440        }
441        attempt.follow()
442    })
443}
444
445/// Builder for the non-default [`RegistryClient`] connection postures.
446///
447/// Start from [`RegistryClient::builder`]. The defaults match
448/// [`RegistryClient::new`] exactly — the stronger `SafeDnsResolver`
449/// DNS-hook posture (RFC-ACDP-0006 §7.6), the default [`SsrfPolicy`],
450/// and the RFC-ACDP-0006 §7.4 timeouts (5s connect, 30s total) — so a
451/// bare `builder(url).build().await` is equivalent to `new(url)`. Each
452/// setter changes exactly one knob:
453///
454/// - [`Self::pinned`] — pin-once resolution instead of the DNS hook.
455/// - [`Self::ssrf_policy`] — a custom SSRF policy (e.g. a test policy).
456/// - [`Self::root_cert_pem`] — trust an extra PEM root (private CA).
457/// - [`Self::connect_timeout`] / [`Self::request_timeout`] — override
458///   the RFC default timeouts.
459pub struct RegistryClientBuilder {
460    base_url: String,
461    pinned: bool,
462    ssrf_policy: SsrfPolicy,
463    root_cert_pem: Option<Vec<u8>>,
464    /// Test-only: pin `<authority>` to a fixed socket via reqwest's
465    /// `.resolve()`. Only set through the private `RegistryClient::build`
466    /// shim (never via the public `builder()` surface).
467    resolve_target: Option<std::net::SocketAddr>,
468    connect_timeout: Duration,
469    request_timeout: Duration,
470}
471
472impl RegistryClientBuilder {
473    fn new(base_url: &str) -> Self {
474        Self {
475            base_url: base_url.to_string(),
476            pinned: false,
477            ssrf_policy: SsrfPolicy::default(),
478            root_cert_pem: None,
479            resolve_target: None,
480            connect_timeout: CONNECT_TIMEOUT,
481            request_timeout: REQUEST_TIMEOUT,
482        }
483    }
484
485    /// Select **pin-once** resolution (RFC-ACDP-0006 §7.6): resolve the
486    /// authority's DNS a single time, validate that answer against the
487    /// SSRF policy, and pin the connection to it. Weaker than the
488    /// default DNS-hook posture (which re-validates every connection) —
489    /// prefer the default unless you specifically need pin-once.
490    pub fn pinned(mut self, pinned: bool) -> Self {
491        self.pinned = pinned;
492        self
493    }
494
495    /// Override the [`SsrfPolicy`] applied to the base URL and to every
496    /// resolved IP. Defaults to [`SsrfPolicy::default`].
497    pub fn ssrf_policy(mut self, policy: SsrfPolicy) -> Self {
498        self.ssrf_policy = policy;
499        self
500    }
501
502    /// Trust an additional PEM-encoded root certificate in addition to
503    /// the system roots (e.g. a private/corporate CA).
504    pub fn root_cert_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
505        self.root_cert_pem = Some(pem.into());
506        self
507    }
508
509    /// Override the connect timeout (default: RFC-ACDP-0006 §7.4, 5s).
510    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
511        self.connect_timeout = timeout;
512        self
513    }
514
515    /// Override the total request timeout (default: RFC-ACDP-0006 §7.4,
516    /// 30s).
517    pub fn request_timeout(mut self, timeout: Duration) -> Self {
518        self.request_timeout = timeout;
519        self
520    }
521
522    /// Build the [`RegistryClient`].
523    ///
524    /// Async because [`Self::pinned`] mode resolves DNS up front. The
525    /// default (DNS-hook) mode does no async work — it is constructed
526    /// synchronously internally.
527    pub async fn build(self) -> Result<RegistryClient, AcdpError> {
528        if !self.pinned {
529            return self.build_blocking();
530        }
531
532        // ── Pin-once mode (RFC-ACDP-0006 §7.6) ──────────────────────
533        let base = self.base_url.trim_end_matches('/').to_string();
534        let parsed = url::Url::parse(&base)
535            .map_err(|e| AcdpError::SchemaViolation(format!("invalid base URL: {e}")))?;
536        // Pre-flight: scheme + host range checks via the same policy.
537        self.ssrf_policy.check_url(&base)?;
538        let host = parsed
539            .host_str()
540            .ok_or_else(|| AcdpError::SchemaViolation(format!("base URL has no host: {base}")))?
541            .to_string();
542        let port = parsed
543            .port_or_known_default()
544            .unwrap_or(if parsed.scheme() == "http" { 80 } else { 443 });
545        let pinned = self.ssrf_policy.pin_resolved_ip(&host, port).await?;
546
547        let mut builder = Client::builder()
548            .use_rustls_tls()
549            .connect_timeout(self.connect_timeout)
550            .timeout(self.request_timeout)
551            .redirect(redirect_policy())
552            .resolve(&host, pinned);
553        builder = Self::apply_root_cert(builder, self.root_cert_pem.as_deref())?;
554
555        let http = builder
556            .build()
557            .map_err(|e| AcdpError::Http(e.to_string()))?;
558        Ok(RegistryClient { base, http })
559    }
560
561    /// Synchronous build for the default DNS-hook posture (no pinning).
562    /// The public sync constructors ([`RegistryClient::new`] and the
563    /// test-transport factories) route through here.
564    fn build_blocking(self) -> Result<RegistryClient, AcdpError> {
565        debug_assert!(
566            !self.pinned,
567            "build_blocking is DNS-hook only; pinned mode must use the async build()"
568        );
569        let base = self.base_url.trim_end_matches('/').to_string();
570        // RFC-ACDP-0006 §7 / RFC-ACDP-0008 §4.8–4.9: reject non-HTTPS,
571        // IP-literal, and malformed base URLs up front, then filter every
572        // resolved IP at DNS time (below) so DNS-rebinding answers in
573        // forbidden ranges are refused before connect.
574        self.ssrf_policy.check_url(&base)?;
575        let original_authority = url::Url::parse(&base)
576            .ok()
577            .and_then(|u| u.host_str().map(str::to_string));
578
579        let mut builder = Client::builder()
580            .use_rustls_tls()
581            .connect_timeout(self.connect_timeout)
582            .timeout(self.request_timeout)
583            .redirect(redirect_policy())
584            // DNS-time SSRF filtering for every connection (incl. redirects
585            // and reconnects), defeating DNS rebinding — RFC-ACDP-0006 §7.6.
586            // Mirrors `WebResolver::build_http_client` / `HttpsDataRefFetcher`.
587            .dns_resolver(acdp_safe_http::SafeDnsResolver::arc(self.ssrf_policy));
588        builder = Self::apply_root_cert(builder, self.root_cert_pem.as_deref())?;
589
590        if let (Some(target), Some(host)) = (self.resolve_target, original_authority) {
591            builder = builder.resolve(&host, target);
592        }
593
594        let http = builder
595            .build()
596            .map_err(|e| AcdpError::Http(e.to_string()))?;
597        Ok(RegistryClient { base, http })
598    }
599
600    fn apply_root_cert(
601        builder: reqwest::ClientBuilder,
602        pem: Option<&[u8]>,
603    ) -> Result<reqwest::ClientBuilder, AcdpError> {
604        let Some(pem) = pem else {
605            return Ok(builder);
606        };
607        let cert = reqwest::Certificate::from_pem(pem)
608            .map_err(|e| AcdpError::Http(format!("invalid root cert PEM: {e}")))?;
609        Ok(builder.add_root_certificate(cert))
610    }
611}
612
613/// Fluent search builder bound to a [`RegistryClient`]. See
614/// [`RegistryClient::search_builder`].
615pub struct RegistrySearch<'a> {
616    client: &'a RegistryClient,
617    inner: acdp_types::search::SearchParamsBuilder,
618}
619
620impl<'a> RegistrySearch<'a> {
621    fn new(client: &'a RegistryClient) -> Self {
622        Self {
623            client,
624            inner: acdp_types::search::SearchParamsBuilder::new(),
625        }
626    }
627
628    /// Issue the search.
629    pub async fn send(self) -> Result<SearchResponse, AcdpError> {
630        let params = self.inner.build();
631        self.client.search(&params).await
632    }
633    /// Full-text query.
634    pub fn q(mut self, q: impl Into<String>) -> Self {
635        self.inner = self.inner.q(q);
636        self
637    }
638    /// Filter on `type`.
639    pub fn context_type(mut self, t: impl Into<String>) -> Self {
640        self.inner = self.inner.context_type(t);
641        self
642    }
643    /// Filter on `domain`.
644    pub fn domain(mut self, d: impl Into<String>) -> Self {
645        self.inner = self.inner.domain(d);
646        self
647    }
648    /// Accumulate a tag.
649    pub fn tag(mut self, t: impl Into<String>) -> Self {
650        self.inner = self.inner.tag(t);
651        self
652    }
653    /// Filter on `agent_id`.
654    pub fn agent_id(mut self, a: impl Into<String>) -> Self {
655        self.inner = self.inner.agent_id(a);
656        self
657    }
658    /// Filter on `derived_from` (strongly typed).
659    pub fn derived_from(mut self, c: &acdp_types::CtxId) -> Self {
660        self.inner = self.inner.derived_from_ctx_id(c);
661        self
662    }
663    /// Lower bound on `created_at`.
664    pub fn created_after(mut self, dt: chrono::DateTime<chrono::Utc>) -> Self {
665        self.inner = self.inner.created_after(dt);
666        self
667    }
668    /// Upper bound on `created_at`.
669    pub fn created_before(mut self, dt: chrono::DateTime<chrono::Utc>) -> Self {
670        self.inner = self.inner.created_before(dt);
671        self
672    }
673    /// Status filter.
674    pub fn status(mut self, s: impl Into<String>) -> Self {
675        self.inner = self.inner.status(s);
676        self
677    }
678    /// Result page size cap.
679    pub fn limit(mut self, l: u32) -> Self {
680        self.inner = self.inner.limit(l);
681        self
682    }
683    /// Pagination cursor.
684    pub fn cursor(mut self, c: impl Into<String>) -> Self {
685        self.inner = self.inner.cursor(c);
686        self
687    }
688}
689
690// ── Internal helpers on RegistryClient ───────────────────────────────────────
691
692impl RegistryClient {
693    async fn parse_success<T: serde::de::DeserializeOwned>(
694        &self,
695        resp: reqwest::Response,
696        max_bytes: usize,
697    ) -> Result<T, AcdpError> {
698        if resp.status().is_success() {
699            let bytes = read_body_capped(resp, max_bytes).await?;
700            serde_json::from_slice(&bytes).map_err(|e| AcdpError::Serialization(e.to_string()))
701        } else {
702            // Error envelopes are tiny — apply the metadata cap so a
703            // hostile registry can't exhaust memory via the error path.
704            let bytes = match read_body_capped(resp, MAX_METADATA_BYTES).await {
705                Ok(b) => b,
706                Err(_) => {
707                    return Err(AcdpError::from_wire_error(WireError {
708                        error: acdp_types::publish::WireErrorBody {
709                            code: "unknown".into(),
710                            message: "could not read registry error response".into(),
711                            details: None,
712                        },
713                    }));
714                }
715            };
716            let wire: WireError = serde_json::from_slice(&bytes).unwrap_or_else(|_| WireError {
717                error: acdp_types::publish::WireErrorBody {
718                    code: "unknown".into(),
719                    message: "could not parse registry error response".into(),
720                    details: None,
721                },
722            });
723            Err(AcdpError::from_wire_error(wire))
724        }
725    }
726}
727
728/// Extract the cache TTL for a capabilities response per
729/// RFC-ACDP-0006 §4.2 — `min(Cache-Control: max-age=N, 3600s)`.
730///
731/// Falls back to a conservative 300s when no parseable `max-age`
732/// directive is present (matches [`crate::ResolverOptions::capabilities_ttl`]'s
733/// default so behavior is identical to the pre-BUG-09 code path on
734/// silent registries).
735fn cache_ttl_from_response(resp: &reqwest::Response) -> std::time::Duration {
736    const MAX_CAPS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
737    const DEFAULT_CAPS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
738
739    let Some(cc) = resp
740        .headers()
741        .get(reqwest::header::CACHE_CONTROL)
742        .and_then(|v| v.to_str().ok())
743    else {
744        return DEFAULT_CAPS_CACHE_TTL;
745    };
746    for directive in cc.split(',') {
747        let directive = directive.trim();
748        if let Some(value) = directive
749            .strip_prefix("max-age=")
750            .or_else(|| directive.strip_prefix("s-maxage="))
751        {
752            if let Ok(secs) = value.parse::<u64>() {
753                return std::time::Duration::from_secs(secs).min(MAX_CAPS_CACHE_TTL);
754            }
755        }
756    }
757    DEFAULT_CAPS_CACHE_TTL
758}
759
760fn parse_retrieval_metadata(resp: &reqwest::Response) -> RetrievalMetadata {
761    let headers = resp.headers();
762    let etag = headers
763        .get(reqwest::header::ETAG)
764        .and_then(|v| v.to_str().ok())
765        .map(|s| s.to_string());
766    let cache_control = headers
767        .get(reqwest::header::CACHE_CONTROL)
768        .and_then(|v| v.to_str().ok())
769        .map(|s| s.to_string());
770    let last_modified = headers
771        .get(reqwest::header::LAST_MODIFIED)
772        .and_then(|v| v.to_str().ok())
773        .and_then(|s| {
774            DateTime::parse_from_rfc2822(s)
775                .ok()
776                .map(|dt| dt.with_timezone(&Utc))
777        });
778    RetrievalMetadata {
779        etag,
780        cache_control,
781        last_modified,
782    }
783}
784
785/// Read the response body, aborting if the running total exceeds
786/// `max_bytes`. Returns [`AcdpError::PayloadTooLarge`] on overflow.
787async fn read_body_capped(
788    mut resp: reqwest::Response,
789    max_bytes: usize,
790) -> Result<Vec<u8>, AcdpError> {
791    if let Some(len) = resp.content_length() {
792        if len as usize > max_bytes {
793            return Err(AcdpError::PayloadTooLarge(format!(
794                "response Content-Length {len} exceeds cap {max_bytes}"
795            )));
796        }
797    }
798    let mut buf = Vec::with_capacity(8 * 1024);
799    while let Some(chunk) = resp
800        .chunk()
801        .await
802        .map_err(|e| AcdpError::Http(e.to_string()))?
803    {
804        if buf.len() + chunk.len() > max_bytes {
805            return Err(AcdpError::PayloadTooLarge(format!(
806                "response body exceeded {max_bytes} bytes"
807            )));
808        }
809        buf.extend_from_slice(&chunk);
810    }
811    Ok(buf)
812}