Skip to main content

zeph_a2a/
client.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! A2A protocol HTTP client with optional TLS enforcement and SSRF protection.
5
6use std::net::SocketAddr;
7use std::pin::Pin;
8use std::time::Duration;
9
10use eventsource_stream::Eventsource;
11use futures_core::Stream;
12use serde::{Deserialize, Serialize, de::DeserializeOwned};
13use tokio_stream::StreamExt;
14use zeph_common::net::resolve_and_validate;
15
16use crate::error::A2aError;
17use crate::jsonrpc::{
18    JsonRpcRequest, JsonRpcResponse, METHOD_CANCEL_TASK, METHOD_GET_TASK, METHOD_SEND_MESSAGE,
19    METHOD_SEND_STREAMING_MESSAGE, SendMessageParams, TaskIdParams,
20};
21use crate::types::{Task, TaskArtifactUpdateEvent, TaskStatusUpdateEvent};
22
23/// A pinned, heap-allocated stream of [`TaskEvent`]s from a streaming A2A call.
24///
25/// Produced by [`A2aClient::stream_message`]. Each item is either a status update
26/// or an artifact update; errors are surfaced inline as `Err(A2aError)`.
27pub type TaskEventStream = Pin<Box<dyn Stream<Item = Result<TaskEvent, A2aError>> + Send>>;
28
29/// A single event received on a streaming (`message/stream`) A2A connection.
30///
31/// The A2A spec multiplexes two event kinds over the same SSE channel. This enum
32/// uses `#[serde(untagged)]` so that the deserializer inspects the `kind` field
33/// inside the inner struct to determine the variant.
34#[non_exhaustive]
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(untagged)]
37pub enum TaskEvent {
38    /// A task lifecycle transition (e.g., `submitted` → `working` → `completed`).
39    StatusUpdate(TaskStatusUpdateEvent),
40    /// A new or updated output artifact from the agent.
41    ArtifactUpdate(TaskArtifactUpdateEvent),
42}
43
44/// Security posture applied to outbound [`A2aClient`] requests.
45///
46/// Named fields eliminate the transposition hazard of a two-bool builder method
47/// (`with_security(true, false)` vs. `with_security(false, true)` are easy to swap
48/// by accident) and group the security boundary as one reviewable unit.
49///
50/// # Examples
51///
52/// ```rust
53/// use zeph_a2a::{A2aClient, SecurityPolicy};
54///
55/// // Recommended for production: reject HTTP and private/loopback targets.
56/// let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy::hardened());
57///
58/// // Partial policy via named fields — no ambiguity about which flag is which.
59/// let tls_only = SecurityPolicy {
60///     require_tls: true,
61///     ssrf_protection: false,
62/// };
63/// let _ = client.with_security(tls_only);
64/// ```
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct SecurityPolicy {
67    /// Reject any endpoint that does not start with `https://`, and build requests with
68    /// `https_only(true)` so a redirect cannot silently downgrade the connection to `http://`.
69    pub require_tls: bool,
70    /// Resolve the endpoint hostname via DNS, reject private/loopback/link-local ranges,
71    /// and pin the validated address for the actual connection so it cannot be re-resolved
72    /// to a different (attacker-controlled) address between the check and the connect.
73    pub ssrf_protection: bool,
74}
75
76impl SecurityPolicy {
77    /// Both protections enabled. The recommended posture for production deployments
78    /// that talk to untrusted or third-party A2A endpoints.
79    ///
80    /// # Examples
81    ///
82    /// ```rust
83    /// use zeph_a2a::SecurityPolicy;
84    ///
85    /// let policy = SecurityPolicy::hardened();
86    /// assert!(policy.require_tls);
87    /// assert!(policy.ssrf_protection);
88    /// ```
89    #[must_use]
90    pub const fn hardened() -> Self {
91        Self {
92            require_tls: true,
93            ssrf_protection: true,
94        }
95    }
96
97    /// Both protections disabled. Suitable only for local development against
98    /// trusted, non-adversarial endpoints (e.g. `http://localhost`).
99    ///
100    /// # Examples
101    ///
102    /// ```rust
103    /// use zeph_a2a::SecurityPolicy;
104    ///
105    /// let policy = SecurityPolicy::permissive();
106    /// assert!(!policy.require_tls);
107    /// assert!(!policy.ssrf_protection);
108    /// ```
109    #[must_use]
110    pub const fn permissive() -> Self {
111        Self {
112            require_tls: false,
113            ssrf_protection: false,
114        }
115    }
116}
117
118/// A DNS-validated hostname and its resolved addresses, used to pin the actual HTTP
119/// connection to the exact addresses that passed SSRF validation (see [`SecurityPolicy`]).
120#[derive(Debug)]
121struct PinnedTarget {
122    host: String,
123    addrs: Vec<SocketAddr>,
124}
125
126/// HTTP client for the A2A protocol.
127///
128/// `A2aClient` wraps a `reqwest::Client` and provides typed methods for the four
129/// A2A JSON-RPC operations: `message/send`, `message/stream`, `tasks/get`, and
130/// `tasks/cancel`. Each call optionally accepts a bearer token for authentication.
131///
132/// # Security
133///
134/// Use [`with_security`](A2aClient::with_security) to harden the client for
135/// production deployments — see [`SecurityPolicy`]. When either flag is enabled,
136/// each request is sent through a dedicated per-request `reqwest::Client` with
137/// redirects disabled and, when `ssrf_protection` is on, the connection pinned to
138/// the exact addresses that were validated (no re-resolution at connect time).
139///
140/// # Examples
141///
142/// ```rust,no_run
143/// use zeph_a2a::{A2aClient, SecurityPolicy, SendMessageParams, Message};
144///
145/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
146/// let client = A2aClient::new(reqwest::Client::new())
147///     .with_security(SecurityPolicy::hardened());
148///
149/// let params = SendMessageParams {
150///     message: Message::user_text("Summarize this page."),
151///     configuration: None,
152/// };
153/// let task = client.send_message("https://agent.example.com/a2a", params, Some("tok")).await?;
154/// println!("Task state: {:?}", task.status.state);
155/// # Ok(())
156/// # }
157/// ```
158pub struct A2aClient {
159    client: reqwest::Client,
160    security: SecurityPolicy,
161    /// Per-request timeout applied to `rpc_call` (send + JSON parse) and to the initial
162    /// `send()` in `stream_message`. The SSE body stream itself is not bounded — that
163    /// is the caller's responsibility.
164    ///
165    /// If the underlying `reqwest::Client` was also built with `.timeout()`, both limits
166    /// race: whichever fires first wins. `request_timeout` takes semantic priority because
167    /// it maps to `A2aError::Timeout`; the reqwest-level timeout maps to `A2aError::Http`.
168    request_timeout: Duration,
169}
170
171impl A2aClient {
172    /// Create a new `A2aClient` with no security restrictions.
173    ///
174    /// Security features are disabled by default for local/dev usage. Enable them
175    /// with [`with_security`](Self::with_security) for production deployments.
176    #[must_use]
177    pub fn new(client: reqwest::Client) -> Self {
178        Self {
179            client,
180            security: SecurityPolicy::permissive(),
181            request_timeout: Duration::from_secs(30),
182        }
183    }
184
185    /// Configure the [`SecurityPolicy`] for this client.
186    ///
187    /// Defaults to [`SecurityPolicy::permissive()`] (no restrictions). This method
188    /// uses the builder pattern and can be chained directly after [`new`](Self::new).
189    ///
190    /// # Examples
191    ///
192    /// ```rust
193    /// use zeph_a2a::{A2aClient, SecurityPolicy};
194    ///
195    /// let client = A2aClient::new(reqwest::Client::new())
196    ///     .with_security(SecurityPolicy::hardened());
197    /// ```
198    #[must_use]
199    pub fn with_security(mut self, policy: SecurityPolicy) -> Self {
200        self.security = policy;
201        self
202    }
203
204    /// Set the per-request timeout for RPC and streaming connection calls (default: 30 seconds).
205    ///
206    /// Applied to the full send + JSON response parse in `rpc_call`, and to the initial
207    /// HTTP `send()` in `stream_message`. The SSE body stream after connection is intentionally
208    /// unbounded — streams can legitimately run for a long time.
209    #[must_use]
210    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
211        self.request_timeout = timeout;
212        self
213    }
214
215    /// # Errors
216    /// Returns `A2aError` on network, JSON, or JSON-RPC errors, or `A2aError::Timeout`
217    /// if the request exceeds the configured `request_timeout`.
218    #[tracing::instrument(name = "a2a.client.send_message", skip_all, err)]
219    pub async fn send_message(
220        &self,
221        endpoint: &str,
222        params: SendMessageParams,
223        token: Option<&str>,
224    ) -> Result<Task, A2aError> {
225        self.rpc_call(endpoint, METHOD_SEND_MESSAGE, params, token)
226            .await
227    }
228
229    /// # Errors
230    /// Returns `A2aError` on network failure or if the SSE connection cannot be established.
231    #[tracing::instrument(name = "a2a.client.stream_message", skip_all, err)]
232    pub async fn stream_message(
233        &self,
234        endpoint: &str,
235        params: SendMessageParams,
236        token: Option<&str>,
237    ) -> Result<TaskEventStream, A2aError> {
238        let pinned = self.validate_endpoint(endpoint).await?;
239        let request_client = self.request_client(pinned.as_ref())?;
240        let request = JsonRpcRequest::new(METHOD_SEND_STREAMING_MESSAGE, params);
241        let mut req = request_client.post(endpoint).json(&request);
242        if let Some(t) = token {
243            req = req.bearer_auth(t);
244        }
245        let resp = tokio::time::timeout(self.request_timeout, req.send())
246            .await
247            .map_err(|_| A2aError::Timeout(self.request_timeout))?
248            .map_err(A2aError::Http)?;
249
250        if !resp.status().is_success() {
251            let status = resp.status();
252            let body = tokio::time::timeout(Duration::from_secs(5), resp.text())
253                .await
254                .unwrap_or(Ok(String::new()))
255                .unwrap_or_default();
256            // Truncate body to avoid leaking large upstream error responses.
257            let truncated = if body.len() > 256 {
258                format!("{}…", &body[..256])
259            } else {
260                body
261            };
262            return Err(A2aError::Stream(format!("HTTP {status}: {truncated}")));
263        }
264
265        let event_stream = resp.bytes_stream().eventsource();
266        let mapped = event_stream.filter_map(|event| match event {
267            Ok(event) => {
268                if event.data.is_empty() || event.data == "[DONE]" {
269                    return None;
270                }
271                match serde_json::from_str::<JsonRpcResponse<TaskEvent>>(&event.data) {
272                    Ok(rpc_resp) => match rpc_resp.into_result() {
273                        Ok(task_event) => Some(Ok(task_event)),
274                        Err(rpc_err) => Some(Err(A2aError::from(rpc_err))),
275                    },
276                    Err(e) => Some(Err(A2aError::Stream(format!(
277                        "failed to parse SSE event: {e}"
278                    )))),
279                }
280            }
281            Err(e) => Some(Err(A2aError::Stream(format!("SSE stream error: {e}")))),
282        });
283
284        Ok(Box::pin(mapped))
285    }
286
287    /// # Errors
288    /// Returns `A2aError` on network, JSON, or JSON-RPC errors, or `A2aError::Timeout`
289    /// if the request exceeds the configured `request_timeout`.
290    #[tracing::instrument(name = "a2a.client.get_task", skip_all, err)]
291    pub async fn get_task(
292        &self,
293        endpoint: &str,
294        params: TaskIdParams,
295        token: Option<&str>,
296    ) -> Result<Task, A2aError> {
297        self.rpc_call(endpoint, METHOD_GET_TASK, params, token)
298            .await
299    }
300
301    /// # Errors
302    /// Returns `A2aError` on network, JSON, or JSON-RPC errors, or `A2aError::Timeout`
303    /// if the request exceeds the configured `request_timeout`.
304    #[tracing::instrument(name = "a2a.client.cancel_task", skip_all, err)]
305    pub async fn cancel_task(
306        &self,
307        endpoint: &str,
308        params: TaskIdParams,
309        token: Option<&str>,
310    ) -> Result<Task, A2aError> {
311        self.rpc_call(endpoint, METHOD_CANCEL_TASK, params, token)
312            .await
313    }
314
315    /// Validates `endpoint` against the configured [`SecurityPolicy`] and, when
316    /// `ssrf_protection` is enabled, resolves its hostname once and returns the
317    /// validated addresses to be pinned for the actual connection.
318    ///
319    /// Returning `Ok(None)` means either security is off for that check, or the
320    /// endpoint has no host (validation is skipped, matching prior behavior).
321    #[tracing::instrument(name = "a2a.client.validate_endpoint", skip_all, err)]
322    async fn validate_endpoint(&self, endpoint: &str) -> Result<Option<PinnedTarget>, A2aError> {
323        if self.security.require_tls && !endpoint.starts_with("https://") {
324            return Err(A2aError::Security(format!(
325                "TLS required but endpoint uses HTTP: {endpoint}"
326            )));
327        }
328
329        if !self.security.ssrf_protection {
330            return Ok(None);
331        }
332
333        let url: url::Url = endpoint
334            .parse()
335            .map_err(|e| A2aError::Security(format!("invalid URL: {e}")))?;
336
337        let Some(host) = url.host_str() else {
338            return Ok(None);
339        };
340        let port = url.port_or_known_default().unwrap_or(443);
341        let addrs = resolve_and_validate(host, port)
342            .await
343            .map_err(|e| A2aError::Security(e.to_string()))?;
344
345        Ok(Some(PinnedTarget {
346            host: host.to_owned(),
347            addrs,
348        }))
349    }
350
351    /// Returns `true` when either half of the [`SecurityPolicy`] requires requests
352    /// to be sent through a dedicated per-request client instead of `self.client`.
353    fn needs_hardened_client(&self) -> bool {
354        self.security.require_tls || self.security.ssrf_protection
355    }
356
357    /// Selects the `reqwest::Client` to use for a single request: the shared
358    /// injected client when no security is configured, or a fresh hardened client
359    /// (redirects disabled, optionally TLS-enforced and address-pinned) otherwise.
360    fn request_client(&self, pinned: Option<&PinnedTarget>) -> Result<reqwest::Client, A2aError> {
361        if self.needs_hardened_client() {
362            self.build_hardened_client(pinned)
363        } else {
364            Ok(self.client.clone())
365        }
366    }
367
368    /// Builds a per-request client hardened per the configured [`SecurityPolicy`].
369    ///
370    /// Redirects are always disabled (`Policy::none()`) so a malicious `3xx` response
371    /// cannot silently redirect the connection to a private address or downgrade to
372    /// `http://` — the caller (`rpc_call`/`stream_message`) treats any non-2xx or
373    /// unparseable response as an error instead of following it. When `pinned` is
374    /// `Some`, the client is additionally locked to the exact addresses that passed
375    /// SSRF validation via `resolve_to_addrs`, so reqwest cannot re-resolve the
376    /// hostname to a different address at connect time (closing the DNS-rebinding
377    /// TOCTOU window).
378    fn build_hardened_client(
379        &self,
380        pinned: Option<&PinnedTarget>,
381    ) -> Result<reqwest::Client, A2aError> {
382        let mut builder = reqwest::Client::builder()
383            .user_agent(concat!("zeph-a2a/", env!("CARGO_PKG_VERSION")))
384            .redirect(reqwest::redirect::Policy::none());
385
386        if self.security.require_tls {
387            builder = builder.https_only(true);
388        }
389        if let Some(target) = pinned {
390            builder = builder.resolve_to_addrs(&target.host, &target.addrs);
391        }
392
393        builder
394            .build()
395            .map_err(|e| A2aError::Security(format!("failed to build hardened client: {e}")))
396    }
397
398    #[tracing::instrument(name = "a2a.client.rpc_call", skip_all, err)]
399    async fn rpc_call<P: Serialize, R: DeserializeOwned>(
400        &self,
401        endpoint: &str,
402        method: &str,
403        params: P,
404        token: Option<&str>,
405    ) -> Result<R, A2aError> {
406        let pinned = self.validate_endpoint(endpoint).await?;
407        let request_client = self.request_client(pinned.as_ref())?;
408        let request = JsonRpcRequest::new(method, params);
409        let mut req = request_client.post(endpoint).json(&request);
410        if let Some(t) = token {
411            req = req.bearer_auth(t);
412        }
413        let rpc_response: JsonRpcResponse<R> = tokio::time::timeout(self.request_timeout, async {
414            let resp = req.send().await?;
415            resp.json().await
416        })
417        .await
418        .map_err(|_| A2aError::Timeout(self.request_timeout))?
419        .map_err(A2aError::Http)?;
420        rpc_response.into_result().map_err(A2aError::from)
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use std::assert_matches;
427    use std::net::IpAddr;
428
429    use super::*;
430    use zeph_common::net::is_private_ip;
431
432    use crate::jsonrpc::{JsonRpcError, JsonRpcResponse};
433    use crate::types::{
434        Artifact, Message, Part, Task, TaskArtifactUpdateEvent, TaskState, TaskStatus,
435        TaskStatusUpdateEvent,
436    };
437
438    #[test]
439    fn task_event_deserialize_status_update() {
440        let event = TaskStatusUpdateEvent {
441            kind: "status-update".into(),
442            task_id: "t-1".into(),
443            context_id: None,
444            status: TaskStatus {
445                state: TaskState::Working,
446                timestamp: "ts".into(),
447                message: Some(Message::user_text("thinking...")),
448            },
449            is_final: false,
450        };
451        let json = serde_json::to_string(&event).unwrap();
452        let parsed: TaskEvent = serde_json::from_str(&json).unwrap();
453        assert_matches!(parsed, TaskEvent::StatusUpdate(_));
454    }
455
456    #[test]
457    fn task_event_deserialize_artifact_update() {
458        let event = TaskArtifactUpdateEvent {
459            kind: "artifact-update".into(),
460            task_id: "t-1".into(),
461            context_id: None,
462            artifact: Artifact {
463                artifact_id: "a-1".into(),
464                name: None,
465                parts: vec![Part::text("result")],
466                metadata: None,
467            },
468            is_final: true,
469        };
470        let json = serde_json::to_string(&event).unwrap();
471        let parsed: TaskEvent = serde_json::from_str(&json).unwrap();
472        assert_matches!(parsed, TaskEvent::ArtifactUpdate(_));
473    }
474
475    #[test]
476    fn rpc_response_with_task_result() {
477        let task = Task {
478            id: "t-1".into(),
479            context_id: None,
480            status: TaskStatus {
481                state: TaskState::Completed,
482                timestamp: "ts".into(),
483                message: None,
484            },
485            artifacts: vec![],
486            history: vec![],
487            metadata: None,
488        };
489        let resp = JsonRpcResponse {
490            jsonrpc: "2.0".into(),
491            id: serde_json::Value::String("req-1".into()),
492            result: Some(task),
493            error: None,
494        };
495        let json = serde_json::to_string(&resp).unwrap();
496        let back: JsonRpcResponse<Task> = serde_json::from_str(&json).unwrap();
497        let task = back.into_result().unwrap();
498        assert_eq!(task.id, "t-1");
499        assert_eq!(task.status.state, TaskState::Completed);
500    }
501
502    #[test]
503    fn rpc_response_with_error() {
504        let resp: JsonRpcResponse<Task> = JsonRpcResponse {
505            jsonrpc: "2.0".into(),
506            id: serde_json::Value::String("req-1".into()),
507            result: None,
508            error: Some(JsonRpcError {
509                code: -32001,
510                message: "task not found".into(),
511                data: None,
512            }),
513        };
514        let json = serde_json::to_string(&resp).unwrap();
515        let back: JsonRpcResponse<Task> = serde_json::from_str(&json).unwrap();
516        let err = back.into_result().unwrap_err();
517        assert_eq!(err.code, -32001);
518    }
519
520    #[test]
521    fn a2a_client_construction() {
522        let client = A2aClient::new(reqwest::Client::new());
523        drop(client);
524    }
525
526    #[test]
527    fn is_private_ip_loopback() {
528        assert!(is_private_ip(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)));
529        assert!(is_private_ip(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)));
530    }
531
532    #[test]
533    fn is_private_ip_private_ranges() {
534        assert!(is_private_ip("10.0.0.1".parse().unwrap()));
535        assert!(is_private_ip("172.16.0.1".parse().unwrap()));
536        assert!(is_private_ip("192.168.1.1".parse().unwrap()));
537    }
538
539    #[test]
540    fn is_private_ip_link_local() {
541        assert!(is_private_ip("169.254.0.1".parse().unwrap()));
542    }
543
544    #[test]
545    fn is_private_ip_unspecified() {
546        assert!(is_private_ip("0.0.0.0".parse().unwrap()));
547        assert!(is_private_ip("::".parse().unwrap()));
548    }
549
550    #[test]
551    fn is_private_ip_public() {
552        assert!(!is_private_ip("8.8.8.8".parse().unwrap()));
553        assert!(!is_private_ip("1.1.1.1".parse().unwrap()));
554    }
555
556    #[tokio::test]
557    async fn tls_enforcement_rejects_http() {
558        let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
559            require_tls: true,
560            ssrf_protection: false,
561        });
562        let result = client.validate_endpoint("http://example.com/rpc").await;
563        assert!(result.is_err());
564        let err = result.unwrap_err();
565        assert_matches!(err, A2aError::Security(_));
566        assert!(err.to_string().contains("TLS required"));
567    }
568
569    #[tokio::test]
570    async fn tls_enforcement_allows_https() {
571        let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
572            require_tls: true,
573            ssrf_protection: false,
574        });
575        let result = client.validate_endpoint("https://example.com/rpc").await;
576        assert!(result.is_ok());
577    }
578
579    #[tokio::test]
580    async fn ssrf_protection_rejects_localhost() {
581        let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
582            require_tls: false,
583            ssrf_protection: true,
584        });
585        let result = client.validate_endpoint("http://127.0.0.1:8080/rpc").await;
586        assert!(result.is_err());
587        assert!(result.unwrap_err().to_string().contains("SSRF"));
588    }
589
590    #[tokio::test]
591    async fn no_security_allows_http_localhost() {
592        let client = A2aClient::new(reqwest::Client::new());
593        let result = client.validate_endpoint("http://127.0.0.1:8080/rpc").await;
594        assert!(result.is_ok());
595    }
596
597    #[test]
598    fn jsonrpc_request_serialization_for_send_message() {
599        let params = SendMessageParams {
600            message: Message::user_text("hello"),
601            configuration: None,
602        };
603        let req = JsonRpcRequest::new(METHOD_SEND_MESSAGE, params);
604        let json = serde_json::to_string(&req).unwrap();
605        assert!(json.contains("\"method\":\"message/send\""));
606        assert!(json.contains("\"jsonrpc\":\"2.0\""));
607        assert!(json.contains("\"hello\""));
608    }
609
610    #[test]
611    fn jsonrpc_request_serialization_for_get_task() {
612        let params = TaskIdParams {
613            id: "task-123".into(),
614            history_length: Some(5),
615        };
616        let req = JsonRpcRequest::new(METHOD_GET_TASK, params);
617        let json = serde_json::to_string(&req).unwrap();
618        assert!(json.contains("\"method\":\"tasks/get\""));
619        assert!(json.contains("\"task-123\""));
620        assert!(json.contains("\"historyLength\":5"));
621    }
622
623    #[test]
624    fn jsonrpc_request_serialization_for_cancel_task() {
625        let params = TaskIdParams {
626            id: "task-456".into(),
627            history_length: None,
628        };
629        let req = JsonRpcRequest::new(METHOD_CANCEL_TASK, params);
630        let json = serde_json::to_string(&req).unwrap();
631        assert!(json.contains("\"method\":\"tasks/cancel\""));
632        assert!(!json.contains("historyLength"));
633    }
634
635    #[test]
636    fn jsonrpc_request_serialization_for_stream() {
637        let params = SendMessageParams {
638            message: Message::user_text("stream me"),
639            configuration: None,
640        };
641        let req = JsonRpcRequest::new(METHOD_SEND_STREAMING_MESSAGE, params);
642        let json = serde_json::to_string(&req).unwrap();
643        assert!(json.contains("\"method\":\"message/stream\""));
644    }
645
646    #[tokio::test]
647    async fn send_message_connection_error() {
648        let client = A2aClient::new(reqwest::Client::new());
649        let params = SendMessageParams {
650            message: Message::user_text("hello"),
651            configuration: None,
652        };
653        let result = client
654            .send_message("http://127.0.0.1:1/rpc", params, None)
655            .await;
656        assert!(result.is_err());
657        assert_matches!(result.unwrap_err(), A2aError::Http(_));
658    }
659
660    #[tokio::test]
661    async fn get_task_connection_error() {
662        let client = A2aClient::new(reqwest::Client::new());
663        let params = TaskIdParams {
664            id: "t-1".into(),
665            history_length: None,
666        };
667        let result = client
668            .get_task("http://127.0.0.1:1/rpc", params, None)
669            .await;
670        assert!(result.is_err());
671        assert_matches!(result.unwrap_err(), A2aError::Http(_));
672    }
673
674    #[tokio::test]
675    async fn cancel_task_connection_error() {
676        let client = A2aClient::new(reqwest::Client::new());
677        let params = TaskIdParams {
678            id: "t-1".into(),
679            history_length: None,
680        };
681        let result = client
682            .cancel_task("http://127.0.0.1:1/rpc", params, None)
683            .await;
684        assert!(result.is_err());
685        assert_matches!(result.unwrap_err(), A2aError::Http(_));
686    }
687
688    #[tokio::test]
689    async fn stream_message_connection_error() {
690        let client = A2aClient::new(reqwest::Client::new());
691        let params = SendMessageParams {
692            message: Message::user_text("stream me"),
693            configuration: None,
694        };
695        let result = client
696            .stream_message("http://127.0.0.1:1/rpc", params, None)
697            .await;
698        assert!(result.is_err());
699    }
700
701    #[tokio::test]
702    async fn stream_message_tls_required_rejects_http() {
703        let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
704            require_tls: true,
705            ssrf_protection: false,
706        });
707        let params = SendMessageParams {
708            message: Message::user_text("hello"),
709            configuration: None,
710        };
711        let result = client
712            .stream_message("http://example.com/rpc", params, None)
713            .await;
714        match result {
715            Err(A2aError::Security(msg)) => assert!(msg.contains("TLS required")),
716            _ => panic!("expected Security error"),
717        }
718    }
719
720    #[tokio::test]
721    async fn send_message_tls_required_rejects_http() {
722        let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
723            require_tls: true,
724            ssrf_protection: false,
725        });
726        let params = SendMessageParams {
727            message: Message::user_text("hello"),
728            configuration: None,
729        };
730        let result = client
731            .send_message("http://example.com/rpc", params, None)
732            .await;
733        assert!(result.is_err());
734        assert_matches!(result.unwrap_err(), A2aError::Security(_));
735    }
736
737    #[tokio::test]
738    async fn get_task_tls_required_rejects_http() {
739        let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
740            require_tls: true,
741            ssrf_protection: false,
742        });
743        let params = TaskIdParams {
744            id: "t-1".into(),
745            history_length: None,
746        };
747        let result = client
748            .get_task("http://example.com/rpc", params, None)
749            .await;
750        assert!(result.is_err());
751        assert_matches!(result.unwrap_err(), A2aError::Security(_));
752    }
753
754    #[tokio::test]
755    async fn cancel_task_tls_required_rejects_http() {
756        let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
757            require_tls: true,
758            ssrf_protection: false,
759        });
760        let params = TaskIdParams {
761            id: "t-1".into(),
762            history_length: None,
763        };
764        let result = client
765            .cancel_task("http://example.com/rpc", params, None)
766            .await;
767        assert!(result.is_err());
768        assert_matches!(result.unwrap_err(), A2aError::Security(_));
769    }
770
771    #[tokio::test]
772    async fn validate_endpoint_invalid_url_with_ssrf() {
773        let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
774            require_tls: false,
775            ssrf_protection: true,
776        });
777        let result = client.validate_endpoint("not-a-url").await;
778        assert!(result.is_err());
779        assert_matches!(result.unwrap_err(), A2aError::Security(_));
780    }
781
782    #[test]
783    fn with_security_returns_configured_client() {
784        let client =
785            A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy::hardened());
786        assert!(client.security.require_tls);
787        assert!(client.security.ssrf_protection);
788    }
789
790    #[test]
791    fn default_client_no_security() {
792        let client = A2aClient::new(reqwest::Client::new());
793        assert!(!client.security.require_tls);
794        assert!(!client.security.ssrf_protection);
795    }
796
797    #[test]
798    fn needs_hardened_client_reflects_policy() {
799        assert!(!A2aClient::new(reqwest::Client::new()).needs_hardened_client());
800        assert!(
801            A2aClient::new(reqwest::Client::new())
802                .with_security(SecurityPolicy {
803                    require_tls: true,
804                    ssrf_protection: false,
805                })
806                .needs_hardened_client()
807        );
808        assert!(
809            A2aClient::new(reqwest::Client::new())
810                .with_security(SecurityPolicy {
811                    require_tls: false,
812                    ssrf_protection: true,
813                })
814                .needs_hardened_client()
815        );
816        assert!(
817            A2aClient::new(reqwest::Client::new())
818                .with_security(SecurityPolicy::hardened())
819                .needs_hardened_client()
820        );
821    }
822
823    #[test]
824    fn task_event_clone() {
825        let event = TaskEvent::StatusUpdate(TaskStatusUpdateEvent {
826            kind: "status-update".into(),
827            task_id: "t-1".into(),
828            context_id: None,
829            status: TaskStatus {
830                state: TaskState::Working,
831                timestamp: "ts".into(),
832                message: None,
833            },
834            is_final: false,
835        });
836        let cloned = event.clone();
837        let json1 = serde_json::to_string(&event).unwrap();
838        let json2 = serde_json::to_string(&cloned).unwrap();
839        assert_eq!(json1, json2);
840    }
841
842    #[test]
843    fn task_event_debug() {
844        let event = TaskEvent::ArtifactUpdate(TaskArtifactUpdateEvent {
845            kind: "artifact-update".into(),
846            task_id: "t-1".into(),
847            context_id: None,
848            artifact: Artifact {
849                artifact_id: "a-1".into(),
850                name: None,
851                parts: vec![Part::text("data")],
852                metadata: None,
853            },
854            is_final: true,
855        });
856        let dbg = format!("{event:?}");
857        assert!(dbg.contains("ArtifactUpdate"));
858    }
859
860    #[test]
861    fn is_private_ip_ipv4_non_private() {
862        assert!(!is_private_ip("93.184.216.34".parse().unwrap()));
863    }
864
865    #[test]
866    fn is_private_ip_ipv6_non_private() {
867        assert!(!is_private_ip("2001:db8::1".parse().unwrap()));
868    }
869
870    #[test]
871    fn rpc_response_error_takes_priority_over_result() {
872        let resp = JsonRpcResponse {
873            jsonrpc: "2.0".into(),
874            id: serde_json::Value::String("1".into()),
875            result: Some(Task {
876                id: "t-1".into(),
877                context_id: None,
878                status: TaskStatus {
879                    state: TaskState::Completed,
880                    timestamp: "ts".into(),
881                    message: None,
882                },
883                artifacts: vec![],
884                history: vec![],
885                metadata: None,
886            }),
887            error: Some(JsonRpcError {
888                code: -32001,
889                message: "error".into(),
890                data: None,
891            }),
892        };
893        let err = resp.into_result().unwrap_err();
894        assert_eq!(err.code, -32001);
895    }
896
897    #[test]
898    fn rpc_response_neither_result_nor_error() {
899        let resp: JsonRpcResponse<Task> = JsonRpcResponse {
900            jsonrpc: "2.0".into(),
901            id: serde_json::Value::String("1".into()),
902            result: None,
903            error: None,
904        };
905        let err = resp.into_result().unwrap_err();
906        assert_eq!(err.code, -32603);
907    }
908
909    #[test]
910    fn task_event_serialize_round_trip() {
911        let event = TaskEvent::StatusUpdate(TaskStatusUpdateEvent {
912            kind: "status-update".into(),
913            task_id: "t-1".into(),
914            context_id: Some("ctx-1".into()),
915            status: TaskStatus {
916                state: TaskState::Completed,
917                timestamp: "2025-01-01T00:00:00Z".into(),
918                message: Some(Message::user_text("done")),
919            },
920            is_final: true,
921        });
922        let json = serde_json::to_string(&event).unwrap();
923        let back: TaskEvent = serde_json::from_str(&json).unwrap();
924        assert_matches!(back, TaskEvent::StatusUpdate(_));
925    }
926}
927
928#[cfg(test)]
929mod wiremock_tests {
930    use std::assert_matches;
931    use tokio_stream::StreamExt;
932    use wiremock::matchers::{header, method, path};
933    use wiremock::{Mock, MockServer, ResponseTemplate};
934
935    use crate::client::{A2aClient, PinnedTarget, SecurityPolicy};
936    use crate::jsonrpc::{SendMessageParams, TaskIdParams};
937    use crate::testing::*;
938    use crate::types::Message;
939
940    #[tokio::test]
941    async fn send_message_success() {
942        let server = MockServer::start().await;
943        Mock::given(method("POST"))
944            .and(path("/rpc"))
945            .respond_with(task_rpc_response("task-1", "submitted"))
946            .mount(&server)
947            .await;
948
949        let client = A2aClient::new(reqwest::Client::new());
950        let params = SendMessageParams {
951            message: Message::user_text("hello"),
952            configuration: None,
953        };
954        let task = client
955            .send_message(&format!("{}/rpc", server.uri()), params, None)
956            .await
957            .unwrap();
958        assert_eq!(task.id, "task-1");
959    }
960
961    #[tokio::test]
962    async fn send_message_rpc_error() {
963        let server = MockServer::start().await;
964        Mock::given(method("POST"))
965            .and(path("/rpc"))
966            .respond_with(task_rpc_error_response(-32001, "task not found"))
967            .mount(&server)
968            .await;
969
970        let client = A2aClient::new(reqwest::Client::new());
971        let params = SendMessageParams {
972            message: Message::user_text("hi"),
973            configuration: None,
974        };
975        let result = client
976            .send_message(&format!("{}/rpc", server.uri()), params, None)
977            .await;
978        assert!(result.is_err());
979        let err = result.unwrap_err();
980        assert_matches!(err, crate::error::A2aError::JsonRpc { code: -32001, .. });
981    }
982
983    #[tokio::test]
984    async fn send_message_with_bearer_auth() {
985        let server = MockServer::start().await;
986        Mock::given(method("POST"))
987            .and(path("/rpc"))
988            .and(header("authorization", "Bearer secret-token"))
989            .respond_with(task_rpc_response("task-auth", "submitted"))
990            .mount(&server)
991            .await;
992
993        let client = A2aClient::new(reqwest::Client::new());
994        let params = SendMessageParams {
995            message: Message::user_text("secure"),
996            configuration: None,
997        };
998        let task = client
999            .send_message(
1000                &format!("{}/rpc", server.uri()),
1001                params,
1002                Some("secret-token"),
1003            )
1004            .await
1005            .unwrap();
1006        assert_eq!(task.id, "task-auth");
1007    }
1008
1009    #[tokio::test]
1010    async fn get_task_success() {
1011        let server = MockServer::start().await;
1012        Mock::given(method("POST"))
1013            .and(path("/rpc"))
1014            .respond_with(task_rpc_response("task-get", "completed"))
1015            .mount(&server)
1016            .await;
1017
1018        let client = A2aClient::new(reqwest::Client::new());
1019        let params = TaskIdParams {
1020            id: "task-get".into(),
1021            history_length: None,
1022        };
1023        let task = client
1024            .get_task(&format!("{}/rpc", server.uri()), params, None)
1025            .await
1026            .unwrap();
1027        assert_eq!(task.id, "task-get");
1028    }
1029
1030    #[tokio::test]
1031    async fn cancel_task_success() {
1032        let server = MockServer::start().await;
1033        Mock::given(method("POST"))
1034            .and(path("/rpc"))
1035            .respond_with(task_rpc_response("task-cancel", "canceled"))
1036            .mount(&server)
1037            .await;
1038
1039        let client = A2aClient::new(reqwest::Client::new());
1040        let params = TaskIdParams {
1041            id: "task-cancel".into(),
1042            history_length: None,
1043        };
1044        let task = client
1045            .cancel_task(&format!("{}/rpc", server.uri()), params, None)
1046            .await
1047            .unwrap();
1048        assert_eq!(task.id, "task-cancel");
1049    }
1050
1051    #[tokio::test]
1052    async fn stream_message_success() {
1053        let server = MockServer::start().await;
1054        Mock::given(method("POST"))
1055            .and(path("/rpc"))
1056            .respond_with(sse_task_events_response("task-stream", "result content"))
1057            .mount(&server)
1058            .await;
1059
1060        let client = A2aClient::new(reqwest::Client::new());
1061        let params = SendMessageParams {
1062            message: Message::user_text("stream"),
1063            configuration: None,
1064        };
1065        let stream = client
1066            .stream_message(&format!("{}/rpc", server.uri()), params, None)
1067            .await
1068            .unwrap();
1069        let events: Vec<_> = stream.collect().await;
1070        assert!(!events.is_empty());
1071    }
1072
1073    #[tokio::test]
1074    async fn stream_message_http_error() {
1075        let server = MockServer::start().await;
1076        Mock::given(method("POST"))
1077            .and(path("/rpc"))
1078            .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
1079            .mount(&server)
1080            .await;
1081
1082        let client = A2aClient::new(reqwest::Client::new());
1083        let params = SendMessageParams {
1084            message: Message::user_text("fail"),
1085            configuration: None,
1086        };
1087        let result = client
1088            .stream_message(&format!("{}/rpc", server.uri()), params, None)
1089            .await;
1090        let err = result.err().expect("expected error");
1091        assert_matches!(err, crate::error::A2aError::Stream(_));
1092    }
1093
1094    #[tokio::test]
1095    async fn rpc_call_times_out() {
1096        let server = MockServer::start().await;
1097        Mock::given(method("POST"))
1098            .and(path("/rpc"))
1099            .respond_with(
1100                ResponseTemplate::new(200)
1101                    .set_delay(std::time::Duration::from_secs(5))
1102                    .set_body_json(serde_json::json!({
1103                        "jsonrpc": "2.0",
1104                        "id": "req-1",
1105                        "result": {
1106                            "id": "t-1",
1107                            "status": {"state": "completed", "timestamp": "2026-01-01T00:00:00Z"}
1108                        }
1109                    })),
1110            )
1111            .mount(&server)
1112            .await;
1113
1114        let client = A2aClient::new(reqwest::Client::new())
1115            .with_request_timeout(std::time::Duration::from_millis(100));
1116        let params = SendMessageParams {
1117            message: Message::user_text("hello"),
1118            configuration: None,
1119        };
1120        let result = client
1121            .send_message(&format!("{}/rpc", server.uri()), params, None)
1122            .await;
1123        assert!(result.is_err());
1124        assert!(
1125            matches!(result.unwrap_err(), crate::error::A2aError::Timeout(_)),
1126            "expected Timeout error"
1127        );
1128    }
1129
1130    /// Proves the DNS-rebinding TOCTOU is closed: `resolve_to_addrs` pins the connection to
1131    /// the address validated by `resolve_and_validate`, so reqwest never re-resolves `fake_host`
1132    /// (a hostname reserved by RFC 2606 and guaranteed to never resolve via real DNS) at connect
1133    /// time. If the client re-resolved instead of using the pinned address, this request would
1134    /// fail with a DNS lookup error rather than reaching the mock server.
1135    #[tokio::test]
1136    async fn hardened_client_pins_connection_bypassing_dns() {
1137        let server = MockServer::start().await;
1138        Mock::given(method("GET"))
1139            .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
1140            .mount(&server)
1141            .await;
1142
1143        let addr = *server.address();
1144        let fake_host = "zeph-a2a-pin-test.invalid";
1145        let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
1146            require_tls: false,
1147            ssrf_protection: true,
1148        });
1149        let pinned = PinnedTarget {
1150            host: fake_host.to_owned(),
1151            addrs: vec![addr],
1152        };
1153        let hardened = client.build_hardened_client(Some(&pinned)).unwrap();
1154
1155        let resp = hardened
1156            .get(format!("http://{fake_host}:{}/", addr.port()))
1157            .send()
1158            .await
1159            .unwrap_or_else(|e| panic!("pinned request to unresolvable host failed: {e}"));
1160        assert_eq!(resp.status(), 200);
1161        assert_eq!(resp.text().await.unwrap(), "ok");
1162    }
1163
1164    /// Proves the redirect-based SSRF bypass is closed: the hardened client does not
1165    /// automatically follow a `3xx` response, even when `Location` points at a private
1166    /// address. `rpc_call`/`stream_message` treat the raw redirect response as a normal
1167    /// (non-2xx) response and surface an error instead of connecting to `Location`.
1168    #[tokio::test]
1169    async fn hardened_client_does_not_auto_follow_redirect_to_private_ip() {
1170        let server = MockServer::start().await;
1171        Mock::given(method("GET"))
1172            .respond_with(
1173                ResponseTemplate::new(302).insert_header("Location", "http://127.0.0.1:9/internal"),
1174            )
1175            .mount(&server)
1176            .await;
1177
1178        let addr = *server.address();
1179        let fake_host = "zeph-a2a-redirect-test.invalid";
1180        let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
1181            require_tls: false,
1182            ssrf_protection: true,
1183        });
1184        let pinned = PinnedTarget {
1185            host: fake_host.to_owned(),
1186            addrs: vec![addr],
1187        };
1188        let hardened = client.build_hardened_client(Some(&pinned)).unwrap();
1189
1190        let resp = hardened
1191            .get(format!("http://{fake_host}:{}/start", addr.port()))
1192            .send()
1193            .await
1194            .unwrap();
1195
1196        assert_eq!(resp.status(), reqwest::StatusCode::FOUND);
1197        assert_eq!(
1198            resp.headers().get(reqwest::header::LOCATION).unwrap(),
1199            "http://127.0.0.1:9/internal"
1200        );
1201    }
1202
1203    /// Proves TLS enforcement holds even on the hardened per-request client: `https_only(true)`
1204    /// rejects a plaintext `http://` connection outright, closing the https-to-http downgrade
1205    /// gap that an unvalidated redirect could otherwise exploit.
1206    #[tokio::test]
1207    async fn hardened_client_with_require_tls_rejects_plaintext_connection() {
1208        let server = MockServer::start().await;
1209        Mock::given(method("GET"))
1210            .respond_with(ResponseTemplate::new(200))
1211            .mount(&server)
1212            .await;
1213
1214        let addr = *server.address();
1215        let fake_host = "zeph-a2a-tls-test.invalid";
1216        let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
1217            require_tls: true,
1218            ssrf_protection: true,
1219        });
1220        let pinned = PinnedTarget {
1221            host: fake_host.to_owned(),
1222            addrs: vec![addr],
1223        };
1224        let hardened = client.build_hardened_client(Some(&pinned)).unwrap();
1225
1226        let result = hardened
1227            .get(format!("http://{fake_host}:{}/", addr.port()))
1228            .send()
1229            .await;
1230        assert!(
1231            result.is_err(),
1232            "https_only(true) must reject a plain http:// URL"
1233        );
1234    }
1235}