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