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
225pub const MAX_LIST_PAGES: usize = 10_000;
231
232impl std::fmt::Debug for Config {
237 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238 f.debug_struct("Config")
239 .field("endpoint", &redact_url(&self.endpoint))
240 .field("auth", &self.auth)
241 .field("retry", &self.retry)
242 .field("tls", &self.tls)
243 .field("timeout", &self.timeout)
244 .field("max_decoding_message_size", &self.max_decoding_message_size)
245 .finish()
246 }
247}
248
249impl Config {
250 #[must_use]
253 pub fn new(endpoint: impl Into<String>) -> Self {
254 Self {
255 endpoint: endpoint.into(),
256 auth: Auth::None,
257 retry: None,
258 tls: None,
259 timeout: None,
260 max_decoding_message_size: DEFAULT_MAX_DECODING_MESSAGE_SIZE,
261 }
262 }
263
264 #[must_use]
266 pub fn with_token(mut self, token: impl Into<String>) -> Self {
267 self.auth = Auth::Static(token.into());
268 self
269 }
270
271 #[must_use]
274 pub fn with_oidc<T: TokenSource + 'static>(mut self, provider: T) -> Self {
275 self.auth = Auth::Dynamic(Arc::new(provider));
276 self
277 }
278
279 #[must_use]
281 pub fn with_retry(mut self, retry: RetryConfig) -> Self {
282 self.retry = Some(retry);
283 self
284 }
285
286 #[must_use]
288 pub fn with_tls(mut self, tls: TlsConfig) -> Self {
289 self.tls = Some(tls);
290 self
291 }
292
293 #[must_use]
296 pub fn with_timeout(mut self, timeout: Duration) -> Self {
297 self.timeout = Some(timeout);
298 self
299 }
300
301 #[must_use]
307 pub fn with_max_decoding_message_size(mut self, bytes: usize) -> Self {
308 self.max_decoding_message_size = bytes;
309 self
310 }
311
312 #[must_use]
314 pub fn max_decoding_message_size(&self) -> usize {
315 self.max_decoding_message_size
316 }
317
318 #[must_use]
320 pub fn endpoint(&self) -> &str {
321 &self.endpoint
322 }
323
324 #[must_use]
326 pub fn auth(&self) -> &Auth {
327 &self.auth
328 }
329
330 #[must_use]
332 pub fn retry(&self) -> Option<&RetryConfig> {
333 self.retry.as_ref()
334 }
335
336 pub fn connect_channel(&self) -> Result<Channel> {
347 let (uri, want_tls) = resolve_endpoint(&self.endpoint, self.tls.is_some());
348 let mut endpoint = Endpoint::from_shared(uri.clone())
349 .map_err(|e| {
350 Error::InvalidRequest(format!("invalid endpoint uri {}: {e}", redact_url(&uri)))
351 })?
352 .timeout(self.timeout.unwrap_or(Duration::from_secs(30)))
353 .connect_timeout(Duration::from_secs(10))
354 .http2_keep_alive_interval(Duration::from_secs(30))
355 .keep_alive_timeout(Duration::from_secs(20))
356 .keep_alive_while_idle(true)
357 .tcp_keepalive(Some(Duration::from_secs(60)))
358 .tcp_nodelay(true);
359
360 if want_tls {
361 endpoint = endpoint
362 .tls_config(build_tls(self.tls.as_ref()))
363 .map_err(|e| {
364 Error::InvalidRequest(format!("invalid TLS config: {}", crate::chain(&e)))
365 })?;
366 }
367
368 Ok(endpoint.connect_lazy())
369 }
370}
371
372fn resolve_endpoint(endpoint: &str, tls_configured: bool) -> (String, bool) {
382 if !endpoint.contains("://") {
388 let scheme = if tls_configured { "https" } else { "http" };
389 return (format!("{scheme}://{endpoint}"), tls_configured);
390 }
391
392 let is_https = endpoint
393 .get(..8)
394 .is_some_and(|s| s.eq_ignore_ascii_case("https://"));
395 let is_http = endpoint
396 .get(..7)
397 .is_some_and(|s| s.eq_ignore_ascii_case("http://"));
398 let want_tls = tls_configured || is_https;
399 if want_tls && is_http {
400 (format!("https://{}", &endpoint[7..]), true)
401 } else {
402 (endpoint.to_string(), want_tls)
403 }
404}
405
406#[cfg(test)]
407mod decode_limit_tests {
408 use super::*;
409
410 #[test]
411 fn the_default_is_far_above_what_a_real_page_needs() {
412 assert_eq!(DEFAULT_MAX_DECODING_MESSAGE_SIZE, 128 * 1024 * 1024);
418 const { assert!(DEFAULT_MAX_DECODING_MESSAGE_SIZE > 4 * 1024 * 1024) };
419 assert_eq!(
420 Config::new("http://localhost:3901").max_decoding_message_size(),
421 DEFAULT_MAX_DECODING_MESSAGE_SIZE
422 );
423 }
424
425 #[test]
426 fn the_limit_is_configurable_in_both_directions() {
427 let big = Config::new("http://x").with_max_decoding_message_size(512 * 1024 * 1024);
429 assert_eq!(big.max_decoding_message_size(), 512 * 1024 * 1024);
430 let small = Config::new("http://x").with_max_decoding_message_size(1024);
432 assert_eq!(small.max_decoding_message_size(), 1024);
433 }
434}
435
436#[cfg(test)]
437#[allow(clippy::unwrap_used, clippy::expect_used)]
438mod redaction_tests {
439 use super::*;
440
441 const KEY: &[u8] = b"-----BEGIN PRIVATE KEY-----SUPERSECRET-----END PRIVATE KEY-----";
442
443 #[test]
447 fn debug_never_prints_key_material() {
448 let tls = TlsConfig::new().with_client_identity(b"certbytes".to_vec(), KEY.to_vec());
449 let rendered = format!("{tls:?}");
450
451 assert!(
453 !rendered.contains("83, 85, 80"),
454 "key bytes leaked: {rendered}"
455 );
456 assert!(!rendered.contains("PRIVATE KEY"), "{rendered}");
457 assert!(rendered.contains("redacted"), "{rendered}");
459 assert!(
460 rendered.contains(&format!("{} bytes", KEY.len())),
461 "{rendered}"
462 );
463
464 let cfg = Config::new("https://host:3901").with_tls(tls);
466 let rendered = format!("{cfg:?}");
467 assert!(
468 !rendered.contains("83, 85, 80"),
469 "leaked via Config: {rendered}"
470 );
471 }
472
473 #[test]
474 fn debug_redacts_credentials_in_the_endpoint() {
475 let cfg = Config::new("https://alice:s3cr3t@localhost:3901");
476 let rendered = format!("{cfg:?}");
477 assert!(!rendered.contains("s3cr3t"), "{rendered}");
478 assert!(!rendered.contains("alice"), "{rendered}");
479 assert!(
480 rendered.contains("localhost:3901"),
481 "host must survive: {rendered}"
482 );
483 }
484
485 #[test]
486 fn redact_url_keeps_everything_that_is_not_a_credential() {
487 assert_eq!(redact_url("https://u:p@h:1/x?q=1"), "https://***@h:1/x?q=1");
489 assert_eq!(redact_url("ws://tok@h/v2/updates"), "ws://***@h/v2/updates");
490 assert_eq!(redact_url("https://h/a@b"), "https://h/a@b");
492 for url in [
495 "https://localhost:3901",
496 "http://kc:8082/realms/AppProvider/protocol/openid-connect/token",
497 "not a url at all",
498 "://",
499 "",
500 ] {
501 assert_eq!(redact_url(url), url, "should be untouched: {url}");
502 }
503 }
504}
505
506#[cfg(test)]
507#[allow(clippy::bool_assert_comparison)]
508mod tests {
509 use super::resolve_endpoint;
510
511 #[test]
512 fn with_tls_on_http_endpoint_is_upgraded_to_https() {
513 let (uri, tls) = resolve_endpoint("http://host:5001", true);
516 assert_eq!(uri, "https://host:5001");
517 assert_eq!(tls, true);
518 }
519
520 #[test]
521 fn https_scheme_detection_is_case_insensitive() {
522 let (uri, tls) = resolve_endpoint("HTTPS://host:443", false);
525 assert_eq!(uri, "HTTPS://host:443");
526 assert_eq!(tls, true);
527 }
528
529 #[test]
530 fn plain_http_without_tls_stays_plaintext() {
531 let (uri, tls) = resolve_endpoint("http://host:3901", false);
532 assert_eq!(uri, "http://host:3901");
533 assert_eq!(tls, false);
534 }
535
536 #[test]
537 fn https_without_explicit_tls_wants_tls() {
538 let (uri, tls) = resolve_endpoint("https://host:443", false);
539 assert_eq!(uri, "https://host:443");
540 assert_eq!(tls, true);
541 }
542
543 #[test]
544 fn uppercase_http_with_tls_is_upgraded() {
545 let (uri, tls) = resolve_endpoint("HTTP://host:5001", true);
546 assert_eq!(uri, "https://host:5001");
547 assert_eq!(tls, true);
548 }
549
550 #[test]
556 fn a_scheme_less_host_and_port_gets_the_scheme_it_implies() {
557 let (uri, tls) =
558 resolve_endpoint("grpc-ledger-api.app-provider.demo.localhost:3901", false);
559 assert_eq!(
560 uri,
561 "http://grpc-ledger-api.app-provider.demo.localhost:3901"
562 );
563 assert_eq!(tls, false);
564
565 let (uri, tls) = resolve_endpoint("ledger.example:443", true);
568 assert_eq!(uri, "https://ledger.example:443");
569 assert_eq!(tls, true);
570
571 let (uri, _) = resolve_endpoint("[::1]:3901", false);
573 assert_eq!(uri, "http://[::1]:3901");
574 }
575}