1use 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#[derive(Clone)]
27pub struct RegistryClient {
28 base: String,
29 http: Client,
30}
31
32#[derive(Debug, Clone, Default)]
38pub struct RetrievalMetadata {
39 pub etag: Option<String>,
41 pub cache_control: Option<String>,
43 pub last_modified: Option<DateTime<Utc>>,
45}
46
47impl RegistryClient {
48 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 pub fn new(base_url: &str) -> Result<Self, AcdpError> {
83 Self::build(base_url, None, None, SsrfPolicy::default())
84 }
85
86 pub fn builder(base_url: &str) -> RegistryClientBuilder {
95 RegistryClientBuilder::new(base_url)
96 }
97
98 #[cfg(feature = "test-transport")]
106 pub fn with_root_cert_pem(base_url: &str, pem: &[u8]) -> Result<Self, AcdpError> {
107 Self::build(base_url, Some(pem), None, SsrfPolicy::allow_test_loopback())
111 }
112
113 #[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 #[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 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 #[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 #[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 #[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 #[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 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 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 #[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 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 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 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 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 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 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 pub fn search_builder(&self) -> RegistrySearch<'_> {
414 RegistrySearch::new(self)
415 }
416}
417
418fn 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 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
445pub struct RegistryClientBuilder {
460 base_url: String,
461 pinned: bool,
462 ssrf_policy: SsrfPolicy,
463 root_cert_pem: Option<Vec<u8>>,
464 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 pub fn pinned(mut self, pinned: bool) -> Self {
491 self.pinned = pinned;
492 self
493 }
494
495 pub fn ssrf_policy(mut self, policy: SsrfPolicy) -> Self {
498 self.ssrf_policy = policy;
499 self
500 }
501
502 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 pub fn connect_timeout(mut self, timeout: Duration) -> Self {
511 self.connect_timeout = timeout;
512 self
513 }
514
515 pub fn request_timeout(mut self, timeout: Duration) -> Self {
518 self.request_timeout = timeout;
519 self
520 }
521
522 pub async fn build(self) -> Result<RegistryClient, AcdpError> {
528 if !self.pinned {
529 return self.build_blocking();
530 }
531
532 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 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 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 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_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
613pub 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 pub async fn send(self) -> Result<SearchResponse, AcdpError> {
630 let params = self.inner.build();
631 self.client.search(¶ms).await
632 }
633 pub fn q(mut self, q: impl Into<String>) -> Self {
635 self.inner = self.inner.q(q);
636 self
637 }
638 pub fn context_type(mut self, t: impl Into<String>) -> Self {
640 self.inner = self.inner.context_type(t);
641 self
642 }
643 pub fn domain(mut self, d: impl Into<String>) -> Self {
645 self.inner = self.inner.domain(d);
646 self
647 }
648 pub fn tag(mut self, t: impl Into<String>) -> Self {
650 self.inner = self.inner.tag(t);
651 self
652 }
653 pub fn agent_id(mut self, a: impl Into<String>) -> Self {
655 self.inner = self.inner.agent_id(a);
656 self
657 }
658 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 pub fn created_after(mut self, dt: chrono::DateTime<chrono::Utc>) -> Self {
665 self.inner = self.inner.created_after(dt);
666 self
667 }
668 pub fn created_before(mut self, dt: chrono::DateTime<chrono::Utc>) -> Self {
670 self.inner = self.inner.created_before(dt);
671 self
672 }
673 pub fn status(mut self, s: impl Into<String>) -> Self {
675 self.inner = self.inner.status(s);
676 self
677 }
678 pub fn limit(mut self, l: u32) -> Self {
680 self.inner = self.inner.limit(l);
681 self
682 }
683 pub fn cursor(mut self, c: impl Into<String>) -> Self {
685 self.inner = self.inner.cursor(c);
686 self
687 }
688}
689
690impl 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 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
728fn 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
785async 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}