1use crate::{
6 DEFAULT_USER_AGENT, REQUEST_TIMEOUT,
7 provider::{
8 mpp::transport::{LazyMppHttpTransport, lazy_mpp_ws_connect},
9 redact_url,
10 },
11};
12use alloy_json_rpc::{RequestPacket, ResponsePacket};
13use alloy_pubsub::{PubSubConnect, PubSubFrontend};
14use alloy_rpc_types_engine::{Claims, JwtSecret};
15use alloy_transport::{
16 Authorization, BoxTransport, TransportError, TransportErrorKind, TransportFut,
17 utils::guess_local_url,
18};
19use alloy_transport_ipc::IpcConnect;
20use alloy_transport_ws::WsConnect;
21use regex::{Captures, Regex};
22use reqwest::header::{HeaderName, HeaderValue};
23use std::{
24 error::Error as StdError,
25 fmt,
26 path::PathBuf,
27 str::FromStr,
28 sync::{Arc, LazyLock},
29};
30use thiserror::Error;
31use tokio::sync::RwLock;
32use tower::Service;
33use url::Url;
34
35const KNOWN_MPP_HOSTS: &[&str] = &[".mpp.tempo.xyz", ".mpp.moderato.tempo.xyz"];
40
41static HTTP_URL_RE: LazyLock<Regex> =
42 LazyLock::new(|| Regex::new(r#"(?i)https?://[^\s<>"']+"#).expect("valid URL regex"));
43
44fn is_known_mpp_endpoint(url: &Url) -> bool {
46 url.host_str().is_some_and(|host| KNOWN_MPP_HOSTS.iter().any(|suffix| host.ends_with(suffix)))
47}
48
49#[derive(Clone, Debug)]
52pub enum InnerTransport {
53 Http(LazyMppHttpTransport),
59 Ws(PubSubFrontend),
61 Ipc(PubSubFrontend),
63}
64
65#[derive(Error, Debug)]
67pub enum RuntimeTransportError {
68 #[error("Internal transport error: {0} with {1}")]
70 TransportError(TransportError, String),
71
72 #[error("URL scheme is not supported: {0}")]
74 BadScheme(String),
75
76 #[error("Invalid HTTP header: {0}")]
78 BadHeader(String),
79
80 #[error("Invalid IPC file path: {0}")]
82 BadPath(String),
83
84 #[error(transparent)]
86 HttpConstructionError(#[from] reqwest::Error),
87
88 #[error("Invalid JWT: {0}")]
90 InvalidJwt(String),
91}
92
93#[derive(Clone, Debug)]
101pub struct RuntimeTransport {
102 inner: Arc<RwLock<Option<InnerTransport>>>,
104 url: Url,
106 headers: Vec<String>,
108 jwt: Option<String>,
110 timeout: std::time::Duration,
112 accept_invalid_certs: bool,
114 no_proxy: bool,
116}
117
118#[derive(Debug)]
120pub struct RuntimeTransportBuilder {
121 url: Url,
122 headers: Vec<String>,
123 jwt: Option<String>,
124 timeout: std::time::Duration,
125 accept_invalid_certs: bool,
126 no_proxy: bool,
127}
128
129impl RuntimeTransportBuilder {
130 pub const fn new(url: Url) -> Self {
132 Self {
133 url,
134 headers: vec![],
135 jwt: None,
136 timeout: REQUEST_TIMEOUT,
137 accept_invalid_certs: false,
138 no_proxy: false,
139 }
140 }
141
142 pub fn with_headers(mut self, headers: Vec<String>) -> Self {
144 self.headers = headers;
145 self
146 }
147
148 pub fn with_jwt(mut self, jwt: Option<String>) -> Self {
150 self.jwt = jwt;
151 self
152 }
153
154 pub const fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
156 self.timeout = timeout;
157 self
158 }
159
160 pub const fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self {
162 self.accept_invalid_certs = accept_invalid_certs;
163 self
164 }
165
166 pub const fn no_proxy(mut self, no_proxy: bool) -> Self {
171 self.no_proxy = no_proxy;
172 self
173 }
174
175 pub fn build(self) -> RuntimeTransport {
178 RuntimeTransport {
179 inner: Arc::new(RwLock::new(None)),
180 url: self.url,
181 headers: self.headers,
182 jwt: self.jwt,
183 timeout: self.timeout,
184 accept_invalid_certs: self.accept_invalid_certs,
185 no_proxy: self.no_proxy,
186 }
187 }
188}
189
190impl fmt::Display for RuntimeTransport {
191 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192 write!(f, "RuntimeTransport {}", redact_url(self.url.as_str()))
193 }
194}
195
196impl RuntimeTransport {
197 pub async fn connect(&self) -> Result<InnerTransport, RuntimeTransportError> {
199 match self.url.scheme() {
200 "http" | "https" => self.connect_http(),
201 "ws" | "wss" => self.connect_ws().await,
202 "file" => self.connect_ipc().await,
203 _ => Err(RuntimeTransportError::BadScheme(self.url.scheme().to_string())),
204 }
205 }
206
207 fn reqwest_headers(&self) -> Result<reqwest::header::HeaderMap, RuntimeTransportError> {
208 let mut headers = reqwest::header::HeaderMap::new();
209
210 if let Some(jwt) = self.jwt.clone() {
212 let auth =
213 build_auth(jwt).map_err(|e| RuntimeTransportError::InvalidJwt(e.to_string()))?;
214
215 let mut auth_value: HeaderValue =
216 HeaderValue::from_str(&auth.to_string()).expect("Header should be valid string");
217 auth_value.set_sensitive(true);
218
219 headers.insert(reqwest::header::AUTHORIZATION, auth_value);
220 };
221
222 for header in &self.headers {
224 let make_err = || RuntimeTransportError::BadHeader(header.clone());
225
226 let (key, val) = header.split_once(':').ok_or_else(make_err)?;
227
228 headers.insert(
229 HeaderName::from_str(key.trim()).map_err(|_| make_err())?,
230 HeaderValue::from_str(val.trim()).map_err(|_| make_err())?,
231 );
232 }
233
234 if !headers.contains_key(reqwest::header::USER_AGENT) {
235 headers.insert(
236 reqwest::header::USER_AGENT,
237 HeaderValue::from_str(DEFAULT_USER_AGENT)
238 .expect("User-Agent should be valid string"),
239 );
240 }
241
242 if !headers.contains_key(HeaderName::from_static("x-api-key"))
245 && let Ok(api_key) = std::env::var("MPP_API_KEY")
246 {
247 let api_key = api_key.trim();
248 if !api_key.is_empty() {
249 let mut value = HeaderValue::from_str(api_key)
250 .map_err(|_| RuntimeTransportError::BadHeader("MPP_API_KEY".to_string()))?;
251 value.set_sensitive(true);
252 headers.insert(HeaderName::from_static("x-api-key"), value);
253 }
254 }
255
256 Ok(headers)
257 }
258
259 fn reqwest_client_with_headers(
260 &self,
261 headers: reqwest::header::HeaderMap,
262 ) -> Result<reqwest::Client, RuntimeTransportError> {
263 let mut client_builder = reqwest::Client::builder()
264 .timeout(self.timeout)
265 .danger_accept_invalid_certs(self.accept_invalid_certs);
266
267 if self.no_proxy || guess_local_url(self.url.as_str()) {
271 client_builder = client_builder.no_proxy();
272 }
273
274 client_builder = client_builder.default_headers(headers);
275
276 Ok(client_builder.build()?)
277 }
278
279 pub fn reqwest_client(&self) -> Result<reqwest::Client, RuntimeTransportError> {
281 self.reqwest_client_with_headers(self.reqwest_headers()?)
282 }
283
284 fn connect_http(&self) -> Result<InnerTransport, RuntimeTransportError> {
286 let headers = self.reqwest_headers()?;
287 let client = self.reqwest_client_with_headers(headers.clone())?;
288 Ok(InnerTransport::Http(LazyMppHttpTransport::lazy(client, self.url.clone(), headers)))
289 }
290
291 async fn connect_ws(&self) -> Result<InnerTransport, RuntimeTransportError> {
297 let auth = self.jwt.as_ref().and_then(|jwt| build_auth(jwt.clone()).ok());
298
299 let service = if is_known_mpp_endpoint(&self.url) {
300 let mut ws = lazy_mpp_ws_connect(&self.url);
301 if let Some(auth) = auth {
302 ws = ws.with_auth(auth);
303 }
304 ws.into_service().await.map_err(|e| {
305 RuntimeTransportError::TransportError(e, redact_url(self.url.as_str()))
306 })?
307 } else {
308 let mut ws = WsConnect::new(self.url.to_string());
309 if let Some(auth) = auth {
310 ws = ws.with_auth(auth);
311 }
312 ws.into_service().await.map_err(|e| {
313 RuntimeTransportError::TransportError(e, redact_url(self.url.as_str()))
314 })?
315 };
316
317 Ok(InnerTransport::Ws(service))
318 }
319
320 async fn connect_ipc(&self) -> Result<InnerTransport, RuntimeTransportError> {
322 let path = url_to_file_path(&self.url)
323 .map_err(|_| RuntimeTransportError::BadPath(self.url.to_string()))?;
324 let ipc_connector = IpcConnect::new(path.clone());
325 let ipc = ipc_connector.into_service().await.map_err(|e| {
326 RuntimeTransportError::TransportError(e, path.clone().display().to_string())
327 })?;
328 Ok(InnerTransport::Ipc(ipc))
329 }
330
331 pub fn request(&self, req: RequestPacket) -> TransportFut<'static> {
339 let this = self.clone();
340 Box::pin(async move {
341 let mut inner = this.inner.read().await;
342 if inner.is_none() {
343 drop(inner);
344 {
345 let mut inner_mut = this.inner.write().await;
346 if inner_mut.is_none() {
347 *inner_mut =
348 Some(this.connect().await.map_err(TransportErrorKind::custom)?);
349 }
350 }
351 inner = this.inner.read().await;
352 }
353
354 match inner.clone().expect("must've been initialized") {
356 InnerTransport::Http(mut http) => http
357 .call(req)
358 .await
359 .map_err(|error| redact_http_transport_error(error, &this.url)),
360 InnerTransport::Ws(mut ws) => ws.call(req).await,
361 InnerTransport::Ipc(mut ipc) => ipc.call(req).await,
362 }
363 })
364 }
365
366 pub fn boxed(self) -> BoxTransport
368 where
369 Self: Sized + Clone + Send + Sync + 'static,
370 {
371 BoxTransport::new(self)
372 }
373}
374
375fn redact_http_transport_error(error: TransportError, endpoint: &Url) -> TransportError {
376 let alloy_json_rpc::RpcError::Transport(TransportErrorKind::Custom(source)) = &error else {
377 return error;
378 };
379 let safe_endpoint = redact_url(endpoint.as_str());
380
381 let mut message = String::new();
382 let mut error: Option<&(dyn StdError + 'static)> = Some(source.as_ref());
383 while let Some(source) = error {
384 if !message.is_empty() {
385 message.push_str(": ");
386 }
387 message.push_str(&source.to_string());
388 error = source.source();
389 }
390 let message = HTTP_URL_RE.replace_all(&message, |captures: &Captures<'_>| {
391 let candidate = &captures[0];
392 let Ok(url) = Url::parse(candidate) else { return candidate.to_owned() };
393 if url.host() == endpoint.host()
394 && url.port_or_known_default() == endpoint.port_or_known_default()
395 {
396 safe_endpoint.clone()
397 } else {
398 candidate.to_owned()
399 }
400 });
401 TransportErrorKind::custom_str(&message)
402}
403
404impl tower::Service<RequestPacket> for RuntimeTransport {
405 type Response = ResponsePacket;
406 type Error = TransportError;
407 type Future = TransportFut<'static>;
408
409 #[inline]
410 fn poll_ready(
411 &mut self,
412 _cx: &mut std::task::Context<'_>,
413 ) -> std::task::Poll<Result<(), Self::Error>> {
414 std::task::Poll::Ready(Ok(()))
415 }
416
417 #[inline]
418 fn call(&mut self, req: RequestPacket) -> Self::Future {
419 self.request(req)
420 }
421}
422
423impl tower::Service<RequestPacket> for &RuntimeTransport {
424 type Response = ResponsePacket;
425 type Error = TransportError;
426 type Future = TransportFut<'static>;
427
428 #[inline]
429 fn poll_ready(
430 &mut self,
431 _cx: &mut std::task::Context<'_>,
432 ) -> std::task::Poll<Result<(), Self::Error>> {
433 std::task::Poll::Ready(Ok(()))
434 }
435
436 #[inline]
437 fn call(&mut self, req: RequestPacket) -> Self::Future {
438 self.request(req)
439 }
440}
441
442fn build_auth(jwt: String) -> eyre::Result<Authorization> {
443 let secret = JwtSecret::from_hex(jwt)?;
445 let claims = Claims::default();
446 let token = secret.encode(&claims)?;
447
448 let auth = Authorization::Bearer(token);
449
450 Ok(auth)
451}
452
453#[cfg(windows)]
454fn url_to_file_path(url: &Url) -> Result<PathBuf, ()> {
455 const PREFIX: &str = "file:///pipe/";
456
457 let url_str = url.as_str();
458
459 if let Some(pipe_name) = url_str.strip_prefix(PREFIX) {
460 let pipe_path = format!(r"\\.\pipe\{pipe_name}");
461 return Ok(PathBuf::from(pipe_path));
462 }
463
464 url.to_file_path()
465}
466
467#[cfg(not(windows))]
468fn url_to_file_path(url: &Url) -> Result<PathBuf, ()> {
469 url.to_file_path()
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use reqwest::header::HeaderMap;
476 use std::io;
477
478 #[derive(Debug, Error)]
479 #[error("request to https://example.com/private-api-key failed")]
480 struct ProviderError {
481 #[source]
482 source: io::Error,
483 }
484
485 #[test]
486 fn http_transport_errors_preserve_provider_guidance() {
487 let endpoint =
488 Url::parse("https://user:password@example.com/private-api-key?token=secret").unwrap();
489 let error = TransportErrorKind::custom(ProviderError {
490 source: io::Error::other(
491 "Authorize an access key with:\n cast tempo login --no-browser",
492 ),
493 });
494
495 let report = redact_http_transport_error(error, &endpoint).to_string();
496
497 assert!(report.contains("https://example.com/"));
498 assert!(report.contains("cast tempo login --no-browser"));
499 assert!(!report.contains("password"));
500 assert!(!report.contains("private-api-key"));
501 assert!(!report.contains("secret"));
502 }
503
504 #[test]
505 fn http_transport_errors_redact_endpoint_paths() {
506 let endpoint =
507 Url::parse("https://user:password@example.com/private-api-key?token=secret").unwrap();
508 let error = TransportErrorKind::custom_str(concat!(
509 "request to https://example.com/private-api-key failed: connection refused\n\n",
510 "Authorize an access key with:\n cast tempo login"
511 ));
512
513 let error = redact_http_transport_error(error, &endpoint);
514 let report = error.to_string();
515
516 assert!(report.contains("https://example.com/"));
517 assert!(!report.contains("password"));
518 assert!(!report.contains("private-api-key"));
519 assert!(!report.contains("secret"));
520 assert!(report.to_lowercase().contains("connection refused"));
521 assert!(report.contains("cast tempo login"));
522 }
523
524 #[test]
525 fn http_transport_errors_redact_normalized_endpoint_variants() {
526 let endpoint =
527 Url::parse("https://user:password@example.com/private-api-key?token=secret").unwrap();
528 let error = TransportErrorKind::custom_str(
529 "request to https://USER:normalized@example.com:443/different%2Fpath?key=other failed",
530 );
531
532 let report = redact_http_transport_error(error, &endpoint).to_string();
533
534 assert!(report.contains("https://example.com/"));
535 assert!(!report.contains("normalized"));
536 assert!(!report.contains("different"));
537 assert!(!report.contains("other"));
538 }
539
540 #[tokio::test]
541 async fn websocket_error_redacts_url_credentials() {
542 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
543 let address = listener.local_addr().unwrap();
544 drop(listener);
545 let url = Url::parse(&format!(
546 "ws://user:password@{address}/private-api-key?token=secret#fragment"
547 ))
548 .unwrap();
549 let transport = RuntimeTransportBuilder::new(url).build();
550
551 let error = transport.connect_ws().await.unwrap_err().to_string();
552
553 assert!(error.contains(&format!("ws://{address}/")));
554 assert!(!error.contains("user"));
555 assert!(!error.contains("password"));
556 assert!(!error.contains("private-api-key"));
557 assert!(!error.contains("secret"));
558 }
559
560 #[tokio::test]
561 async fn test_user_agent_header() {
562 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
563 let url = Url::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap();
564
565 let http_handler = axum::routing::get(|actual_headers: HeaderMap| {
566 let user_agent = HeaderName::from_str("User-Agent").unwrap();
567 assert_eq!(actual_headers[user_agent], HeaderValue::from_str("test-agent").unwrap());
568
569 async { "" }
570 });
571
572 let server_task = tokio::spawn(async move {
573 axum::serve(listener, http_handler.into_make_service()).await.unwrap()
574 });
575
576 let transport = RuntimeTransportBuilder::new(url.clone())
577 .with_headers(vec!["User-Agent: test-agent".to_string()])
578 .build();
579 let inner = transport.connect_http().unwrap();
580
581 match inner {
582 InnerTransport::Http(http) => {
583 let _ = http.client().get(url).send().await.unwrap();
584
585 }
587 _ => unreachable!(),
588 }
589
590 server_task.abort();
591 }
592}