1use std::error::Error as _;
2
3use reqwest::Url;
4use serde_json::Value;
5use tokio::sync::broadcast;
6
7use crate::triggers::TriggerEvent;
8
9const A2A_AGENT_CARD_PATH: &str = ".well-known/a2a-agent";
10const A2A_PROTOCOL_VERSION: &str = "1.0.0";
11
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct ResolvedA2aEndpoint {
14 pub card_url: String,
15 pub rpc_url: String,
16 pub agent_id: Option<String>,
17 pub target_agent: String,
18}
19
20#[derive(Clone, Debug, PartialEq)]
21pub enum DispatchAck {
22 InlineResult {
23 task_id: String,
24 result: Value,
25 },
26 PendingTask {
27 task_id: String,
28 state: String,
29 handle: Value,
30 },
31}
32
33#[derive(Debug)]
34pub enum A2aClientError {
35 InvalidTarget(String),
36 Discovery(String),
37 Protocol(String),
38 Cancelled(String),
39}
40
41impl std::fmt::Display for A2aClientError {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 match self {
44 Self::InvalidTarget(message)
45 | Self::Discovery(message)
46 | Self::Protocol(message)
47 | Self::Cancelled(message) => f.write_str(message),
48 }
49 }
50}
51
52impl std::error::Error for A2aClientError {}
53
54#[derive(Debug)]
55enum AgentCardFetchError {
56 Cancelled(String),
57 Discovery(String),
58 ConnectRefused(String),
59}
60
61pub async fn dispatch_trigger_event(
62 raw_target: &str,
63 allow_cleartext: bool,
64 binding_id: &str,
65 binding_key: &str,
66 event: &TriggerEvent,
67 cancel_rx: &mut broadcast::Receiver<()>,
68) -> Result<(ResolvedA2aEndpoint, DispatchAck), A2aClientError> {
69 let target = parse_target(raw_target)?;
70 let endpoint = resolve_endpoint(&target, allow_cleartext, cancel_rx).await?;
71 let message_id = format!("{}.{}", event.trace_id.0, event.id.0);
72 let envelope = serde_json::json!({
73 "kind": "harn.trigger.dispatch",
74 "message_id": message_id,
75 "trace_id": event.trace_id.0,
76 "event_id": event.id.0,
77 "trigger_id": binding_id,
78 "binding_key": binding_key,
79 "target_agent": endpoint.target_agent,
80 "event": event,
81 });
82 let text = serde_json::to_string(&envelope)
83 .map_err(|error| A2aClientError::Protocol(format!("serialize A2A envelope: {error}")))?;
84 let request = crate::jsonrpc::request(
85 message_id.clone(),
86 "a2a.SendMessage",
87 serde_json::json!({
88 "contextId": event.trace_id.0,
89 "message": {
90 "messageId": message_id,
91 "role": "user",
92 "parts": [{
93 "type": "text",
94 "text": text,
95 }],
96 "metadata": {
97 "kind": "harn.trigger.dispatch",
98 "trace_id": event.trace_id.0,
99 "event_id": event.id.0,
100 "trigger_id": binding_id,
101 "binding_key": binding_key,
102 "target_agent": endpoint.target_agent,
103 },
104 },
105 }),
106 );
107
108 let body = send_jsonrpc(&endpoint.rpc_url, &request, &event.trace_id.0, cancel_rx).await?;
109 let result = body.get("result").cloned().ok_or_else(|| {
110 if let Some(error) = body.get("error") {
111 let message = error
112 .get("message")
113 .and_then(Value::as_str)
114 .unwrap_or("unknown A2A error");
115 A2aClientError::Protocol(format!("A2A task dispatch failed: {message}"))
116 } else {
117 A2aClientError::Protocol("A2A task dispatch response missing result".to_string())
118 }
119 })?;
120
121 let task_id = result
122 .get("id")
123 .and_then(Value::as_str)
124 .filter(|value| !value.is_empty())
125 .ok_or_else(|| A2aClientError::Protocol("A2A task response missing result.id".to_string()))?
126 .to_string();
127 let state = task_state(&result)?.to_string();
128
129 if state == "completed" {
130 let inline = extract_inline_result(&result);
131 return Ok((
132 endpoint,
133 DispatchAck::InlineResult {
134 task_id,
135 result: inline,
136 },
137 ));
138 }
139
140 Ok((
141 endpoint.clone(),
142 DispatchAck::PendingTask {
143 task_id: task_id.clone(),
144 state: state.clone(),
145 handle: serde_json::json!({
146 "kind": "a2a_task_handle",
147 "task_id": task_id,
148 "state": state,
149 "target_agent": endpoint.target_agent,
150 "rpc_url": endpoint.rpc_url,
151 "card_url": endpoint.card_url,
152 "agent_id": endpoint.agent_id,
153 }),
154 },
155 ))
156}
157
158pub fn target_agent_label(raw_target: &str) -> String {
159 parse_target(raw_target)
160 .map(|target| target.target_agent_label())
161 .unwrap_or_else(|_| raw_target.to_string())
162}
163
164#[derive(Clone, Debug)]
165struct ParsedTarget {
166 authority: String,
167 target_agent: String,
168}
169
170impl ParsedTarget {
171 fn target_agent_label(&self) -> String {
172 if self.target_agent.is_empty() {
173 self.authority.clone()
174 } else {
175 self.target_agent.clone()
176 }
177 }
178}
179
180fn parse_target(raw_target: &str) -> Result<ParsedTarget, A2aClientError> {
181 let parsed = Url::parse(&format!("http://{raw_target}")).map_err(|error| {
182 A2aClientError::InvalidTarget(format!(
183 "invalid a2a dispatch target '{raw_target}': {error}"
184 ))
185 })?;
186 let host = parsed.host_str().ok_or_else(|| {
187 A2aClientError::InvalidTarget(format!(
188 "invalid a2a dispatch target '{raw_target}': missing host"
189 ))
190 })?;
191 let authority = if let Some(port) = parsed.port() {
192 format!("{host}:{port}")
193 } else {
194 host.to_string()
195 };
196 Ok(ParsedTarget {
197 authority,
198 target_agent: parsed.path().trim_start_matches('/').to_string(),
199 })
200}
201
202async fn resolve_endpoint(
203 target: &ParsedTarget,
204 allow_cleartext: bool,
205 cancel_rx: &mut broadcast::Receiver<()>,
206) -> Result<ResolvedA2aEndpoint, A2aClientError> {
207 let mut last_error = None;
208 for scheme in card_resolution_schemes(allow_cleartext) {
209 let card_url = format!("{scheme}://{}/{A2A_AGENT_CARD_PATH}", target.authority);
210 match fetch_agent_card(&card_url, cancel_rx).await {
211 Ok(card) => {
212 return endpoint_from_card(
213 card_url,
214 allow_cleartext,
215 &target.authority,
216 target.target_agent.clone(),
217 &card,
218 );
219 }
220 Err(AgentCardFetchError::Cancelled(message)) => {
221 return Err(A2aClientError::Cancelled(message));
222 }
223 Err(error) => {
224 let message = agent_card_fetch_error_message(&error);
225 last_error = Some(message);
226 if should_try_cleartext_fallback(scheme, allow_cleartext, &error, &target.authority)
227 {
228 continue;
229 }
230 break;
231 }
232 }
233 }
234 Err(A2aClientError::Discovery(format!(
235 "could not resolve A2A agent card for '{}': {}",
236 target.authority,
237 last_error.unwrap_or_else(|| "unknown discovery error".to_string())
238 )))
239}
240
241async fn fetch_agent_card(
242 card_url: &str,
243 cancel_rx: &mut broadcast::Receiver<()>,
244) -> Result<Value, AgentCardFetchError> {
245 let response = tokio::select! {
246 response = crate::llm::shared_utility_client().get(card_url).send() => {
247 match response {
248 Ok(response) => Ok(response),
249 Err(error) if is_connect_refused(&error) => Err(AgentCardFetchError::ConnectRefused(
250 format!("A2A HTTP request failed: {error}")
251 )),
252 Err(error) => Err(AgentCardFetchError::Discovery(
253 format!("A2A HTTP request failed: {error}")
254 )),
255 }
256 }
257 _ = recv_cancel(cancel_rx) => Err(AgentCardFetchError::Cancelled(
258 "A2A agent-card fetch cancelled".to_string()
259 )),
260 }?;
261 if !response.status().is_success() {
262 return Err(AgentCardFetchError::Discovery(format!(
263 "GET {card_url} returned HTTP {}",
264 response.status()
265 )));
266 }
267 response
268 .json::<Value>()
269 .await
270 .map_err(|error| AgentCardFetchError::Discovery(format!("parse {card_url}: {error}")))
271}
272
273fn endpoint_from_card(
274 card_url: String,
275 allow_cleartext: bool,
276 requested_authority: &str,
277 target_agent: String,
278 card: &Value,
279) -> Result<ResolvedA2aEndpoint, A2aClientError> {
280 let base_url = card
281 .get("url")
282 .and_then(Value::as_str)
283 .ok_or_else(|| A2aClientError::Discovery("A2A agent card missing url".to_string()))?;
284 let base_url = Url::parse(base_url).map_err(|error| {
285 A2aClientError::Discovery(format!("invalid A2A card url '{base_url}': {error}"))
286 })?;
287 ensure_cleartext_allowed(&base_url, allow_cleartext, "agent card")?;
288 let card_authority = url_authority(&base_url)?;
289 if !authorities_equivalent(&card_authority, requested_authority) {
290 return Err(A2aClientError::Discovery(format!(
291 "A2A agent card url authority mismatch: requested '{requested_authority}', card returned '{card_authority}'"
292 )));
293 }
294 let interfaces = card
295 .get("interfaces")
296 .and_then(Value::as_array)
297 .ok_or_else(|| {
298 A2aClientError::Discovery("A2A agent card missing interfaces".to_string())
299 })?;
300 let jsonrpc_interfaces: Vec<&Value> = interfaces
301 .iter()
302 .filter(|entry| entry.get("protocol").and_then(Value::as_str) == Some("jsonrpc"))
303 .collect();
304 if jsonrpc_interfaces.len() != 1 {
305 return Err(A2aClientError::Discovery(format!(
306 "A2A agent card must expose exactly one jsonrpc interface, found {}",
307 jsonrpc_interfaces.len()
308 )));
309 }
310 let interface_url = jsonrpc_interfaces[0]
311 .get("url")
312 .and_then(Value::as_str)
313 .ok_or_else(|| {
314 A2aClientError::Discovery("A2A jsonrpc interface missing url".to_string())
315 })?;
316 let rpc_url = base_url.join(interface_url).map_err(|error| {
317 A2aClientError::Discovery(format!(
318 "invalid A2A interface url '{interface_url}': {error}"
319 ))
320 })?;
321 ensure_cleartext_allowed(&rpc_url, allow_cleartext, "jsonrpc interface")?;
322 Ok(ResolvedA2aEndpoint {
323 card_url,
324 rpc_url: rpc_url.to_string(),
325 agent_id: card.get("id").and_then(Value::as_str).map(str::to_string),
326 target_agent,
327 })
328}
329
330fn card_resolution_schemes(allow_cleartext: bool) -> &'static [&'static str] {
331 if allow_cleartext {
332 &["https", "http"]
333 } else {
334 &["https"]
335 }
336}
337
338fn should_try_cleartext_fallback(
351 scheme: &str,
352 allow_cleartext: bool,
353 error: &AgentCardFetchError,
354 authority: &str,
355) -> bool {
356 if !allow_cleartext || scheme != "https" {
357 return false;
358 }
359 match error {
360 AgentCardFetchError::Cancelled(_) => false,
361 AgentCardFetchError::ConnectRefused(_) => true,
362 AgentCardFetchError::Discovery(_) => is_loopback_authority(authority),
363 }
364}
365
366fn ensure_cleartext_allowed(
367 url: &Url,
368 allow_cleartext: bool,
369 label: &str,
370) -> Result<(), A2aClientError> {
371 if allow_cleartext || url.scheme() != "http" {
372 return Ok(());
373 }
374 Err(A2aClientError::Discovery(format!(
375 "cleartext A2A {label} '{url}' requires `allow_cleartext = true` on the trigger binding"
376 )))
377}
378
379fn is_loopback_authority(authority: &str) -> bool {
380 let (host, _) = split_authority(authority);
381 if host.eq_ignore_ascii_case("localhost") {
382 return true;
383 }
384 if let Ok(ip) = host.parse::<std::net::IpAddr>() {
385 return ip.is_loopback();
386 }
387 false
388}
389
390fn authorities_equivalent(card_authority: &str, requested_authority: &str) -> bool {
401 if card_authority == requested_authority {
402 return true;
403 }
404 let (_, card_port) = split_authority(card_authority);
405 let (_, requested_port) = split_authority(requested_authority);
406 if card_port != requested_port {
407 return false;
408 }
409 is_loopback_authority(card_authority) && is_loopback_authority(requested_authority)
410}
411
412fn split_authority(authority: &str) -> (&str, &str) {
415 let (host_raw, port) = if authority.starts_with('[') {
416 if let Some(end) = authority.rfind(']') {
418 let host = &authority[..=end];
419 let rest = &authority[end + 1..];
420 let port = rest.strip_prefix(':').unwrap_or("");
421 (host, port)
422 } else {
423 (authority, "")
424 }
425 } else {
426 match authority.rsplit_once(':') {
427 Some((host, port)) => (host, port),
428 None => (authority, ""),
429 }
430 };
431 let host = host_raw.trim_start_matches('[').trim_end_matches(']');
432 (host, port)
433}
434
435fn agent_card_fetch_error_message(error: &AgentCardFetchError) -> String {
436 match error {
437 AgentCardFetchError::Cancelled(message)
438 | AgentCardFetchError::Discovery(message)
439 | AgentCardFetchError::ConnectRefused(message) => message.clone(),
440 }
441}
442
443fn is_connect_refused(error: &reqwest::Error) -> bool {
444 if !error.is_connect() {
445 return false;
446 }
447 let mut source = error.source();
448 while let Some(cause) = source {
449 if let Some(io_error) = cause.downcast_ref::<std::io::Error>() {
450 if io_error.kind() == std::io::ErrorKind::ConnectionRefused {
451 return true;
452 }
453 }
454 source = cause.source();
455 }
456 false
457}
458
459fn url_authority(url: &Url) -> Result<String, A2aClientError> {
460 let host = url
461 .host_str()
462 .ok_or_else(|| A2aClientError::Discovery(format!("A2A card url '{url}' missing host")))?;
463 Ok(if let Some(port) = url.port() {
464 format!("{host}:{port}")
465 } else {
466 host.to_string()
467 })
468}
469
470async fn send_jsonrpc(
471 rpc_url: &str,
472 request: &Value,
473 trace_id: &str,
474 cancel_rx: &mut broadcast::Receiver<()>,
475) -> Result<Value, A2aClientError> {
476 let response = send_http(
477 crate::llm::shared_blocking_client()
478 .post(rpc_url)
479 .header(reqwest::header::CONTENT_TYPE, "application/json")
480 .header("A2A-Version", A2A_PROTOCOL_VERSION)
481 .header("A2A-Trace-Id", trace_id)
482 .json(request),
483 cancel_rx,
484 "A2A task dispatch cancelled",
485 )
486 .await?;
487 if !response.status().is_success() {
488 return Err(A2aClientError::Protocol(format!(
489 "A2A task dispatch returned HTTP {}",
490 response.status()
491 )));
492 }
493 response
494 .json::<Value>()
495 .await
496 .map_err(|error| A2aClientError::Protocol(format!("parse A2A dispatch response: {error}")))
497}
498
499async fn send_http(
500 request: reqwest::RequestBuilder,
501 cancel_rx: &mut broadcast::Receiver<()>,
502 cancelled_message: &'static str,
503) -> Result<reqwest::Response, A2aClientError> {
504 tokio::select! {
505 response = request.send() => response
506 .map_err(|error| A2aClientError::Protocol(format!("A2A HTTP request failed: {error}"))),
507 _ = recv_cancel(cancel_rx) => Err(A2aClientError::Cancelled(cancelled_message.to_string())),
508 }
509}
510
511fn task_state(task: &Value) -> Result<&str, A2aClientError> {
512 task.pointer("/status/state")
513 .and_then(Value::as_str)
514 .filter(|value| !value.is_empty())
515 .ok_or_else(|| {
516 A2aClientError::Protocol("A2A task response missing result.status.state".to_string())
517 })
518}
519
520fn extract_inline_result(task: &Value) -> Value {
521 let text = task
522 .get("history")
523 .and_then(Value::as_array)
524 .and_then(|history| {
525 history.iter().rev().find_map(|message| {
526 let role = message.get("role").and_then(Value::as_str)?;
527 if role != "agent" {
528 return None;
529 }
530 message
531 .get("parts")
532 .and_then(Value::as_array)
533 .and_then(|parts| {
534 parts.iter().find_map(|part| {
535 if part.get("type").and_then(Value::as_str) == Some("text") {
536 part.get("text").and_then(Value::as_str).map(str::trim_end)
537 } else {
538 None
539 }
540 })
541 })
542 })
543 });
544 match text {
545 Some(text) if !text.is_empty() => {
546 serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.to_string()))
547 }
548 _ => task.clone(),
549 }
550}
551
552async fn recv_cancel(cancel_rx: &mut broadcast::Receiver<()>) {
553 let _ = cancel_rx.recv().await;
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 #[test]
561 fn target_agent_label_prefers_path() {
562 assert_eq!(target_agent_label("reviewer.prod/triage"), "triage");
563 assert_eq!(target_agent_label("reviewer.prod"), "reviewer.prod");
564 }
565
566 #[test]
567 fn extract_inline_result_parses_json_text() {
568 let task = serde_json::json!({
569 "history": [
570 {"role": "user", "parts": [{"type": "text", "text": "ignored"}]},
571 {"role": "agent", "parts": [{"type": "text", "text": "{\"trace_id\":\"trace_123\"}\n"}]},
572 ]
573 });
574 assert_eq!(
575 extract_inline_result(&task),
576 serde_json::json!({"trace_id": "trace_123"})
577 );
578 }
579
580 #[test]
581 fn discovery_prefers_https_before_http() {
582 assert_eq!(card_resolution_schemes(false), ["https"]);
583 assert_eq!(card_resolution_schemes(true), ["https", "http"]);
584 }
585
586 #[test]
587 fn cleartext_fallback_only_after_https_connect_refused() {
588 assert!(should_try_cleartext_fallback(
589 "https",
590 true,
591 &AgentCardFetchError::ConnectRefused("connect refused".to_string()),
592 "reviewer.example:443",
593 ));
594 assert!(!should_try_cleartext_fallback(
595 "http",
596 true,
597 &AgentCardFetchError::ConnectRefused("connect refused".to_string()),
598 "reviewer.example:443",
599 ));
600 assert!(!should_try_cleartext_fallback(
601 "https",
602 true,
603 &AgentCardFetchError::Discovery("tls handshake failed".to_string()),
604 "reviewer.example:443",
605 ));
606 }
607
608 #[test]
609 fn cleartext_fallback_requires_opt_in_even_for_loopback_authorities() {
610 for authority in [
611 "127.0.0.1:8080",
612 "localhost:8080",
613 "[::1]:8080",
614 "127.1.2.3:9000",
615 ] {
616 assert!(
617 !should_try_cleartext_fallback(
618 "https",
619 false,
620 &AgentCardFetchError::Discovery("tls handshake failed".to_string()),
621 authority,
622 ),
623 "cleartext fallback must stay disabled without opt-in for '{authority}'"
624 );
625 }
626 }
627
628 #[test]
629 fn cleartext_fallback_allows_loopback_after_opt_in() {
630 for authority in [
633 "127.0.0.1:8080",
634 "localhost:8080",
635 "[::1]:8080",
636 "127.1.2.3:9000",
637 ] {
638 assert!(
639 should_try_cleartext_fallback(
640 "https",
641 true,
642 &AgentCardFetchError::Discovery("tls handshake failed".to_string()),
643 authority,
644 ),
645 "expected cleartext fallback for loopback authority '{authority}'"
646 );
647 }
648 }
649
650 #[test]
651 fn cleartext_fallback_denies_external_tls_failures() {
652 for authority in [
655 "reviewer.example:443",
656 "8.8.8.8:443",
657 "192.168.1.10:8080",
658 "10.0.0.5:8443",
659 ] {
660 assert!(
661 !should_try_cleartext_fallback(
662 "https",
663 true,
664 &AgentCardFetchError::Discovery("tls handshake failed".to_string()),
665 authority,
666 ),
667 "cleartext fallback must be denied for external authority '{authority}'"
668 );
669 }
670 }
671
672 #[test]
673 fn is_loopback_authority_recognises_loopback_forms() {
674 assert!(is_loopback_authority("127.0.0.1:8080"));
675 assert!(is_loopback_authority("localhost:8080"));
676 assert!(is_loopback_authority("LOCALHOST:9000"));
677 assert!(is_loopback_authority("[::1]:8080"));
678 assert!(is_loopback_authority("127.5.5.5:1234"));
679 assert!(!is_loopback_authority("8.8.8.8:443"));
680 assert!(!is_loopback_authority("192.168.1.10:8080"));
681 assert!(!is_loopback_authority("example.com:443"));
682 assert!(!is_loopback_authority("reviewer.prod"));
683 }
684
685 #[test]
686 fn endpoint_from_card_rejects_card_url_authority_mismatch() {
687 let error = endpoint_from_card(
688 "https://trusted.example/.well-known/a2a-agent".to_string(),
689 false,
690 "trusted.example",
691 "triage".to_string(),
692 &serde_json::json!({
693 "url": "https://evil.example",
694 "interfaces": [{"protocol": "jsonrpc", "url": "/rpc"}],
695 }),
696 )
697 .unwrap_err();
698 assert_eq!(
699 error.to_string(),
700 "A2A agent card url authority mismatch: requested 'trusted.example', card returned 'evil.example'"
701 );
702 }
703
704 #[test]
705 fn endpoint_from_card_rejects_cleartext_without_opt_in() {
706 let error = endpoint_from_card(
707 "https://127.0.0.1:8080/.well-known/a2a-agent".to_string(),
708 false,
709 "127.0.0.1:8080",
710 "triage".to_string(),
711 &serde_json::json!({
712 "url": "http://localhost:8080",
713 "interfaces": [{"protocol": "jsonrpc", "url": "/rpc"}],
714 }),
715 )
716 .expect_err("cleartext card should require explicit opt-in");
717 assert!(error
718 .to_string()
719 .contains("requires `allow_cleartext = true`"));
720 }
721
722 #[test]
723 fn endpoint_from_card_accepts_loopback_alias_pairs_when_cleartext_opted_in() {
724 let card = serde_json::json!({
728 "url": "http://localhost:8080",
729 "interfaces": [{"protocol": "jsonrpc", "url": "/rpc"}],
730 });
731 let endpoint = endpoint_from_card(
732 "http://127.0.0.1:8080/.well-known/a2a-agent".to_string(),
733 true,
734 "127.0.0.1:8080",
735 "triage".to_string(),
736 &card,
737 )
738 .expect("loopback alias pair should be accepted");
739 assert_eq!(endpoint.rpc_url, "http://localhost:8080/rpc");
740
741 let card_v6 = serde_json::json!({
743 "url": "http://[::1]:8080",
744 "interfaces": [{"protocol": "jsonrpc", "url": "/rpc"}],
745 });
746 let endpoint_v6 = endpoint_from_card(
747 "http://localhost:8080/.well-known/a2a-agent".to_string(),
748 true,
749 "localhost:8080",
750 "triage".to_string(),
751 &card_v6,
752 )
753 .expect("IPv6 loopback alias should be accepted");
754 assert_eq!(endpoint_v6.rpc_url, "http://[::1]:8080/rpc");
755
756 let card_wrong_port = serde_json::json!({
758 "url": "http://localhost:9000",
759 "interfaces": [{"protocol": "jsonrpc", "url": "/rpc"}],
760 });
761 let error = endpoint_from_card(
762 "http://127.0.0.1:8080/.well-known/a2a-agent".to_string(),
763 true,
764 "127.0.0.1:8080",
765 "triage".to_string(),
766 &card_wrong_port,
767 )
768 .expect_err("mismatched ports must still be rejected even on loopback");
769 assert!(error
770 .to_string()
771 .contains("A2A agent card url authority mismatch"));
772 }
773
774 #[test]
775 fn authorities_equivalent_rejects_non_loopback_host_mismatch() {
776 assert!(!authorities_equivalent(
777 "internal.corp.example:443",
778 "trusted.example:443",
779 ));
780 assert!(!authorities_equivalent("10.0.0.5:8080", "127.0.0.1:8080",));
781 assert!(authorities_equivalent(
782 "trusted.example:443",
783 "trusted.example:443",
784 ));
785 }
786}