use std::time::Duration;
use acdp_primitives::error::AcdpError;
use acdp_primitives::limits::{
CONNECT_TIMEOUT, MAX_CONTEXT_BYTES, MAX_METADATA_BYTES, MAX_REDIRECTS, REQUEST_TIMEOUT,
};
use acdp_safe_http::SsrfPolicy;
use acdp_types::{
body::FullContext,
capabilities::CapabilitiesDocument,
primitives::{CtxId, LineageId},
publish::{PublishRequest, PublishResponse, WireError},
search::{SearchParams, SearchResponse},
};
use chrono::{DateTime, Utc};
use reqwest::{redirect, Client};
#[derive(Clone)]
pub struct RegistryClient {
base: String,
http: Client,
}
#[derive(Debug, Clone, Default)]
pub struct RetrievalMetadata {
pub etag: Option<String>,
pub cache_control: Option<String>,
pub last_modified: Option<DateTime<Utc>>,
}
impl RegistryClient {
pub fn authority(&self) -> Option<String> {
url::Url::parse(&self.base).ok().and_then(|u| {
let host = u.host_str()?.to_string();
Some(match u.port() {
Some(p) => format!("{host}:{p}"),
None => host,
})
})
}
pub fn new(base_url: &str) -> Result<Self, AcdpError> {
Self::build(base_url, None, None, SsrfPolicy::default())
}
pub fn builder(base_url: &str) -> RegistryClientBuilder {
RegistryClientBuilder::new(base_url)
}
#[cfg(feature = "test-transport")]
pub fn with_root_cert_pem(base_url: &str, pem: &[u8]) -> Result<Self, AcdpError> {
Self::build(base_url, Some(pem), None, SsrfPolicy::allow_test_loopback())
}
#[doc(hidden)]
#[cfg(feature = "test-transport")]
pub fn with_test_transport(base_url: &str) -> Result<Self, AcdpError> {
let policy = SsrfPolicy {
reject_ip_literals: false,
allow_http: true,
allow_loopback_resolved: true,
};
Self::build(base_url, None, None, policy)
}
#[doc(hidden)]
#[cfg(feature = "test-transport")]
pub fn with_test_endpoint(
base_url: &str,
target: std::net::SocketAddr,
pem: &[u8],
) -> Result<Self, AcdpError> {
Self::build(
base_url,
Some(pem),
Some(target),
SsrfPolicy::allow_test_loopback(),
)
}
fn build(
base_url: &str,
extra_root_pem: Option<&[u8]>,
resolve_target: Option<std::net::SocketAddr>,
policy_ssrf: SsrfPolicy,
) -> Result<Self, AcdpError> {
RegistryClientBuilder {
base_url: base_url.to_string(),
pinned: false,
ssrf_policy: policy_ssrf,
root_cert_pem: extra_root_pem.map(<[u8]>::to_vec),
resolve_target,
connect_timeout: CONNECT_TIMEOUT,
request_timeout: REQUEST_TIMEOUT,
}
.build_blocking()
}
#[deprecated(
since = "0.4.0",
note = "prefer `RegistryClient::new` (SafeDnsResolver DNS hook — validates every \
connection, strictly stronger than pin-once) or, for explicit pin-once mode, \
`RegistryClient::builder(base_url).pinned(true).ssrf_policy(policy).build().await`"
)]
pub async fn new_pinned(base_url: &str, policy: &SsrfPolicy) -> Result<Self, AcdpError> {
Self::builder(base_url)
.pinned(true)
.ssrf_policy(policy.clone())
.build()
.await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn capabilities(&self) -> Result<CapabilitiesDocument, AcdpError> {
Ok(self.capabilities_with_ttl().await?.0)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn capabilities_with_ttl(
&self,
) -> Result<(CapabilitiesDocument, std::time::Duration), AcdpError> {
let url = format!("{}/.well-known/acdp.json", self.base);
let resp = self.http.get(&url).send().await?;
let ttl = cache_ttl_from_response(&resp);
let caps: CapabilitiesDocument = self.parse_success(resp, MAX_METADATA_BYTES).await?;
acdp_validation::validate_capabilities(&caps)?;
Ok((caps, ttl))
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self, req)))]
pub async fn publish(&self, req: &PublishRequest) -> Result<PublishResponse, AcdpError> {
let url = format!("{}/contexts", self.base);
let resp = self
.http
.post(&url)
.header("Content-Type", "application/acdp+json")
.json(req)
.send()
.await?;
self.parse_success(resp, MAX_METADATA_BYTES).await
}
pub async fn publish_idempotent(
&self,
req: &PublishRequest,
idempotency_key: &str,
) -> Result<PublishResponse, AcdpError> {
let url = format!("{}/contexts", self.base);
let resp = self
.http
.post(&url)
.header("Content-Type", "application/acdp+json")
.header("Idempotency-Key", idempotency_key)
.json(req)
.send()
.await?;
self.parse_success(resp, MAX_METADATA_BYTES).await
}
pub async fn publish_with_retry(
&self,
req: &PublishRequest,
idempotency_key: &str,
max_attempts: u32,
) -> Result<PublishResponse, AcdpError> {
let attempts = max_attempts.max(1);
let mut last_err: Option<AcdpError> = None;
for attempt in 0..attempts {
match self.publish_idempotent(req, idempotency_key).await {
Ok(resp) => return Ok(resp),
Err(e) if e.is_transient() && attempt + 1 < attempts => {
let backoff_ms = 250u64 * (1 << attempt.min(3));
last_err = Some(e);
#[cfg(feature = "tracing")]
tracing::debug!(
attempt = attempt + 1,
backoff_ms,
"publish transient failure; retrying"
);
tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
}
Err(e) => return Err(e),
}
}
Err(last_err
.unwrap_or_else(|| AcdpError::Http("publish_with_retry exhausted attempts".into())))
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(ctx_id = %ctx_id)))]
pub async fn retrieve(&self, ctx_id: &CtxId) -> Result<FullContext, AcdpError> {
let encoded = urlencoding::encode(ctx_id.as_str());
let url = format!("{}/contexts/{}", self.base, encoded);
let resp = self.http.get(&url).send().await?;
self.parse_success(resp, MAX_CONTEXT_BYTES).await
}
pub async fn retrieve_with_metadata(
&self,
ctx_id: &CtxId,
) -> Result<(FullContext, RetrievalMetadata), AcdpError> {
let encoded = urlencoding::encode(ctx_id.as_str());
let url = format!("{}/contexts/{}", self.base, encoded);
let resp = self.http.get(&url).send().await?;
let metadata = parse_retrieval_metadata(&resp);
let body = self.parse_success(resp, MAX_CONTEXT_BYTES).await?;
Ok((body, metadata))
}
pub async fn retrieve_if_none_match(
&self,
ctx_id: &CtxId,
etag: &str,
) -> Result<Option<(FullContext, RetrievalMetadata)>, AcdpError> {
let encoded = urlencoding::encode(ctx_id.as_str());
let url = format!("{}/contexts/{}", self.base, encoded);
let resp = self
.http
.get(&url)
.header("If-None-Match", etag)
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_MODIFIED {
return Ok(None);
}
let metadata = parse_retrieval_metadata(&resp);
let body = self.parse_success(resp, MAX_CONTEXT_BYTES).await?;
Ok(Some((body, metadata)))
}
pub async fn retrieve_body(&self, ctx_id: &CtxId) -> Result<acdp_types::body::Body, AcdpError> {
let encoded = urlencoding::encode(ctx_id.as_str());
let url = format!("{}/contexts/{}/body", self.base, encoded);
let resp = self.http.get(&url).send().await?;
self.parse_success(resp, MAX_CONTEXT_BYTES).await
}
pub async fn lineage(&self, lineage_id: &LineageId) -> Result<Vec<FullContext>, AcdpError> {
let encoded = urlencoding::encode(lineage_id.as_str());
let url = format!("{}/lineages/{}", self.base, encoded);
let resp = self.http.get(&url).send().await?;
self.parse_success::<serde_json::Value>(resp, MAX_CONTEXT_BYTES)
.await
.and_then(|v| {
serde_json::from_value(v).map_err(|e| AcdpError::Serialization(e.to_string()))
})
}
pub async fn current(&self, lineage_id: &LineageId) -> Result<FullContext, AcdpError> {
let encoded = urlencoding::encode(lineage_id.as_str());
let url = format!("{}/lineages/{}/current", self.base, encoded);
let resp = self.http.get(&url).send().await?;
self.parse_success(resp, MAX_CONTEXT_BYTES).await
}
pub async fn search(&self, params: &SearchParams) -> Result<SearchResponse, AcdpError> {
let url = format!("{}/contexts/search", self.base);
let resp = self.http.get(&url).query(params).send().await?;
self.parse_success(resp, MAX_METADATA_BYTES).await
}
pub fn search_builder(&self) -> RegistrySearch<'_> {
RegistrySearch::new(self)
}
}
fn redirect_policy() -> redirect::Policy {
redirect::Policy::custom(move |attempt| {
if attempt.previous().len() >= MAX_REDIRECTS {
return attempt.error(format!(
"exceeded {MAX_REDIRECTS} redirects per RFC-ACDP-0006 §7.5"
));
}
let cross = attempt
.previous()
.first()
.filter(|orig| !acdp_safe_http::same_fetch_authority(orig, attempt.url()))
.map(|orig| (orig.to_string(), attempt.url().to_string()));
if let Some((from, to)) = cross {
return attempt.error(format!(
"cross-authority redirect rejected ({from} -> {to})"
));
}
attempt.follow()
})
}
pub struct RegistryClientBuilder {
base_url: String,
pinned: bool,
ssrf_policy: SsrfPolicy,
root_cert_pem: Option<Vec<u8>>,
resolve_target: Option<std::net::SocketAddr>,
connect_timeout: Duration,
request_timeout: Duration,
}
impl RegistryClientBuilder {
fn new(base_url: &str) -> Self {
Self {
base_url: base_url.to_string(),
pinned: false,
ssrf_policy: SsrfPolicy::default(),
root_cert_pem: None,
resolve_target: None,
connect_timeout: CONNECT_TIMEOUT,
request_timeout: REQUEST_TIMEOUT,
}
}
pub fn pinned(mut self, pinned: bool) -> Self {
self.pinned = pinned;
self
}
pub fn ssrf_policy(mut self, policy: SsrfPolicy) -> Self {
self.ssrf_policy = policy;
self
}
pub fn root_cert_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
self.root_cert_pem = Some(pem.into());
self
}
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = timeout;
self
}
pub fn request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = timeout;
self
}
pub async fn build(self) -> Result<RegistryClient, AcdpError> {
if !self.pinned {
return self.build_blocking();
}
let base = self.base_url.trim_end_matches('/').to_string();
let parsed = url::Url::parse(&base)
.map_err(|e| AcdpError::SchemaViolation(format!("invalid base URL: {e}")))?;
self.ssrf_policy.check_url(&base)?;
let host = parsed
.host_str()
.ok_or_else(|| AcdpError::SchemaViolation(format!("base URL has no host: {base}")))?
.to_string();
let port = parsed
.port_or_known_default()
.unwrap_or(if parsed.scheme() == "http" { 80 } else { 443 });
let pinned = self.ssrf_policy.pin_resolved_ip(&host, port).await?;
let mut builder = Client::builder()
.use_rustls_tls()
.connect_timeout(self.connect_timeout)
.timeout(self.request_timeout)
.redirect(redirect_policy())
.resolve(&host, pinned);
builder = Self::apply_root_cert(builder, self.root_cert_pem.as_deref())?;
let http = builder
.build()
.map_err(|e| AcdpError::Http(e.to_string()))?;
Ok(RegistryClient { base, http })
}
fn build_blocking(self) -> Result<RegistryClient, AcdpError> {
debug_assert!(
!self.pinned,
"build_blocking is DNS-hook only; pinned mode must use the async build()"
);
let base = self.base_url.trim_end_matches('/').to_string();
self.ssrf_policy.check_url(&base)?;
let original_authority = url::Url::parse(&base)
.ok()
.and_then(|u| u.host_str().map(str::to_string));
let mut builder = Client::builder()
.use_rustls_tls()
.connect_timeout(self.connect_timeout)
.timeout(self.request_timeout)
.redirect(redirect_policy())
.dns_resolver(acdp_safe_http::SafeDnsResolver::arc(self.ssrf_policy));
builder = Self::apply_root_cert(builder, self.root_cert_pem.as_deref())?;
if let (Some(target), Some(host)) = (self.resolve_target, original_authority) {
builder = builder.resolve(&host, target);
}
let http = builder
.build()
.map_err(|e| AcdpError::Http(e.to_string()))?;
Ok(RegistryClient { base, http })
}
fn apply_root_cert(
builder: reqwest::ClientBuilder,
pem: Option<&[u8]>,
) -> Result<reqwest::ClientBuilder, AcdpError> {
let Some(pem) = pem else {
return Ok(builder);
};
let cert = reqwest::Certificate::from_pem(pem)
.map_err(|e| AcdpError::Http(format!("invalid root cert PEM: {e}")))?;
Ok(builder.add_root_certificate(cert))
}
}
pub struct RegistrySearch<'a> {
client: &'a RegistryClient,
inner: acdp_types::search::SearchParamsBuilder,
}
impl<'a> RegistrySearch<'a> {
fn new(client: &'a RegistryClient) -> Self {
Self {
client,
inner: acdp_types::search::SearchParamsBuilder::new(),
}
}
pub async fn send(self) -> Result<SearchResponse, AcdpError> {
let params = self.inner.build();
self.client.search(¶ms).await
}
pub fn q(mut self, q: impl Into<String>) -> Self {
self.inner = self.inner.q(q);
self
}
pub fn context_type(mut self, t: impl Into<String>) -> Self {
self.inner = self.inner.context_type(t);
self
}
pub fn domain(mut self, d: impl Into<String>) -> Self {
self.inner = self.inner.domain(d);
self
}
pub fn tag(mut self, t: impl Into<String>) -> Self {
self.inner = self.inner.tag(t);
self
}
pub fn agent_id(mut self, a: impl Into<String>) -> Self {
self.inner = self.inner.agent_id(a);
self
}
pub fn derived_from(mut self, c: &acdp_types::CtxId) -> Self {
self.inner = self.inner.derived_from_ctx_id(c);
self
}
pub fn created_after(mut self, dt: chrono::DateTime<chrono::Utc>) -> Self {
self.inner = self.inner.created_after(dt);
self
}
pub fn created_before(mut self, dt: chrono::DateTime<chrono::Utc>) -> Self {
self.inner = self.inner.created_before(dt);
self
}
pub fn status(mut self, s: impl Into<String>) -> Self {
self.inner = self.inner.status(s);
self
}
pub fn limit(mut self, l: u32) -> Self {
self.inner = self.inner.limit(l);
self
}
pub fn cursor(mut self, c: impl Into<String>) -> Self {
self.inner = self.inner.cursor(c);
self
}
}
impl RegistryClient {
async fn parse_success<T: serde::de::DeserializeOwned>(
&self,
resp: reqwest::Response,
max_bytes: usize,
) -> Result<T, AcdpError> {
if resp.status().is_success() {
let bytes = read_body_capped(resp, max_bytes).await?;
serde_json::from_slice(&bytes).map_err(|e| AcdpError::Serialization(e.to_string()))
} else {
let bytes = match read_body_capped(resp, MAX_METADATA_BYTES).await {
Ok(b) => b,
Err(_) => {
return Err(AcdpError::from_wire_error(WireError {
error: acdp_types::publish::WireErrorBody {
code: "unknown".into(),
message: "could not read registry error response".into(),
details: None,
},
}));
}
};
let wire: WireError = serde_json::from_slice(&bytes).unwrap_or_else(|_| WireError {
error: acdp_types::publish::WireErrorBody {
code: "unknown".into(),
message: "could not parse registry error response".into(),
details: None,
},
});
Err(AcdpError::from_wire_error(wire))
}
}
}
fn cache_ttl_from_response(resp: &reqwest::Response) -> std::time::Duration {
const MAX_CAPS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
const DEFAULT_CAPS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
let Some(cc) = resp
.headers()
.get(reqwest::header::CACHE_CONTROL)
.and_then(|v| v.to_str().ok())
else {
return DEFAULT_CAPS_CACHE_TTL;
};
for directive in cc.split(',') {
let directive = directive.trim();
if let Some(value) = directive
.strip_prefix("max-age=")
.or_else(|| directive.strip_prefix("s-maxage="))
{
if let Ok(secs) = value.parse::<u64>() {
return std::time::Duration::from_secs(secs).min(MAX_CAPS_CACHE_TTL);
}
}
}
DEFAULT_CAPS_CACHE_TTL
}
fn parse_retrieval_metadata(resp: &reqwest::Response) -> RetrievalMetadata {
let headers = resp.headers();
let etag = headers
.get(reqwest::header::ETAG)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let cache_control = headers
.get(reqwest::header::CACHE_CONTROL)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let last_modified = headers
.get(reqwest::header::LAST_MODIFIED)
.and_then(|v| v.to_str().ok())
.and_then(|s| {
DateTime::parse_from_rfc2822(s)
.ok()
.map(|dt| dt.with_timezone(&Utc))
});
RetrievalMetadata {
etag,
cache_control,
last_modified,
}
}
async fn read_body_capped(
mut resp: reqwest::Response,
max_bytes: usize,
) -> Result<Vec<u8>, AcdpError> {
if let Some(len) = resp.content_length() {
if len as usize > max_bytes {
return Err(AcdpError::PayloadTooLarge(format!(
"response Content-Length {len} exceeds cap {max_bytes}"
)));
}
}
let mut buf = Vec::with_capacity(8 * 1024);
while let Some(chunk) = resp
.chunk()
.await
.map_err(|e| AcdpError::Http(e.to_string()))?
{
if buf.len() + chunk.len() > max_bytes {
return Err(AcdpError::PayloadTooLarge(format!(
"response body exceeded {max_bytes} bytes"
)));
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}