use std::future::Future;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::pin::Pin;
use a2a_protocol_types::error::{A2aError, A2aResult};
use a2a_protocol_types::events::StreamResponse;
use a2a_protocol_types::push::TaskPushNotificationConfig;
use bytes::Bytes;
use http_body_util::Full;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
#[cfg(not(feature = "tls-rustls"))]
type PushHttpClient = Client<HttpConnector, Full<Bytes>>;
#[cfg(feature = "tls-rustls")]
type PushHttpClient = Client<hyper_rustls::HttpsConnector<HttpConnector>, Full<Bytes>>;
#[cfg(feature = "tls-rustls")]
fn push_tls_config() -> rustls::ClientConfig {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.expect("ring provider supports the rustls default protocol versions")
.with_root_certificates(roots)
.with_no_client_auth()
}
#[cfg(feature = "tls-rustls")]
fn build_push_https_client(tls_config: rustls::ClientConfig) -> PushHttpClient {
let mut http = HttpConnector::new();
http.enforce_http(false);
http.set_nodelay(true);
let https = hyper_rustls::HttpsConnectorBuilder::new()
.with_tls_config(tls_config)
.https_or_http()
.enable_all_versions()
.wrap_connector(http);
Client::builder(TokioExecutor::new()).build(https)
}
fn build_push_http_client() -> PushHttpClient {
#[cfg(not(feature = "tls-rustls"))]
{
Client::builder(TokioExecutor::new()).build_http()
}
#[cfg(feature = "tls-rustls")]
{
build_push_https_client(push_tls_config())
}
}
pub trait PushSender: Send + Sync + 'static {
fn send<'a>(
&'a self,
url: &'a str,
event: &'a StreamResponse,
config: &'a TaskPushNotificationConfig,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>>;
fn allows_private_urls(&self) -> bool {
false
}
}
const DEFAULT_PUSH_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[derive(Debug, Clone)]
pub struct PushRetryPolicy {
pub max_attempts: usize,
pub backoff: Vec<std::time::Duration>,
}
impl Default for PushRetryPolicy {
fn default() -> Self {
Self {
max_attempts: 3,
backoff: vec![
std::time::Duration::from_secs(1),
std::time::Duration::from_secs(2),
],
}
}
}
impl PushRetryPolicy {
#[must_use]
pub const fn with_max_attempts(mut self, max: usize) -> Self {
self.max_attempts = max;
self
}
#[must_use]
pub fn with_backoff(mut self, backoff: Vec<std::time::Duration>) -> Self {
self.backoff = backoff;
self
}
}
#[derive(Debug)]
pub struct HttpPushSender {
client: PushHttpClient,
request_timeout: std::time::Duration,
retry_policy: PushRetryPolicy,
allow_private_urls: bool,
}
impl Default for HttpPushSender {
fn default() -> Self {
Self::new()
}
}
impl HttpPushSender {
#[must_use]
pub fn new() -> Self {
Self::with_timeout(DEFAULT_PUSH_REQUEST_TIMEOUT)
}
#[must_use]
pub fn with_timeout(request_timeout: std::time::Duration) -> Self {
let client = build_push_http_client();
Self {
client,
request_timeout,
retry_policy: PushRetryPolicy::default(),
allow_private_urls: false,
}
}
#[cfg(feature = "tls-rustls")]
#[must_use]
pub fn with_tls_config(tls_config: rustls::ClientConfig) -> Self {
Self {
client: build_push_https_client(tls_config),
request_timeout: DEFAULT_PUSH_REQUEST_TIMEOUT,
retry_policy: PushRetryPolicy::default(),
allow_private_urls: false,
}
}
#[must_use]
pub fn with_retry_policy(mut self, policy: PushRetryPolicy) -> Self {
self.retry_policy = policy;
self
}
#[must_use]
pub const fn allow_private_urls(mut self) -> Self {
self.allow_private_urls = true;
self
}
}
#[allow(clippy::missing_const_for_fn)] fn is_private_v4(v4: Ipv4Addr) -> bool {
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) }
fn embedded_ipv4(v6: Ipv6Addr) -> Option<Ipv4Addr> {
if let Some(v4) = v6.to_ipv4_mapped() {
return Some(v4);
}
let v4_from = |g: u16, h: u16| {
let [a, b] = g.to_be_bytes();
let [c, d] = h.to_be_bytes();
Ipv4Addr::new(a, b, c, d)
};
match v6.segments() {
[0x0064, 0xff9b, 0, 0, 0, 0, g, h] => Some(v4_from(g, h)),
[0, 0, 0, 0, 0, 0, g, h] if !(g == 0 && (h == 0 || h == 1)) => Some(v4_from(g, h)),
_ => None,
}
}
#[allow(clippy::missing_const_for_fn)] fn is_private_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_private_v4(v4),
IpAddr::V6(v6) => {
if let Some(v4) = embedded_ipv4(v6) {
return is_private_v4(v4);
}
v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00
|| (v6.segments()[0] & 0xffc0) == 0xfe80
}
}
}
#[allow(clippy::case_sensitive_file_extension_comparisons)] pub(crate) fn validate_webhook_url(url: &str) -> A2aResult<()> {
let uri: hyper::Uri = url
.parse()
.map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
match uri.scheme_str() {
Some("http" | "https") => {}
Some(other) => {
return Err(A2aError::invalid_params(format!(
"webhook URL has unsupported scheme: {other} (expected http or https)"
)));
}
None => {
return Err(A2aError::invalid_params(
"webhook URL missing scheme (expected http:// or https://)",
));
}
}
let host = uri
.host()
.ok_or_else(|| A2aError::invalid_params("webhook URL missing host"))?;
let host_bare = host.trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = host_bare.parse::<IpAddr>() {
if is_private_ip(ip) {
return Err(A2aError::invalid_params(format!(
"webhook URL targets private/loopback address: {host}"
)));
}
}
let host_lower = host.to_ascii_lowercase();
if host_lower == "localhost"
|| host_lower.ends_with(".local")
|| host_lower.ends_with(".internal")
{
return Err(A2aError::invalid_params(format!(
"webhook URL targets local/internal hostname: {host}"
)));
}
Ok(())
}
pub(crate) async fn validate_webhook_url_with_dns(url: &str) -> A2aResult<Option<SocketAddr>> {
validate_webhook_url(url)?;
let uri: hyper::Uri = url
.parse()
.map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
let host = uri
.host()
.ok_or_else(|| A2aError::invalid_params("webhook URL missing host"))?;
let host_bare = host.trim_start_matches('[').trim_end_matches(']');
if host_bare.parse::<IpAddr>().is_ok() {
return Ok(None);
}
let port = uri.port_u16().unwrap_or_else(|| {
if uri.scheme_str() == Some("https") {
443
} else {
80
}
});
let addr = format!("{host_bare}:{port}");
let resolved = tokio::net::lookup_host(&addr).await.map_err(|e| {
A2aError::invalid_params(format!(
"webhook URL hostname could not be resolved: {host_bare}: {e}"
))
})?;
let mut pinned: Option<SocketAddr> = None;
for socket_addr in resolved {
let ip = socket_addr.ip();
if is_private_ip(ip) {
return Err(A2aError::invalid_params(format!(
"webhook URL hostname {host_bare} resolves to private/loopback address: {ip}"
)));
}
if pinned.is_none() {
pinned = Some(socket_addr);
}
}
pinned
.ok_or_else(|| {
A2aError::invalid_params(format!(
"webhook URL hostname {host_bare} did not resolve to any addresses"
))
})
.map(Some)
}
fn rewrite_uri_with_pinned_addr(url: &str, pinned: SocketAddr) -> A2aResult<hyper::Uri> {
let uri: hyper::Uri = url
.parse()
.map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
let scheme = uri
.scheme_str()
.ok_or_else(|| A2aError::invalid_params("webhook URL missing scheme"))?;
let host_str = match pinned.ip() {
IpAddr::V4(v4) => v4.to_string(),
IpAddr::V6(v6) => format!("[{v6}]"),
};
let path_and_query = uri
.path_and_query()
.map_or_else(|| "/".to_string(), std::string::ToString::to_string);
let rewritten = format!(
"{scheme}://{host_str}:{port}{path_and_query}",
port = pinned.port()
);
rewritten
.parse()
.map_err(|e| A2aError::invalid_params(format!("could not rewrite webhook URL: {e}")))
}
fn host_header_from_url(url: &str) -> A2aResult<String> {
let uri: hyper::Uri = url
.parse()
.map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
let host = uri
.host()
.ok_or_else(|| A2aError::invalid_params("webhook URL missing host"))?;
Ok(uri
.port_u16()
.map_or_else(|| host.to_string(), |port| format!("{host}:{port}")))
}
const fn pin_target(is_https: bool, pinned_addr: Option<SocketAddr>) -> Option<SocketAddr> {
match pinned_addr {
Some(addr) if !is_https => Some(addr),
_ => None,
}
}
fn validate_header_value(value: &str, name: &str) -> A2aResult<()> {
if value.contains('\r') || value.contains('\n') {
return Err(A2aError::invalid_params(format!(
"{name} contains invalid characters (CR/LF)"
)));
}
Ok(())
}
#[allow(clippy::manual_async_fn, clippy::too_many_lines)]
impl PushSender for HttpPushSender {
fn allows_private_urls(&self) -> bool {
self.allow_private_urls
}
fn send<'a>(
&'a self,
url: &'a str,
event: &'a StreamResponse,
config: &'a TaskPushNotificationConfig,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
trace_info!(url, "delivering push notification");
let is_https = url
.split_once("://")
.is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https"));
#[cfg(not(feature = "tls-rustls"))]
if is_https {
return Err(A2aError::internal(
"this build of HttpPushSender delivers over HTTP only and cannot reach an \
https:// webhook; enable the `tls-rustls` feature (on by default in \
a2a-protocol-sdk) or supply a TLS-capable PushSender implementation",
));
}
let pinned_addr = if self.allow_private_urls {
None
} else {
validate_webhook_url_with_dns(url).await?
};
let (pinned_uri, pinned_host_header) = match pin_target(is_https, pinned_addr) {
Some(addr) => (
Some(rewrite_uri_with_pinned_addr(url, addr)?),
Some(host_header_from_url(url)?),
),
None => (None, None),
};
if let Some(ref auth) = config.authentication {
if let Some(ref credentials) = auth.credentials {
validate_header_value(credentials, "authentication credentials")?;
}
validate_header_value(&auth.scheme, "authentication scheme")?;
}
if let Some(ref token) = config.token {
validate_header_value(token, "notification token")?;
}
let body_bytes: Bytes = serde_json::to_vec(event)
.map(Bytes::from)
.map_err(|e| A2aError::internal(format!("push serialization: {e}")))?;
let mut last_err = String::new();
for attempt in 0..self.retry_policy.max_attempts {
let mut builder = hyper::Request::builder()
.method(hyper::Method::POST)
.header("content-type", "application/json");
if let Some(uri) = pinned_uri.as_ref() {
builder = builder.uri(uri.clone());
if let Some(host) = pinned_host_header.as_deref() {
builder = builder.header("host", host);
}
} else {
builder = builder.uri(url);
}
if let Some(ref auth) = config.authentication {
let canonical_scheme = if auth.scheme.eq_ignore_ascii_case("bearer") {
Some("Bearer")
} else if auth.scheme.eq_ignore_ascii_case("basic") {
Some("Basic")
} else {
None
};
match (canonical_scheme, auth.credentials.as_deref()) {
(Some(prefix), Some(credentials)) => {
builder =
builder.header("authorization", format!("{prefix} {credentials}"));
}
(Some(_), None) => {
trace_warn!(
scheme = auth.scheme.as_str(),
"authentication scheme has no credentials; no auth header set"
);
}
(None, _) => {
trace_warn!(
scheme = auth.scheme.as_str(),
"unknown authentication scheme; no auth header set"
);
}
}
}
if let Some(ref token) = config.token {
builder = builder
.header("x-a2a-notification-token", token.as_str())
.header("a2a-notification-token", token.as_str());
}
let req = builder
.body(Full::new(body_bytes.clone()))
.map_err(|e| A2aError::internal(format!("push request build: {e}")))?;
let request_result =
tokio::time::timeout(self.request_timeout, self.client.request(req)).await;
match request_result {
Ok(Ok(resp)) if resp.status().is_success() => {
trace_debug!(url, "push notification delivered");
return Ok(());
}
Ok(Ok(resp)) => {
let status = resp.status();
let retryable = status.is_server_error()
|| status == hyper::StatusCode::REQUEST_TIMEOUT
|| status == hyper::StatusCode::TOO_MANY_REQUESTS;
if !retryable {
trace_warn!(url, attempt, status = %status, "push delivery rejected; not retrying");
return Err(A2aError::internal(format!(
"push notification got non-retryable HTTP {status}"
)));
}
last_err = format!("push notification got HTTP {status}");
trace_warn!(url, attempt, status = %status, "push delivery failed");
}
Ok(Err(e)) => {
last_err = format!("push notification failed: {e}");
trace_warn!(url, attempt, error = %e, "push delivery error");
}
Err(_) => {
last_err = format!(
"push notification timed out after {}s",
self.request_timeout.as_secs()
);
trace_warn!(url, attempt, "push delivery timed out");
}
}
if attempt < self.retry_policy.max_attempts - 1 {
let delay = self
.retry_policy
.backoff
.get(attempt)
.or_else(|| self.retry_policy.backoff.last());
if let Some(delay) = delay {
tokio::time::sleep(*delay).await;
}
}
}
Err(A2aError::internal(last_err))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_retry_policy_with_max_attempts() {
let policy = PushRetryPolicy::default().with_max_attempts(5);
assert_eq!(policy.max_attempts, 5);
assert_eq!(policy.backoff.len(), 2);
}
#[test]
fn push_retry_policy_with_backoff() {
let backoff = vec![
std::time::Duration::from_millis(100),
std::time::Duration::from_millis(500),
std::time::Duration::from_secs(1),
];
let policy = PushRetryPolicy::default().with_backoff(backoff.clone());
assert_eq!(policy.backoff, backoff);
assert_eq!(policy.max_attempts, 3);
}
#[test]
fn http_push_sender_with_retry_policy() {
let policy = PushRetryPolicy::default().with_max_attempts(10);
let sender = HttpPushSender::new().with_retry_policy(policy);
assert_eq!(sender.retry_policy.max_attempts, 10);
}
#[test]
fn rejects_url_without_host() {
assert!(validate_webhook_url("http:///path").is_err());
}
#[test]
fn http_push_sender_allow_private_urls() {
let sender = HttpPushSender::new().allow_private_urls();
assert!(sender.allow_private_urls);
}
#[test]
fn http_push_sender_default() {
let sender = HttpPushSender::default();
assert_eq!(sender.request_timeout, DEFAULT_PUSH_REQUEST_TIMEOUT);
assert!(!sender.allow_private_urls);
}
#[test]
fn push_retry_policy_default() {
let policy = PushRetryPolicy::default();
assert_eq!(policy.max_attempts, 3);
assert_eq!(policy.backoff.len(), 2);
assert_eq!(policy.backoff[0], std::time::Duration::from_secs(1));
assert_eq!(policy.backoff[1], std::time::Duration::from_secs(2));
}
#[test]
fn rejects_loopback_ipv4() {
assert!(validate_webhook_url("http://127.0.0.1:8080/webhook").is_err());
}
#[test]
fn rejects_private_10_range() {
assert!(validate_webhook_url("http://10.0.0.1/webhook").is_err());
}
#[test]
fn rejects_private_172_range() {
assert!(validate_webhook_url("http://172.16.0.1/webhook").is_err());
}
#[test]
fn rejects_private_192_168_range() {
assert!(validate_webhook_url("http://192.168.1.1/webhook").is_err());
}
#[test]
fn rejects_link_local() {
assert!(validate_webhook_url("http://169.254.169.254/latest").is_err());
}
#[test]
fn rejects_localhost() {
assert!(validate_webhook_url("http://localhost:8080/webhook").is_err());
}
#[test]
fn rejects_dot_local() {
assert!(validate_webhook_url("http://myservice.local/webhook").is_err());
}
#[test]
fn rejects_dot_internal() {
assert!(validate_webhook_url("http://metadata.internal/webhook").is_err());
}
#[test]
fn rejects_ipv6_loopback() {
assert!(validate_webhook_url("http://[::1]:8080/webhook").is_err());
}
#[test]
fn rejects_ipv4_mapped_loopback() {
assert!(validate_webhook_url("http://[::ffff:127.0.0.1]:8080/webhook").is_err());
}
#[test]
fn rejects_ipv4_mapped_metadata() {
assert!(validate_webhook_url("http://[::ffff:169.254.169.254]/latest/meta-data").is_err());
}
#[test]
fn rejects_ipv4_mapped_private() {
assert!(validate_webhook_url("http://[::ffff:10.0.0.1]/webhook").is_err());
assert!(validate_webhook_url("http://[::ffff:192.168.1.1]/webhook").is_err());
}
#[test]
fn rejects_ipv4_compatible_loopback() {
assert!(validate_webhook_url("http://[::127.0.0.1]/webhook").is_err());
}
#[test]
fn rejects_nat64_wellknown_prefix_to_private() {
assert!(validate_webhook_url("http://[64:ff9b::a9fe:a9fe]/latest").is_err());
}
#[test]
fn accepts_ipv4_mapped_public() {
assert!(validate_webhook_url("http://[::ffff:203.0.113.1]/webhook").is_ok());
}
#[test]
fn is_private_v4_cgnat_boundary() {
assert!(is_private_v4("100.64.0.1".parse().unwrap()));
assert!(is_private_v4("100.127.255.255".parse().unwrap())); assert!(!is_private_v4("100.0.0.1".parse().unwrap())); assert!(!is_private_v4("100.128.0.1".parse().unwrap())); assert!(!is_private_v4("5.64.0.1".parse().unwrap()));
}
#[test]
fn embedded_ipv4_recovers_only_the_v4_bearing_forms() {
let v6 = |s: &str| s.parse::<std::net::Ipv6Addr>().unwrap();
let v4 = |s: &str| Some(s.parse::<std::net::Ipv4Addr>().unwrap());
assert_eq!(embedded_ipv4(v6("::ffff:127.0.0.1")), v4("127.0.0.1"));
assert_eq!(
embedded_ipv4(v6("64:ff9b::a9fe:a9fe")),
v4("169.254.169.254")
);
assert_eq!(embedded_ipv4(v6("::2")), v4("0.0.0.2"));
assert_eq!(embedded_ipv4(v6("::")), None);
assert_eq!(embedded_ipv4(v6("::1")), None);
assert_eq!(embedded_ipv4(v6("2606:4700::1111")), None);
}
#[test]
fn accepts_public_url() {
assert!(validate_webhook_url("https://example.com/webhook").is_ok());
}
#[test]
fn accepts_public_ip() {
assert!(validate_webhook_url("https://203.0.113.1/webhook").is_ok());
}
#[test]
fn rejects_header_with_crlf() {
assert!(validate_header_value("token\r\nX-Injected: value", "test").is_err());
}
#[test]
fn rejects_header_with_cr() {
assert!(validate_header_value("token\rvalue", "test").is_err());
}
#[test]
fn rejects_header_with_lf() {
assert!(validate_header_value("token\nvalue", "test").is_err());
}
#[test]
fn accepts_clean_header_value() {
assert!(validate_header_value("Bearer abc123+/=", "test").is_ok());
}
#[test]
fn rejects_url_without_scheme() {
assert!(validate_webhook_url("example.com/webhook").is_err());
}
#[test]
fn rejects_ftp_scheme() {
assert!(validate_webhook_url("ftp://example.com/webhook").is_err());
}
#[test]
fn rejects_file_scheme() {
assert!(validate_webhook_url("file:///etc/passwd").is_err());
}
#[test]
fn accepts_http_scheme() {
assert!(validate_webhook_url("http://example.com/webhook").is_ok());
}
#[test]
fn rejects_cgnat_range() {
assert!(validate_webhook_url("http://100.64.0.1/webhook").is_err());
}
#[test]
fn rejects_unspecified_ipv4() {
assert!(validate_webhook_url("http://0.0.0.0/webhook").is_err());
}
#[test]
fn rejects_ipv6_unique_local() {
assert!(validate_webhook_url("http://[fc00::1]:8080/webhook").is_err());
}
#[test]
fn rejects_ipv6_link_local() {
assert!(validate_webhook_url("http://[fe80::1]:8080/webhook").is_err());
}
#[tokio::test]
async fn dns_rejects_loopback_ip_literal() {
let result = validate_webhook_url_with_dns("http://127.0.0.1:8080/webhook").await;
assert!(result.is_err(), "loopback IP should be rejected");
}
#[tokio::test]
async fn dns_rejects_private_ip_literal() {
let result = validate_webhook_url_with_dns("http://10.0.0.1/webhook").await;
assert!(result.is_err(), "private IP should be rejected");
}
#[tokio::test]
async fn dns_rejects_localhost_hostname() {
let result = validate_webhook_url_with_dns("http://localhost:8080/webhook").await;
assert!(result.is_err(), "localhost should be rejected");
}
#[tokio::test]
async fn dns_rejects_invalid_scheme() {
let result = validate_webhook_url_with_dns("ftp://example.com/webhook").await;
assert!(result.is_err(), "ftp scheme should be rejected");
}
#[tokio::test]
async fn dns_rejects_missing_host() {
let result = validate_webhook_url_with_dns("http:///path").await;
assert!(result.is_err(), "missing host should be rejected");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dns_rejects_unresolvable_hostname() {
let (tx, rx) = tokio::sync::oneshot::channel();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let result = rt.block_on(validate_webhook_url_with_dns(
"https://this-hostname-definitely-does-not-exist-a2a-test.invalid/webhook",
));
let _ = tx.send(result);
});
match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await {
Ok(Ok(result)) => {
assert!(result.is_err(), "unresolvable hostname should be rejected");
}
Ok(Err(_)) => panic!("sender dropped without sending"),
Err(_elapsed) => {
}
}
}
#[tokio::test]
async fn dns_accepts_ip_literal_public() {
let result = validate_webhook_url_with_dns("https://203.0.113.1/webhook").await;
assert!(
matches!(result, Ok(None)),
"public IP literal should be accepted with no pinning (got {result:?})",
);
}
#[test]
fn rewrite_uri_preserves_scheme_path_and_query() {
let pinned: SocketAddr = "203.0.113.1:8080".parse().unwrap();
let rewritten =
rewrite_uri_with_pinned_addr("http://example.com:8080/webhook?x=1", pinned).unwrap();
assert_eq!(rewritten.to_string(), "http://203.0.113.1:8080/webhook?x=1",);
}
#[test]
fn rewrite_uri_uses_ipv6_brackets() {
let pinned: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
let rewritten =
rewrite_uri_with_pinned_addr("https://example.com/webhook", pinned).unwrap();
assert!(
rewritten.to_string().contains("[2001:db8::1]:443"),
"IPv6 literal should be bracketed: {rewritten}",
);
}
#[test]
fn rewrite_uri_default_path_when_missing() {
let pinned: SocketAddr = "203.0.113.1:80".parse().unwrap();
let rewritten = rewrite_uri_with_pinned_addr("http://example.com", pinned).unwrap();
assert_eq!(rewritten.to_string(), "http://203.0.113.1:80/");
}
#[test]
fn host_header_includes_port_when_present() {
let host = host_header_from_url("http://example.com:8080/webhook").unwrap();
assert_eq!(host, "example.com:8080");
}
#[test]
fn host_header_omits_default_port() {
let host = host_header_from_url("https://example.com/webhook").unwrap();
assert_eq!(host, "example.com");
}
#[test]
fn host_header_from_url_rejects_missing_host() {
let result = host_header_from_url("http:///path");
assert!(result.is_err());
}
#[test]
fn pin_target_pins_http_but_not_https() {
let addr: SocketAddr = "203.0.113.1:8080".parse().unwrap();
assert_eq!(pin_target(false, Some(addr)), Some(addr));
assert_eq!(pin_target(true, Some(addr)), None);
assert_eq!(pin_target(false, None), None);
assert_eq!(pin_target(true, None), None);
}
fn dummy_event() -> StreamResponse {
use a2a_protocol_types::events::TaskStatusUpdateEvent;
use a2a_protocol_types::task::{ContextId, TaskId, TaskState, TaskStatus};
StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
task_id: TaskId::new("t1"),
context_id: ContextId::new("c1"),
status: TaskStatus::with_timestamp(TaskState::Working),
metadata: None,
})
}
fn dummy_config(url: &str) -> TaskPushNotificationConfig {
TaskPushNotificationConfig {
tenant: None,
id: Some("cfg".to_owned()),
task_id: Some("t1".to_owned()),
url: url.to_owned(),
token: None,
authentication: None,
}
}
#[cfg(not(feature = "tls-rustls"))]
#[tokio::test]
async fn https_without_tls_feature_fails_fast() {
let sender = HttpPushSender::new();
let event = dummy_event();
let config = dummy_config("https://example.com/webhook");
let err = sender
.send(&config.url, &event, &config)
.await
.expect_err("https must fail fast without the tls-rustls feature");
assert!(
err.to_string().contains("HTTP only"),
"expected the HTTP-only error, got: {err}"
);
}
#[cfg(feature = "tls-rustls")]
#[tokio::test]
async fn https_with_tls_feature_still_enforces_ssrf() {
let sender = HttpPushSender::new();
let event = dummy_event();
let config = dummy_config("https://127.0.0.1:8443/webhook");
let err = sender
.send(&config.url, &event, &config)
.await
.expect_err("https to a loopback address must be rejected by SSRF");
let msg = err.to_string();
assert!(
msg.contains("private/loopback") || msg.contains("loopback"),
"expected an SSRF rejection (not the HTTP-only error), got: {msg}"
);
}
}