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> {
67 Self::build(base_url, None, None, SsrfPolicy::default())
68 }
69
70 #[cfg_attr(
78 not(feature = "test-transport"),
79 deprecated(
80 note = "SSRF-relaxed test-only constructor: enable the `test-transport` feature to use it without this warning; the ungated fallback is removed in 0.4.0"
81 )
82 )]
83 #[allow(deprecated)] pub fn with_root_cert_pem(base_url: &str, pem: &[u8]) -> Result<Self, AcdpError> {
85 Self::build(base_url, Some(pem), None, SsrfPolicy::allow_test_loopback())
89 }
90
91 #[doc(hidden)]
100 #[cfg_attr(
101 not(feature = "test-transport"),
102 deprecated(
103 note = "SSRF-relaxed test-only constructor: enable the `test-transport` feature to use it without this warning; the ungated fallback is removed in 0.4.0"
104 )
105 )]
106 pub fn with_test_transport(base_url: &str) -> Result<Self, AcdpError> {
107 let policy = SsrfPolicy {
108 reject_ip_literals: false,
109 allow_http: true,
110 allow_loopback_resolved: true,
111 };
112 Self::build(base_url, None, None, policy)
113 }
114
115 #[doc(hidden)]
127 #[cfg_attr(
128 not(feature = "test-transport"),
129 deprecated(
130 note = "SSRF-relaxed test-only constructor: enable the `test-transport` feature to use it without this warning; the ungated fallback is removed in 0.4.0"
131 )
132 )]
133 #[allow(deprecated)] pub fn with_test_endpoint(
135 base_url: &str,
136 target: std::net::SocketAddr,
137 pem: &[u8],
138 ) -> Result<Self, AcdpError> {
139 Self::build(
142 base_url,
143 Some(pem),
144 Some(target),
145 SsrfPolicy::allow_test_loopback(),
146 )
147 }
148
149 fn build(
150 base_url: &str,
151 extra_root_pem: Option<&[u8]>,
152 resolve_target: Option<std::net::SocketAddr>,
153 policy_ssrf: SsrfPolicy,
154 ) -> Result<Self, AcdpError> {
155 let base = base_url.trim_end_matches('/').to_string();
156 policy_ssrf.check_url(&base)?;
163 let original_authority = url::Url::parse(&base)
164 .ok()
165 .and_then(|u| u.host_str().map(str::to_string));
166
167 let policy = redirect::Policy::custom(move |attempt| {
168 if attempt.previous().len() >= MAX_REDIRECTS {
169 return attempt.error(format!(
170 "exceeded {MAX_REDIRECTS} redirects per RFC-ACDP-0006 §7.5"
171 ));
172 }
173 let cross = attempt
176 .previous()
177 .first()
178 .filter(|orig| !acdp_safe_http::same_fetch_authority(orig, attempt.url()))
179 .map(|orig| (orig.to_string(), attempt.url().to_string()));
180 if let Some((from, to)) = cross {
181 return attempt.error(format!(
182 "cross-authority redirect rejected ({from} -> {to})"
183 ));
184 }
185 attempt.follow()
186 });
187
188 let mut builder = Client::builder()
189 .use_rustls_tls()
190 .connect_timeout(CONNECT_TIMEOUT)
191 .timeout(REQUEST_TIMEOUT)
192 .redirect(policy)
193 .dns_resolver(acdp_safe_http::SafeDnsResolver::arc(policy_ssrf));
197
198 if let Some(pem) = extra_root_pem {
199 let cert = reqwest::Certificate::from_pem(pem)
200 .map_err(|e| AcdpError::Http(format!("invalid root cert PEM: {e}")))?;
201 builder = builder.add_root_certificate(cert);
202 }
203
204 if let (Some(target), Some(host)) = (resolve_target, original_authority) {
205 builder = builder.resolve(&host, target);
206 }
207
208 let http = builder
209 .build()
210 .map_err(|e| AcdpError::Http(e.to_string()))?;
211
212 Ok(Self { base, http })
213 }
214
215 pub async fn new_pinned(base_url: &str, policy: &SsrfPolicy) -> Result<Self, AcdpError> {
229 let base = base_url.trim_end_matches('/').to_string();
230 let parsed = url::Url::parse(&base)
231 .map_err(|e| AcdpError::SchemaViolation(format!("invalid base URL: {e}")))?;
232 policy.check_url(&base)?;
234 let host = parsed
235 .host_str()
236 .ok_or_else(|| AcdpError::SchemaViolation(format!("base URL has no host: {base}")))?
237 .to_string();
238 let port = parsed
239 .port_or_known_default()
240 .unwrap_or(if parsed.scheme() == "http" { 80 } else { 443 });
241
242 let pinned = policy.pin_resolved_ip(&host, port).await?;
243
244 let policy_redirect = redirect::Policy::custom(move |attempt| {
245 if attempt.previous().len() >= MAX_REDIRECTS {
246 return attempt.error(format!(
247 "exceeded {MAX_REDIRECTS} redirects per RFC-ACDP-0006 §7.5"
248 ));
249 }
250 let cross = attempt
253 .previous()
254 .first()
255 .filter(|orig| !acdp_safe_http::same_fetch_authority(orig, attempt.url()))
256 .map(|orig| (orig.to_string(), attempt.url().to_string()));
257 if let Some((from, to)) = cross {
258 return attempt.error(format!(
259 "cross-authority redirect rejected ({from} -> {to})"
260 ));
261 }
262 attempt.follow()
263 });
264
265 let http = Client::builder()
266 .use_rustls_tls()
267 .connect_timeout(CONNECT_TIMEOUT)
268 .timeout(REQUEST_TIMEOUT)
269 .redirect(policy_redirect)
270 .resolve(&host, pinned)
271 .build()
272 .map_err(|e| AcdpError::Http(e.to_string()))?;
273
274 Ok(Self { base, http })
275 }
276
277 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
285 pub async fn capabilities(&self) -> Result<CapabilitiesDocument, AcdpError> {
286 Ok(self.capabilities_with_ttl().await?.0)
287 }
288
289 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
298 pub async fn capabilities_with_ttl(
299 &self,
300 ) -> Result<(CapabilitiesDocument, std::time::Duration), AcdpError> {
301 let url = format!("{}/.well-known/acdp.json", self.base);
302 let resp = self.http.get(&url).send().await?;
303 let ttl = cache_ttl_from_response(&resp);
304 let caps: CapabilitiesDocument = self.parse_success(resp, MAX_METADATA_BYTES).await?;
305 acdp_validation::validate_capabilities(&caps)?;
306 Ok((caps, ttl))
307 }
308
309 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, req)))]
313 pub async fn publish(&self, req: &PublishRequest) -> Result<PublishResponse, AcdpError> {
314 let url = format!("{}/contexts", self.base);
315 let resp = self
316 .http
317 .post(&url)
318 .header("Content-Type", "application/acdp+json")
319 .json(req)
320 .send()
321 .await?;
322 self.parse_success(resp, MAX_METADATA_BYTES).await
323 }
324
325 pub async fn publish_idempotent(
327 &self,
328 req: &PublishRequest,
329 idempotency_key: &str,
330 ) -> Result<PublishResponse, AcdpError> {
331 let url = format!("{}/contexts", self.base);
332 let resp = self
333 .http
334 .post(&url)
335 .header("Content-Type", "application/acdp+json")
336 .header("Idempotency-Key", idempotency_key)
337 .json(req)
338 .send()
339 .await?;
340 self.parse_success(resp, MAX_METADATA_BYTES).await
341 }
342
343 pub async fn publish_with_retry(
350 &self,
351 req: &PublishRequest,
352 idempotency_key: &str,
353 max_attempts: u32,
354 ) -> Result<PublishResponse, AcdpError> {
355 let attempts = max_attempts.max(1);
356 let mut last_err: Option<AcdpError> = None;
357 for attempt in 0..attempts {
358 match self.publish_idempotent(req, idempotency_key).await {
359 Ok(resp) => return Ok(resp),
360 Err(e) if e.is_transient() && attempt + 1 < attempts => {
361 let backoff_ms = 250u64 * (1 << attempt.min(3));
362 last_err = Some(e);
363 #[cfg(feature = "tracing")]
364 tracing::debug!(
365 attempt = attempt + 1,
366 backoff_ms,
367 "publish transient failure; retrying"
368 );
369 tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
370 }
371 Err(e) => return Err(e),
372 }
373 }
374 Err(last_err
375 .unwrap_or_else(|| AcdpError::Http("publish_with_retry exhausted attempts".into())))
376 }
377
378 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(ctx_id = %ctx_id)))]
384 pub async fn retrieve(&self, ctx_id: &CtxId) -> Result<FullContext, AcdpError> {
385 let encoded = urlencoding::encode(ctx_id.as_str());
386 let url = format!("{}/contexts/{}", self.base, encoded);
387 let resp = self.http.get(&url).send().await?;
388 self.parse_success(resp, MAX_CONTEXT_BYTES).await
389 }
390
391 pub async fn retrieve_with_metadata(
393 &self,
394 ctx_id: &CtxId,
395 ) -> Result<(FullContext, RetrievalMetadata), AcdpError> {
396 let encoded = urlencoding::encode(ctx_id.as_str());
397 let url = format!("{}/contexts/{}", self.base, encoded);
398 let resp = self.http.get(&url).send().await?;
399 let metadata = parse_retrieval_metadata(&resp);
400 let body = self.parse_success(resp, MAX_CONTEXT_BYTES).await?;
401 Ok((body, metadata))
402 }
403
404 pub async fn retrieve_if_none_match(
409 &self,
410 ctx_id: &CtxId,
411 etag: &str,
412 ) -> Result<Option<(FullContext, RetrievalMetadata)>, AcdpError> {
413 let encoded = urlencoding::encode(ctx_id.as_str());
414 let url = format!("{}/contexts/{}", self.base, encoded);
415 let resp = self
416 .http
417 .get(&url)
418 .header("If-None-Match", etag)
419 .send()
420 .await?;
421 if resp.status() == reqwest::StatusCode::NOT_MODIFIED {
422 return Ok(None);
423 }
424 let metadata = parse_retrieval_metadata(&resp);
425 let body = self.parse_success(resp, MAX_CONTEXT_BYTES).await?;
426 Ok(Some((body, metadata)))
427 }
428
429 pub async fn retrieve_body(&self, ctx_id: &CtxId) -> Result<acdp_types::body::Body, AcdpError> {
431 let encoded = urlencoding::encode(ctx_id.as_str());
432 let url = format!("{}/contexts/{}/body", self.base, encoded);
433 let resp = self.http.get(&url).send().await?;
434 self.parse_success(resp, MAX_CONTEXT_BYTES).await
435 }
436
437 pub async fn lineage(&self, lineage_id: &LineageId) -> Result<Vec<FullContext>, AcdpError> {
441 let encoded = urlencoding::encode(lineage_id.as_str());
442 let url = format!("{}/lineages/{}", self.base, encoded);
443 let resp = self.http.get(&url).send().await?;
444 self.parse_success::<serde_json::Value>(resp, MAX_CONTEXT_BYTES)
445 .await
446 .and_then(|v| {
447 serde_json::from_value(v).map_err(|e| AcdpError::Serialization(e.to_string()))
448 })
449 }
450
451 pub async fn current(&self, lineage_id: &LineageId) -> Result<FullContext, AcdpError> {
453 let encoded = urlencoding::encode(lineage_id.as_str());
454 let url = format!("{}/lineages/{}/current", self.base, encoded);
455 let resp = self.http.get(&url).send().await?;
456 self.parse_success(resp, MAX_CONTEXT_BYTES).await
457 }
458
459 pub async fn search(&self, params: &SearchParams) -> Result<SearchResponse, AcdpError> {
466 let url = format!("{}/contexts/search", self.base);
467 let resp = self.http.get(&url).query(params).send().await?;
468 self.parse_success(resp, MAX_METADATA_BYTES).await
469 }
470
471 pub fn search_builder(&self) -> RegistrySearch<'_> {
487 RegistrySearch::new(self)
488 }
489}
490
491pub struct RegistrySearch<'a> {
494 client: &'a RegistryClient,
495 inner: acdp_types::search::SearchParamsBuilder,
496}
497
498impl<'a> RegistrySearch<'a> {
499 fn new(client: &'a RegistryClient) -> Self {
500 Self {
501 client,
502 inner: acdp_types::search::SearchParamsBuilder::new(),
503 }
504 }
505
506 pub async fn send(self) -> Result<SearchResponse, AcdpError> {
508 let params = self.inner.build();
509 self.client.search(¶ms).await
510 }
511 pub fn q(mut self, q: impl Into<String>) -> Self {
513 self.inner = self.inner.q(q);
514 self
515 }
516 pub fn context_type(mut self, t: impl Into<String>) -> Self {
518 self.inner = self.inner.context_type(t);
519 self
520 }
521 pub fn domain(mut self, d: impl Into<String>) -> Self {
523 self.inner = self.inner.domain(d);
524 self
525 }
526 pub fn tag(mut self, t: impl Into<String>) -> Self {
528 self.inner = self.inner.tag(t);
529 self
530 }
531 pub fn agent_id(mut self, a: impl Into<String>) -> Self {
533 self.inner = self.inner.agent_id(a);
534 self
535 }
536 pub fn derived_from(mut self, c: &acdp_types::CtxId) -> Self {
538 self.inner = self.inner.derived_from_ctx_id(c);
539 self
540 }
541 pub fn created_after(mut self, dt: chrono::DateTime<chrono::Utc>) -> Self {
543 self.inner = self.inner.created_after(dt);
544 self
545 }
546 pub fn created_before(mut self, dt: chrono::DateTime<chrono::Utc>) -> Self {
548 self.inner = self.inner.created_before(dt);
549 self
550 }
551 pub fn status(mut self, s: impl Into<String>) -> Self {
553 self.inner = self.inner.status(s);
554 self
555 }
556 pub fn limit(mut self, l: u32) -> Self {
558 self.inner = self.inner.limit(l);
559 self
560 }
561 pub fn cursor(mut self, c: impl Into<String>) -> Self {
563 self.inner = self.inner.cursor(c);
564 self
565 }
566}
567
568impl RegistryClient {
571 async fn parse_success<T: serde::de::DeserializeOwned>(
572 &self,
573 resp: reqwest::Response,
574 max_bytes: usize,
575 ) -> Result<T, AcdpError> {
576 if resp.status().is_success() {
577 let bytes = read_body_capped(resp, max_bytes).await?;
578 serde_json::from_slice(&bytes).map_err(|e| AcdpError::Serialization(e.to_string()))
579 } else {
580 let bytes = match read_body_capped(resp, MAX_METADATA_BYTES).await {
583 Ok(b) => b,
584 Err(_) => {
585 return Err(AcdpError::from_wire_error(WireError {
586 error: acdp_types::publish::WireErrorBody {
587 code: "unknown".into(),
588 message: "could not read registry error response".into(),
589 details: None,
590 },
591 }));
592 }
593 };
594 let wire: WireError = serde_json::from_slice(&bytes).unwrap_or_else(|_| WireError {
595 error: acdp_types::publish::WireErrorBody {
596 code: "unknown".into(),
597 message: "could not parse registry error response".into(),
598 details: None,
599 },
600 });
601 Err(AcdpError::from_wire_error(wire))
602 }
603 }
604}
605
606fn cache_ttl_from_response(resp: &reqwest::Response) -> std::time::Duration {
614 const MAX_CAPS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
615 const DEFAULT_CAPS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
616
617 let Some(cc) = resp
618 .headers()
619 .get(reqwest::header::CACHE_CONTROL)
620 .and_then(|v| v.to_str().ok())
621 else {
622 return DEFAULT_CAPS_CACHE_TTL;
623 };
624 for directive in cc.split(',') {
625 let directive = directive.trim();
626 if let Some(value) = directive
627 .strip_prefix("max-age=")
628 .or_else(|| directive.strip_prefix("s-maxage="))
629 {
630 if let Ok(secs) = value.parse::<u64>() {
631 return std::time::Duration::from_secs(secs).min(MAX_CAPS_CACHE_TTL);
632 }
633 }
634 }
635 DEFAULT_CAPS_CACHE_TTL
636}
637
638fn parse_retrieval_metadata(resp: &reqwest::Response) -> RetrievalMetadata {
639 let headers = resp.headers();
640 let etag = headers
641 .get(reqwest::header::ETAG)
642 .and_then(|v| v.to_str().ok())
643 .map(|s| s.to_string());
644 let cache_control = headers
645 .get(reqwest::header::CACHE_CONTROL)
646 .and_then(|v| v.to_str().ok())
647 .map(|s| s.to_string());
648 let last_modified = headers
649 .get(reqwest::header::LAST_MODIFIED)
650 .and_then(|v| v.to_str().ok())
651 .and_then(|s| {
652 DateTime::parse_from_rfc2822(s)
653 .ok()
654 .map(|dt| dt.with_timezone(&Utc))
655 });
656 RetrievalMetadata {
657 etag,
658 cache_control,
659 last_modified,
660 }
661}
662
663async fn read_body_capped(
666 mut resp: reqwest::Response,
667 max_bytes: usize,
668) -> Result<Vec<u8>, AcdpError> {
669 if let Some(len) = resp.content_length() {
670 if len as usize > max_bytes {
671 return Err(AcdpError::PayloadTooLarge(format!(
672 "response Content-Length {len} exceeds cap {max_bytes}"
673 )));
674 }
675 }
676 let mut buf = Vec::with_capacity(8 * 1024);
677 while let Some(chunk) = resp
678 .chunk()
679 .await
680 .map_err(|e| AcdpError::Http(e.to_string()))?
681 {
682 if buf.len() + chunk.len() > max_bytes {
683 return Err(AcdpError::PayloadTooLarge(format!(
684 "response body exceeded {max_bytes} bytes"
685 )));
686 }
687 buf.extend_from_slice(&chunk);
688 }
689 Ok(buf)
690}