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 #[must_use]
246 pub fn new(endpoint: impl Into<String>) -> Self {
247 Self {
248 endpoint: endpoint.into(),
249 auth: Auth::None,
250 retry: None,
251 tls: None,
252 timeout: None,
253 max_decoding_message_size: DEFAULT_MAX_DECODING_MESSAGE_SIZE,
254 }
255 }
256
257 #[must_use]
259 pub fn with_token(mut self, token: impl Into<String>) -> Self {
260 self.auth = Auth::Static(token.into());
261 self
262 }
263
264 #[must_use]
267 pub fn with_oidc<T: TokenSource + 'static>(mut self, provider: T) -> Self {
268 self.auth = Auth::Dynamic(Arc::new(provider));
269 self
270 }
271
272 #[must_use]
274 pub fn with_retry(mut self, retry: RetryConfig) -> Self {
275 self.retry = Some(retry);
276 self
277 }
278
279 #[must_use]
281 pub fn with_tls(mut self, tls: TlsConfig) -> Self {
282 self.tls = Some(tls);
283 self
284 }
285
286 #[must_use]
289 pub fn with_timeout(mut self, timeout: Duration) -> Self {
290 self.timeout = Some(timeout);
291 self
292 }
293
294 #[must_use]
300 pub fn with_max_decoding_message_size(mut self, bytes: usize) -> Self {
301 self.max_decoding_message_size = bytes;
302 self
303 }
304
305 #[must_use]
307 pub fn max_decoding_message_size(&self) -> usize {
308 self.max_decoding_message_size
309 }
310
311 #[must_use]
313 pub fn endpoint(&self) -> &str {
314 &self.endpoint
315 }
316
317 #[must_use]
319 pub fn auth(&self) -> &Auth {
320 &self.auth
321 }
322
323 #[must_use]
325 pub fn retry(&self) -> Option<&RetryConfig> {
326 self.retry.as_ref()
327 }
328
329 pub fn connect_channel(&self) -> Result<Channel> {
340 let (uri, want_tls) = resolve_endpoint(&self.endpoint, self.tls.is_some());
341 let mut endpoint = Endpoint::from_shared(uri.clone())
342 .map_err(|e| {
343 Error::InvalidRequest(format!("invalid endpoint uri {}: {e}", redact_url(&uri)))
344 })?
345 .timeout(self.timeout.unwrap_or(Duration::from_secs(30)))
346 .connect_timeout(Duration::from_secs(10))
347 .http2_keep_alive_interval(Duration::from_secs(30))
348 .keep_alive_timeout(Duration::from_secs(20))
349 .keep_alive_while_idle(true)
350 .tcp_keepalive(Some(Duration::from_secs(60)))
351 .tcp_nodelay(true);
352
353 if want_tls {
354 endpoint = endpoint
355 .tls_config(build_tls(self.tls.as_ref()))
356 .map_err(|e| Error::InvalidRequest(format!("invalid TLS config: {e}")))?;
357 }
358
359 Ok(endpoint.connect_lazy())
360 }
361}
362
363fn resolve_endpoint(endpoint: &str, tls_configured: bool) -> (String, bool) {
373 if !endpoint.contains("://") {
379 let scheme = if tls_configured { "https" } else { "http" };
380 return (format!("{scheme}://{endpoint}"), tls_configured);
381 }
382
383 let is_https = endpoint
384 .get(..8)
385 .is_some_and(|s| s.eq_ignore_ascii_case("https://"));
386 let is_http = endpoint
387 .get(..7)
388 .is_some_and(|s| s.eq_ignore_ascii_case("http://"));
389 let want_tls = tls_configured || is_https;
390 if want_tls && is_http {
391 (format!("https://{}", &endpoint[7..]), true)
392 } else {
393 (endpoint.to_string(), want_tls)
394 }
395}
396
397#[cfg(test)]
398mod decode_limit_tests {
399 use super::*;
400
401 #[test]
402 fn the_default_is_far_above_what_a_real_page_needs() {
403 assert_eq!(DEFAULT_MAX_DECODING_MESSAGE_SIZE, 128 * 1024 * 1024);
409 const { assert!(DEFAULT_MAX_DECODING_MESSAGE_SIZE > 4 * 1024 * 1024) };
410 assert_eq!(
411 Config::new("http://localhost:3901").max_decoding_message_size(),
412 DEFAULT_MAX_DECODING_MESSAGE_SIZE
413 );
414 }
415
416 #[test]
417 fn the_limit_is_configurable_in_both_directions() {
418 let big = Config::new("http://x").with_max_decoding_message_size(512 * 1024 * 1024);
420 assert_eq!(big.max_decoding_message_size(), 512 * 1024 * 1024);
421 let small = Config::new("http://x").with_max_decoding_message_size(1024);
423 assert_eq!(small.max_decoding_message_size(), 1024);
424 }
425}
426
427#[cfg(test)]
428#[allow(clippy::unwrap_used, clippy::expect_used)]
429mod redaction_tests {
430 use super::*;
431
432 const KEY: &[u8] = b"-----BEGIN PRIVATE KEY-----SUPERSECRET-----END PRIVATE KEY-----";
433
434 #[test]
438 fn debug_never_prints_key_material() {
439 let tls = TlsConfig::new().with_client_identity(b"certbytes".to_vec(), KEY.to_vec());
440 let rendered = format!("{tls:?}");
441
442 assert!(
444 !rendered.contains("83, 85, 80"),
445 "key bytes leaked: {rendered}"
446 );
447 assert!(!rendered.contains("PRIVATE KEY"), "{rendered}");
448 assert!(rendered.contains("redacted"), "{rendered}");
450 assert!(
451 rendered.contains(&format!("{} bytes", KEY.len())),
452 "{rendered}"
453 );
454
455 let cfg = Config::new("https://host:3901").with_tls(tls);
457 let rendered = format!("{cfg:?}");
458 assert!(
459 !rendered.contains("83, 85, 80"),
460 "leaked via Config: {rendered}"
461 );
462 }
463
464 #[test]
465 fn debug_redacts_credentials_in_the_endpoint() {
466 let cfg = Config::new("https://alice:s3cr3t@localhost:3901");
467 let rendered = format!("{cfg:?}");
468 assert!(!rendered.contains("s3cr3t"), "{rendered}");
469 assert!(!rendered.contains("alice"), "{rendered}");
470 assert!(
471 rendered.contains("localhost:3901"),
472 "host must survive: {rendered}"
473 );
474 }
475
476 #[test]
477 fn redact_url_keeps_everything_that_is_not_a_credential() {
478 assert_eq!(redact_url("https://u:p@h:1/x?q=1"), "https://***@h:1/x?q=1");
480 assert_eq!(redact_url("ws://tok@h/v2/updates"), "ws://***@h/v2/updates");
481 assert_eq!(redact_url("https://h/a@b"), "https://h/a@b");
483 for url in [
486 "https://localhost:3901",
487 "http://kc:8082/realms/AppProvider/protocol/openid-connect/token",
488 "not a url at all",
489 "://",
490 "",
491 ] {
492 assert_eq!(redact_url(url), url, "should be untouched: {url}");
493 }
494 }
495}
496
497#[cfg(test)]
498#[allow(clippy::bool_assert_comparison)]
499mod tests {
500 use super::resolve_endpoint;
501
502 #[test]
503 fn with_tls_on_http_endpoint_is_upgraded_to_https() {
504 let (uri, tls) = resolve_endpoint("http://host:5001", true);
507 assert_eq!(uri, "https://host:5001");
508 assert_eq!(tls, true);
509 }
510
511 #[test]
512 fn https_scheme_detection_is_case_insensitive() {
513 let (uri, tls) = resolve_endpoint("HTTPS://host:443", false);
516 assert_eq!(uri, "HTTPS://host:443");
517 assert_eq!(tls, true);
518 }
519
520 #[test]
521 fn plain_http_without_tls_stays_plaintext() {
522 let (uri, tls) = resolve_endpoint("http://host:3901", false);
523 assert_eq!(uri, "http://host:3901");
524 assert_eq!(tls, false);
525 }
526
527 #[test]
528 fn https_without_explicit_tls_wants_tls() {
529 let (uri, tls) = resolve_endpoint("https://host:443", false);
530 assert_eq!(uri, "https://host:443");
531 assert_eq!(tls, true);
532 }
533
534 #[test]
535 fn uppercase_http_with_tls_is_upgraded() {
536 let (uri, tls) = resolve_endpoint("HTTP://host:5001", true);
537 assert_eq!(uri, "https://host:5001");
538 assert_eq!(tls, true);
539 }
540
541 #[test]
547 fn a_scheme_less_host_and_port_gets_the_scheme_it_implies() {
548 let (uri, tls) =
549 resolve_endpoint("grpc-ledger-api.app-provider.demo.localhost:3901", false);
550 assert_eq!(
551 uri,
552 "http://grpc-ledger-api.app-provider.demo.localhost:3901"
553 );
554 assert_eq!(tls, false);
555
556 let (uri, tls) = resolve_endpoint("ledger.example:443", true);
559 assert_eq!(uri, "https://ledger.example:443");
560 assert_eq!(tls, true);
561
562 let (uri, _) = resolve_endpoint("[::1]:3901", false);
564 assert_eq!(uri, "http://[::1]:3901");
565 }
566}