1use std::fmt;
11use std::future::Future;
12use std::pin::Pin;
13use std::sync::Arc;
14use std::time::Duration;
15
16use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity};
17
18use crate::retry::RetryConfig;
19use crate::{Error, Result};
20
21pub trait TokenSource: Send + Sync + fmt::Debug {
27 fn fetch_bearer(&self) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + '_>>;
30}
31
32#[derive(Clone)]
38#[non_exhaustive]
39pub enum Auth {
40 None,
42 Static(String),
44 Dynamic(Arc<dyn TokenSource>),
46}
47
48impl Auth {
49 pub async fn bearer(&self) -> Result<Option<String>> {
54 match self {
55 Auth::None => Ok(None),
56 Auth::Static(token) => Ok(Some(token.clone())),
57 Auth::Dynamic(source) => source.fetch_bearer().await,
58 }
59 }
60}
61
62impl fmt::Debug for Auth {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 match self {
65 Auth::None => f.write_str("None"),
66 Auth::Static(_) => f.write_str("Static(<redacted>)"),
67 Auth::Dynamic(source) => write!(f, "Dynamic({source:?})"),
68 }
69 }
70}
71
72#[derive(Clone, Default)]
79#[non_exhaustive]
80pub struct TlsConfig {
81 pub ca_certificate_pem: Option<Vec<u8>>,
84 pub domain_name: Option<String>,
87 pub client_identity_pem: Option<(Vec<u8>, Vec<u8>)>,
89}
90
91impl std::fmt::Debug for TlsConfig {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 fn pem(bytes: Option<&Vec<u8>>) -> String {
99 bytes.map_or_else(|| "None".to_string(), |b| format!("<{} bytes>", b.len()))
100 }
101 f.debug_struct("TlsConfig")
102 .field("ca_certificate_pem", &pem(self.ca_certificate_pem.as_ref()))
103 .field("domain_name", &self.domain_name)
104 .field(
105 "client_identity_pem",
106 &self.client_identity_pem.as_ref().map_or_else(
107 || "None".to_string(),
108 |(cert, key)| {
109 format!(
110 "Some((<{} bytes>, <{} bytes, redacted>))",
111 cert.len(),
112 key.len()
113 )
114 },
115 ),
116 )
117 .finish()
118 }
119}
120
121impl TlsConfig {
122 #[must_use]
124 pub fn new() -> Self {
125 Self::default()
126 }
127
128 #[must_use]
130 pub fn with_ca_certificate(mut self, ca_pem: impl Into<Vec<u8>>) -> Self {
131 self.ca_certificate_pem = Some(ca_pem.into());
132 self
133 }
134
135 #[must_use]
137 pub fn with_domain_name(mut self, domain: impl Into<String>) -> Self {
138 self.domain_name = Some(domain.into());
139 self
140 }
141
142 #[must_use]
144 pub fn with_client_identity(
145 mut self,
146 certificate_pem: impl Into<Vec<u8>>,
147 private_key_pem: impl Into<Vec<u8>>,
148 ) -> Self {
149 self.client_identity_pem = Some((certificate_pem.into(), private_key_pem.into()));
150 self
151 }
152}
153
154fn build_tls(tls: Option<&TlsConfig>) -> ClientTlsConfig {
157 let mut config = ClientTlsConfig::new();
158 match tls.and_then(|t| t.ca_certificate_pem.as_ref()) {
159 Some(ca) => config = config.ca_certificate(Certificate::from_pem(ca.clone())),
160 None => config = config.with_native_roots(),
161 }
162 if let Some(domain) = tls.and_then(|t| t.domain_name.as_ref()) {
163 config = config.domain_name(domain.clone());
164 }
165 if let Some((cert, key)) = tls.and_then(|t| t.client_identity_pem.as_ref()) {
166 config = config.identity(Identity::from_pem(cert.clone(), key.clone()));
167 }
168 config
169}
170
171#[must_use]
179pub fn redact_url(url: &str) -> std::borrow::Cow<'_, str> {
180 let Some(scheme_end) = url.find("://") else {
181 return std::borrow::Cow::Borrowed(url);
182 };
183 let authority_start = scheme_end + 3;
184 let authority_end = url[authority_start..]
185 .find(['/', '?', '#'])
186 .map_or(url.len(), |i| authority_start + i);
187 let authority = &url[authority_start..authority_end];
188 let Some(at) = authority.rfind('@') else {
189 return std::borrow::Cow::Borrowed(url);
190 };
191 std::borrow::Cow::Owned(format!(
192 "{}***{}",
193 &url[..authority_start],
194 &url[authority_start + at..]
195 ))
196}
197
198#[derive(Clone)]
201pub struct Config {
202 endpoint: String,
203 auth: Auth,
204 retry: Option<RetryConfig>,
205 tls: Option<TlsConfig>,
206 timeout: Option<Duration>,
207 max_decoding_message_size: usize,
208}
209
210pub const DEFAULT_MAX_DECODING_MESSAGE_SIZE: usize = 128 * 1024 * 1024;
224
225impl std::fmt::Debug for Config {
230 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231 f.debug_struct("Config")
232 .field("endpoint", &redact_url(&self.endpoint))
233 .field("auth", &self.auth)
234 .field("retry", &self.retry)
235 .field("tls", &self.tls)
236 .field("timeout", &self.timeout)
237 .field("max_decoding_message_size", &self.max_decoding_message_size)
238 .finish()
239 }
240}
241
242impl Config {
243 pub fn new(endpoint: impl Into<String>) -> Self {
246 Self {
247 endpoint: endpoint.into(),
248 auth: Auth::None,
249 retry: None,
250 tls: None,
251 timeout: None,
252 max_decoding_message_size: DEFAULT_MAX_DECODING_MESSAGE_SIZE,
253 }
254 }
255
256 #[must_use]
258 pub fn with_token(mut self, token: impl Into<String>) -> Self {
259 self.auth = Auth::Static(token.into());
260 self
261 }
262
263 #[must_use]
266 pub fn with_oidc<T: TokenSource + 'static>(mut self, provider: T) -> Self {
267 self.auth = Auth::Dynamic(Arc::new(provider));
268 self
269 }
270
271 #[must_use]
273 pub fn with_retry(mut self, retry: RetryConfig) -> Self {
274 self.retry = Some(retry);
275 self
276 }
277
278 #[must_use]
280 pub fn with_tls(mut self, tls: TlsConfig) -> Self {
281 self.tls = Some(tls);
282 self
283 }
284
285 #[must_use]
288 pub fn with_timeout(mut self, timeout: Duration) -> Self {
289 self.timeout = Some(timeout);
290 self
291 }
292
293 #[must_use]
299 pub fn with_max_decoding_message_size(mut self, bytes: usize) -> Self {
300 self.max_decoding_message_size = bytes;
301 self
302 }
303
304 #[must_use]
306 pub fn max_decoding_message_size(&self) -> usize {
307 self.max_decoding_message_size
308 }
309
310 #[must_use]
312 pub fn endpoint(&self) -> &str {
313 &self.endpoint
314 }
315
316 #[must_use]
318 pub fn auth(&self) -> &Auth {
319 &self.auth
320 }
321
322 #[must_use]
324 pub fn retry(&self) -> Option<&RetryConfig> {
325 self.retry.as_ref()
326 }
327
328 pub fn connect_channel(&self) -> Result<Channel> {
339 let (uri, want_tls) = resolve_endpoint(&self.endpoint, self.tls.is_some());
340 let mut endpoint = Endpoint::from_shared(uri.clone())
341 .map_err(|e| {
342 Error::InvalidRequest(format!("invalid endpoint uri {}: {e}", redact_url(&uri)))
343 })?
344 .timeout(self.timeout.unwrap_or(Duration::from_secs(30)))
345 .connect_timeout(Duration::from_secs(10))
346 .http2_keep_alive_interval(Duration::from_secs(30))
347 .keep_alive_timeout(Duration::from_secs(20))
348 .keep_alive_while_idle(true)
349 .tcp_keepalive(Some(Duration::from_secs(60)))
350 .tcp_nodelay(true);
351
352 if want_tls {
353 endpoint = endpoint
354 .tls_config(build_tls(self.tls.as_ref()))
355 .map_err(|e| Error::InvalidRequest(format!("invalid TLS config: {e}")))?;
356 }
357
358 Ok(endpoint.connect_lazy())
359 }
360}
361
362fn resolve_endpoint(endpoint: &str, tls_configured: bool) -> (String, bool) {
372 if !endpoint.contains("://") {
378 let scheme = if tls_configured { "https" } else { "http" };
379 return (format!("{scheme}://{endpoint}"), tls_configured);
380 }
381
382 let is_https = endpoint
383 .get(..8)
384 .is_some_and(|s| s.eq_ignore_ascii_case("https://"));
385 let is_http = endpoint
386 .get(..7)
387 .is_some_and(|s| s.eq_ignore_ascii_case("http://"));
388 let want_tls = tls_configured || is_https;
389 if want_tls && is_http {
390 (format!("https://{}", &endpoint[7..]), true)
391 } else {
392 (endpoint.to_string(), want_tls)
393 }
394}
395
396#[cfg(test)]
397mod decode_limit_tests {
398 use super::*;
399
400 #[test]
401 fn the_default_is_far_above_what_a_real_page_needs() {
402 assert_eq!(DEFAULT_MAX_DECODING_MESSAGE_SIZE, 128 * 1024 * 1024);
408 const { assert!(DEFAULT_MAX_DECODING_MESSAGE_SIZE > 4 * 1024 * 1024) };
409 assert_eq!(
410 Config::new("http://localhost:3901").max_decoding_message_size(),
411 DEFAULT_MAX_DECODING_MESSAGE_SIZE
412 );
413 }
414
415 #[test]
416 fn the_limit_is_configurable_in_both_directions() {
417 let big = Config::new("http://x").with_max_decoding_message_size(512 * 1024 * 1024);
419 assert_eq!(big.max_decoding_message_size(), 512 * 1024 * 1024);
420 let small = Config::new("http://x").with_max_decoding_message_size(1024);
422 assert_eq!(small.max_decoding_message_size(), 1024);
423 }
424}
425
426#[cfg(test)]
427#[allow(clippy::unwrap_used, clippy::expect_used)]
428mod redaction_tests {
429 use super::*;
430
431 const KEY: &[u8] = b"-----BEGIN PRIVATE KEY-----SUPERSECRET-----END PRIVATE KEY-----";
432
433 #[test]
437 fn debug_never_prints_key_material() {
438 let tls = TlsConfig::new().with_client_identity(b"certbytes".to_vec(), KEY.to_vec());
439 let rendered = format!("{tls:?}");
440
441 assert!(
443 !rendered.contains("83, 85, 80"),
444 "key bytes leaked: {rendered}"
445 );
446 assert!(!rendered.contains("PRIVATE KEY"), "{rendered}");
447 assert!(rendered.contains("redacted"), "{rendered}");
449 assert!(
450 rendered.contains(&format!("{} bytes", KEY.len())),
451 "{rendered}"
452 );
453
454 let cfg = Config::new("https://host:3901").with_tls(tls);
456 let rendered = format!("{cfg:?}");
457 assert!(
458 !rendered.contains("83, 85, 80"),
459 "leaked via Config: {rendered}"
460 );
461 }
462
463 #[test]
464 fn debug_redacts_credentials_in_the_endpoint() {
465 let cfg = Config::new("https://alice:s3cr3t@localhost:3901");
466 let rendered = format!("{cfg:?}");
467 assert!(!rendered.contains("s3cr3t"), "{rendered}");
468 assert!(!rendered.contains("alice"), "{rendered}");
469 assert!(
470 rendered.contains("localhost:3901"),
471 "host must survive: {rendered}"
472 );
473 }
474
475 #[test]
476 fn redact_url_keeps_everything_that_is_not_a_credential() {
477 assert_eq!(redact_url("https://u:p@h:1/x?q=1"), "https://***@h:1/x?q=1");
479 assert_eq!(redact_url("ws://tok@h/v2/updates"), "ws://***@h/v2/updates");
480 assert_eq!(redact_url("https://h/a@b"), "https://h/a@b");
482 for url in [
485 "https://localhost:3901",
486 "http://kc:8082/realms/AppProvider/protocol/openid-connect/token",
487 "not a url at all",
488 "://",
489 "",
490 ] {
491 assert_eq!(redact_url(url), url, "should be untouched: {url}");
492 }
493 }
494}
495
496#[cfg(test)]
497#[allow(clippy::bool_assert_comparison)]
498mod tests {
499 use super::resolve_endpoint;
500
501 #[test]
502 fn with_tls_on_http_endpoint_is_upgraded_to_https() {
503 let (uri, tls) = resolve_endpoint("http://host:5001", true);
506 assert_eq!(uri, "https://host:5001");
507 assert_eq!(tls, true);
508 }
509
510 #[test]
511 fn https_scheme_detection_is_case_insensitive() {
512 let (uri, tls) = resolve_endpoint("HTTPS://host:443", false);
515 assert_eq!(uri, "HTTPS://host:443");
516 assert_eq!(tls, true);
517 }
518
519 #[test]
520 fn plain_http_without_tls_stays_plaintext() {
521 let (uri, tls) = resolve_endpoint("http://host:3901", false);
522 assert_eq!(uri, "http://host:3901");
523 assert_eq!(tls, false);
524 }
525
526 #[test]
527 fn https_without_explicit_tls_wants_tls() {
528 let (uri, tls) = resolve_endpoint("https://host:443", false);
529 assert_eq!(uri, "https://host:443");
530 assert_eq!(tls, true);
531 }
532
533 #[test]
534 fn uppercase_http_with_tls_is_upgraded() {
535 let (uri, tls) = resolve_endpoint("HTTP://host:5001", true);
536 assert_eq!(uri, "https://host:5001");
537 assert_eq!(tls, true);
538 }
539
540 #[test]
546 fn a_scheme_less_host_and_port_gets_the_scheme_it_implies() {
547 let (uri, tls) =
548 resolve_endpoint("grpc-ledger-api.app-provider.demo.localhost:3901", false);
549 assert_eq!(
550 uri,
551 "http://grpc-ledger-api.app-provider.demo.localhost:3901"
552 );
553 assert_eq!(tls, false);
554
555 let (uri, tls) = resolve_endpoint("ledger.example:443", true);
558 assert_eq!(uri, "https://ledger.example:443");
559 assert_eq!(tls, true);
560
561 let (uri, _) = resolve_endpoint("[::1]:3901", false);
563 assert_eq!(uri, "http://[::1]:3901");
564 }
565}