Skip to main content

microsandbox_network/tls/
state.rs

1//! Shared TLS state: CA, certificate cache, and upstream connectors.
2
3use std::collections::HashMap;
4use std::num::NonZeroUsize;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7
8use lru::LruCache;
9use microsandbox_utils::TLS_SUBDIR;
10use rustls::DigitallySignedStruct;
11use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
12use rustls_pki_types::{CertificateDer, ServerName, UnixTime, pem::PemObject};
13use time::{Duration, OffsetDateTime};
14use tokio_rustls::TlsConnector;
15
16use super::ca::CertAuthority;
17use super::certgen::{self, DomainCert, DomainCertError};
18use crate::secrets::handle::SecretsHandle;
19use microsandbox_types::TlsConfig;
20
21//--------------------------------------------------------------------------------------------------
22// Types
23//--------------------------------------------------------------------------------------------------
24
25/// Shared TLS interception state.
26///
27/// Holds the CA, per-domain certificate cache, upstream TLS connectors,
28/// and configuration. Shared across all TLS proxy tasks via `Arc`.
29pub struct TlsState {
30    /// Interception CA for signing per-domain certs presented to the guest.
31    pub intercept_ca: CertAuthority,
32    /// LRU cache of generated domain certificates.
33    cert_cache: Mutex<LruCache<String, Arc<DomainCert>>>,
34    /// Default TLS connector for upstream (real server) connections.
35    pub connector: TlsConnector,
36    /// Host-scoped TLS connectors for upstream connections.
37    scoped_upstream_connectors: Vec<ScopedUpstreamConnector>,
38    /// TLS configuration.
39    pub config: TlsConfig,
40    /// Live-swappable secrets configuration for placeholder substitution.
41    /// Loaded per connection so live secret updates apply to future traffic.
42    pub secrets: SecretsHandle,
43    /// Pre-computed lowercased bypass patterns for efficient matching.
44    bypass_patterns: Vec<DomainPattern>,
45}
46
47/// Errors that prevent TLS interception state from being initialized safely.
48#[derive(Debug, thiserror::Error)]
49pub enum TlsStateError {
50    /// Exactly one of the intercept CA certificate/key paths was configured.
51    #[error("intercept CA config is incomplete; set both cert_path and key_path")]
52    IncompleteInterceptCaConfig,
53
54    /// The configured intercept CA certificate could not be read.
55    #[error("failed to read intercept CA certificate `{path}`: {source}")]
56    ReadInterceptCaCert {
57        /// Certificate path that failed to read.
58        path: PathBuf,
59        /// Underlying I/O error.
60        #[source]
61        source: std::io::Error,
62    },
63
64    /// The configured intercept CA private key could not be read.
65    #[error("failed to read intercept CA key `{path}`: {source}")]
66    ReadInterceptCaKey {
67        /// Private-key path that failed to read.
68        path: PathBuf,
69        /// Underlying I/O error.
70        #[source]
71        source: std::io::Error,
72    },
73
74    /// The configured intercept CA files were readable but did not form a usable CA.
75    #[error("failed to load intercept CA `{cert_path}` / `{key_path}`: {reason}")]
76    InvalidInterceptCa {
77        /// Certificate path that was loaded.
78        cert_path: PathBuf,
79        /// Private-key path that was loaded.
80        key_path: PathBuf,
81        /// Validation or parsing failure from the CA loader.
82        reason: String,
83    },
84}
85
86/// A pre-processed domain pattern (avoids per-connection allocations).
87enum DomainPattern {
88    /// Exact domain match (lowercased).
89    Exact(String),
90    /// Wildcard suffix match. `suffix` is the bare suffix, `dotted` is `.suffix`
91    /// (pre-computed to avoid per-connection `format!` allocations).
92    Wildcard { suffix: String, dotted: String },
93}
94
95/// An upstream connector selected only for matching server names.
96struct ScopedUpstreamConnector {
97    pattern: DomainPattern,
98    connector: TlsConnector,
99}
100
101/// Effective upstream TLS settings for one host pattern.
102struct ScopedUpstreamSettings {
103    pattern: String,
104    ca_cert: Vec<PathBuf>,
105    verify_upstream: Option<bool>,
106}
107
108impl ScopedUpstreamSettings {
109    fn new(pattern: &str) -> Self {
110        Self {
111            pattern: pattern.to_string(),
112            ca_cert: Vec::new(),
113            verify_upstream: None,
114        }
115    }
116}
117
118/// A [`ServerCertVerifier`] that accepts all server certificates without
119/// validation. Used when `verify_upstream` is `false`.
120#[derive(Debug)]
121struct NoVerify;
122
123/// Refresh cached leaf certs shortly before expiry so long-lived sandboxes
124/// do not start serving an already-expired intercept certificate.
125const CERT_REFRESH_WINDOW: Duration = Duration::minutes(5);
126
127//--------------------------------------------------------------------------------------------------
128// Methods
129//--------------------------------------------------------------------------------------------------
130
131impl TlsState {
132    /// Create TLS state from configuration.
133    ///
134    /// CA resolution order:
135    /// 1. User-provided paths (`config.intercept_ca.cert_path` + `config.intercept_ca.key_path`)
136    /// 2. Microsandbox home TLS path (`$MSB_HOME/tls` or `~/.microsandbox/tls`)
137    /// 3. Auto-generate and persist to the microsandbox home TLS path
138    pub fn new(config: TlsConfig, secrets: SecretsHandle) -> Result<Self, TlsStateError> {
139        let ca = load_or_generate_ca(&config)?;
140
141        let capacity =
142            NonZeroUsize::new(config.cache.capacity).unwrap_or(NonZeroUsize::new(1000).unwrap());
143        let cert_cache = Mutex::new(LruCache::new(capacity));
144
145        let connector = build_upstream_connector(&config, config.verify_upstream, &[]);
146        let scoped_upstream_connectors = build_scoped_upstream_connectors(&config);
147
148        // Pre-compute lowercased bypass patterns to avoid per-connection allocations.
149        let bypass_patterns = config
150            .bypass
151            .iter()
152            .map(|pattern| DomainPattern::new(pattern))
153            .collect();
154
155        Ok(Self {
156            intercept_ca: ca,
157            cert_cache,
158            connector,
159            scoped_upstream_connectors,
160            config,
161            secrets,
162            bypass_patterns,
163        })
164    }
165
166    /// Get or generate a certificate for the given domain.
167    pub fn get_or_generate_cert(&self, domain: &str) -> Result<Arc<DomainCert>, DomainCertError> {
168        let mut cache = match self.cert_cache.lock() {
169            Ok(cache) => cache,
170            Err(poisoned) => {
171                tracing::warn!("TLS certificate cache was poisoned; recovering");
172                poisoned.into_inner()
173            }
174        };
175        if let Some(cert) = cache.get(domain)
176            && cert.expires_at > OffsetDateTime::now_utc() + CERT_REFRESH_WINDOW
177        {
178            return Ok(cert.clone());
179        }
180
181        let cert = Arc::new(certgen::generate_domain_cert(
182            domain,
183            &self.intercept_ca,
184            self.config.cache.validity_hours,
185        )?);
186        cache.put(domain.to_string(), cert.clone());
187        Ok(cert)
188    }
189
190    /// Check if a domain should bypass TLS interception.
191    pub fn should_bypass(&self, sni: &str) -> bool {
192        let sni_lower = normalize_domain(sni);
193        self.bypass_patterns
194            .iter()
195            .any(|pattern| pattern.matches_normalized(&sni_lower))
196    }
197
198    /// Select the upstream connector for the given server name.
199    ///
200    /// Falls back to the default connector when no host-scoped connector
201    /// matches; when several match, the most specific pattern wins.
202    pub fn upstream_connector_for(&self, sni: &str) -> &TlsConnector {
203        self.scoped_upstream_connector_for(sni)
204            .map_or(&self.connector, |scoped| &scoped.connector)
205    }
206
207    /// Find the most specific host-scoped upstream connector for `sni`, if any.
208    fn scoped_upstream_connector_for(&self, sni: &str) -> Option<&ScopedUpstreamConnector> {
209        let sni_lower = normalize_domain(sni);
210        self.scoped_upstream_connectors
211            .iter()
212            .filter(|scoped| scoped.pattern.matches_normalized(&sni_lower))
213            .max_by_key(|scoped| scoped.pattern.specificity())
214    }
215
216    /// Get the CA certificate PEM bytes for guest installation.
217    pub fn ca_cert_pem(&self) -> Vec<u8> {
218        self.intercept_ca.cert_pem()
219    }
220}
221
222impl DomainPattern {
223    fn new(pattern: &str) -> Self {
224        let lower = normalize_domain(pattern);
225        if let Some(suffix) = lower.strip_prefix("*.") {
226            let dotted = format!(".{suffix}");
227            DomainPattern::Wildcard {
228                suffix: suffix.to_string(),
229                dotted,
230            }
231        } else {
232            DomainPattern::Exact(lower)
233        }
234    }
235
236    fn matches_normalized(&self, sni_lower: &str) -> bool {
237        match self {
238            DomainPattern::Exact(exact) => sni_lower == exact,
239            DomainPattern::Wildcard { suffix, dotted } => {
240                sni_lower == suffix || sni_lower.ends_with(dotted.as_str())
241            }
242        }
243    }
244
245    fn specificity(&self) -> usize {
246        match self {
247            DomainPattern::Exact(exact) => exact.len() + 1,
248            DomainPattern::Wildcard { suffix, .. } => suffix.len(),
249        }
250    }
251}
252
253//--------------------------------------------------------------------------------------------------
254// Trait Implementations
255//--------------------------------------------------------------------------------------------------
256
257impl ServerCertVerifier for NoVerify {
258    fn verify_server_cert(
259        &self,
260        _end_entity: &CertificateDer<'_>,
261        _intermediates: &[CertificateDer<'_>],
262        _server_name: &ServerName<'_>,
263        _ocsp_response: &[u8],
264        _now: UnixTime,
265    ) -> Result<ServerCertVerified, rustls::Error> {
266        Ok(ServerCertVerified::assertion())
267    }
268
269    fn verify_tls12_signature(
270        &self,
271        _message: &[u8],
272        _cert: &CertificateDer<'_>,
273        _dss: &DigitallySignedStruct,
274    ) -> Result<HandshakeSignatureValid, rustls::Error> {
275        Ok(HandshakeSignatureValid::assertion())
276    }
277
278    fn verify_tls13_signature(
279        &self,
280        _message: &[u8],
281        _cert: &CertificateDer<'_>,
282        _dss: &DigitallySignedStruct,
283    ) -> Result<HandshakeSignatureValid, rustls::Error> {
284        Ok(HandshakeSignatureValid::assertion())
285    }
286
287    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
288        static SCHEMES: std::sync::OnceLock<Vec<rustls::SignatureScheme>> =
289            std::sync::OnceLock::new();
290        SCHEMES
291            .get_or_init(|| {
292                rustls::crypto::ring::default_provider()
293                    .signature_verification_algorithms
294                    .supported_schemes()
295            })
296            .clone()
297    }
298}
299
300//--------------------------------------------------------------------------------------------------
301// Functions
302//--------------------------------------------------------------------------------------------------
303
304/// Build the upstream TLS connector based on configuration.
305///
306/// When `verify_upstream` is true, loads the system's native root certificates.
307/// When false, uses a permissive verifier that accepts all server certificates.
308fn build_upstream_connector(
309    config: &TlsConfig,
310    verify_upstream: bool,
311    scoped_ca_cert: &[PathBuf],
312) -> TlsConnector {
313    let client_config = if verify_upstream {
314        let mut root_store = rustls::RootCertStore::empty();
315        let certs = rustls_native_certs::load_native_certs();
316        if !certs.errors.is_empty() {
317            tracing::warn!(
318                count = certs.errors.len(),
319                "errors loading native certificates"
320            );
321        }
322        let mut added = 0usize;
323        for cert in certs.certs {
324            if root_store.add(cert).is_ok() {
325                added += 1;
326            }
327        }
328        if added == 0 {
329            tracing::error!("no native root certificates loaded — all upstream TLS will fail");
330        }
331
332        load_upstream_ca_certificates(&mut root_store, &config.upstream_ca_cert);
333        load_upstream_ca_certificates(&mut root_store, scoped_ca_cert);
334
335        rustls::ClientConfig::builder()
336            .with_root_certificates(root_store)
337            .with_no_client_auth()
338    } else {
339        rustls::ClientConfig::builder()
340            .dangerous()
341            .with_custom_certificate_verifier(Arc::new(NoVerify))
342            .with_no_client_auth()
343    };
344
345    TlsConnector::from(Arc::new(client_config))
346}
347
348/// Build host-scoped upstream TLS connectors from grouped scoped settings.
349fn build_scoped_upstream_connectors(config: &TlsConfig) -> Vec<ScopedUpstreamConnector> {
350    grouped_scoped_upstream_settings(config)
351        .into_iter()
352        .filter_map(|settings| {
353            let verify_upstream = settings.verify_upstream.unwrap_or(config.verify_upstream);
354            if verify_upstream == config.verify_upstream && settings.ca_cert.is_empty() {
355                return None;
356            }
357
358            Some(ScopedUpstreamConnector {
359                pattern: DomainPattern::new(&settings.pattern),
360                connector: build_upstream_connector(config, verify_upstream, &settings.ca_cert),
361            })
362        })
363        .collect()
364}
365
366/// Group repeated scoped upstream settings by host pattern.
367///
368/// Grouping order is irrelevant: [`TlsState::upstream_connector_for`] selects
369/// by pattern specificity, not declaration order.
370fn grouped_scoped_upstream_settings(config: &TlsConfig) -> Vec<ScopedUpstreamSettings> {
371    let mut grouped = HashMap::<String, ScopedUpstreamSettings>::new();
372
373    for scoped in &config.scoped_upstream_ca_cert {
374        grouped
375            .entry(normalize_domain(&scoped.pattern))
376            .or_insert_with(|| ScopedUpstreamSettings::new(&scoped.pattern))
377            .ca_cert
378            .push(scoped.path.clone());
379    }
380
381    for scoped in &config.scoped_verify_upstream {
382        grouped
383            .entry(normalize_domain(&scoped.pattern))
384            .or_insert_with(|| ScopedUpstreamSettings::new(&scoped.pattern))
385            .verify_upstream = Some(scoped.verify);
386    }
387
388    grouped.into_values().collect()
389}
390
391/// Load extra upstream CA certificates into the provided root store.
392fn load_upstream_ca_certificates(root_store: &mut rustls::RootCertStore, paths: &[PathBuf]) {
393    for path in paths {
394        match std::fs::read(path) {
395            Ok(pem_data) => {
396                let mut extra_added = 0usize;
397                for cert in CertificateDer::pem_slice_iter(&pem_data).flatten() {
398                    if root_store.add(cert).is_ok() {
399                        extra_added += 1;
400                    }
401                }
402                tracing::info!(
403                    path = %path.display(),
404                    count = extra_added,
405                    "loaded upstream CA certificates"
406                );
407            }
408            Err(e) => {
409                tracing::error!(
410                    path = %path.display(),
411                    error = %e,
412                    "failed to read upstream CA certificate file"
413                );
414            }
415        }
416    }
417}
418
419/// Normalize host patterns and SNI names for matching.
420fn normalize_domain(domain: &str) -> String {
421    domain.trim_end_matches('.').to_ascii_lowercase()
422}
423
424/// Load or generate a CA based on the TLS configuration.
425///
426/// Resolution order:
427/// 1. User-provided paths (`cert_path` + `key_path`)
428/// 2. Microsandbox home TLS path (`$MSB_HOME/tls` or `~/.microsandbox/tls`)
429/// 3. Auto-generate and persist to the microsandbox home TLS path
430fn load_or_generate_ca(config: &TlsConfig) -> Result<CertAuthority, TlsStateError> {
431    match (
432        &config.intercept_ca.cert_path,
433        &config.intercept_ca.key_path,
434    ) {
435        (Some(cert_path), Some(key_path)) => {
436            let cert_pem =
437                std::fs::read(cert_path).map_err(|source| TlsStateError::ReadInterceptCaCert {
438                    path: cert_path.clone(),
439                    source,
440                })?;
441            let key_pem =
442                std::fs::read(key_path).map_err(|source| TlsStateError::ReadInterceptCaKey {
443                    path: key_path.clone(),
444                    source,
445                })?;
446            let ca = CertAuthority::load(&cert_pem, &key_pem).map_err(|err| {
447                TlsStateError::InvalidInterceptCa {
448                    cert_path: cert_path.clone(),
449                    key_path: key_path.clone(),
450                    reason: err.to_string(),
451                }
452            })?;
453            tracing::info!("loaded user-provided CA from {:?}", cert_path);
454            return Ok(ca);
455        }
456        (Some(_), None) | (None, Some(_)) => {
457            return Err(TlsStateError::IncompleteInterceptCaConfig);
458        }
459        (None, None) => {}
460    }
461
462    // 2. Try the same microsandbox home root used by cache/db/logs/metrics.
463    let default_dir = default_ca_dir();
464    let cert_path = default_dir.join("ca.crt");
465    let key_path = default_dir.join("ca.key");
466
467    if cert_path.exists()
468        && key_path.exists()
469        && let (Ok(cert_pem), Ok(key_pem)) = (std::fs::read(&cert_path), std::fs::read(&key_path))
470        && let Ok(ca) = CertAuthority::load(&cert_pem, &key_pem)
471    {
472        tracing::debug!("loaded persisted CA from {:?}", cert_path);
473        return Ok(ca);
474    }
475
476    // 3. Auto-generate and persist.
477    let ca = CertAuthority::generate();
478    if let Err(e) = std::fs::create_dir_all(&default_dir) {
479        tracing::warn!(error = %e, "failed to create CA directory, CA will not persist");
480    } else {
481        if let Err(e) = std::fs::write(&cert_path, ca.cert_pem()) {
482            tracing::warn!(error = %e, "failed to persist CA certificate");
483        }
484        if let Err(e) = write_key_file(&key_path, &ca.key_pem()) {
485            tracing::warn!(error = %e, "failed to persist CA key");
486        } else {
487            tracing::info!("generated and persisted CA to {:?}", default_dir);
488        }
489    }
490    Ok(ca)
491}
492
493/// Default CA persistence directory under the resolved microsandbox home.
494fn default_ca_dir() -> PathBuf {
495    default_ca_dir_from_home(microsandbox_utils::resolve_home())
496}
497
498/// Build the CA directory from a known microsandbox home.
499fn default_ca_dir_from_home(home: impl AsRef<Path>) -> PathBuf {
500    home.as_ref().join(TLS_SUBDIR)
501}
502
503/// Write a private key file with restricted permissions (0o600) from creation.
504///
505/// Uses `OpenOptions` with mode set at creation time to avoid the TOCTOU race
506/// of write-then-chmod where the file is briefly world-readable.
507fn write_key_file(path: &Path, data: &[u8]) -> std::io::Result<()> {
508    #[cfg(unix)]
509    {
510        use std::io::Write;
511        use std::os::unix::fs::OpenOptionsExt;
512        let mut file = std::fs::OpenOptions::new()
513            .write(true)
514            .create(true)
515            .truncate(true)
516            .mode(0o600)
517            .open(path)?;
518        file.write_all(data)?;
519    }
520    #[cfg(not(unix))]
521    {
522        std::fs::write(path, data)?;
523    }
524    Ok(())
525}
526
527//--------------------------------------------------------------------------------------------------
528// Tests
529//--------------------------------------------------------------------------------------------------
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use microsandbox_types::{ScopedUpstreamCaCert, ScopedVerifyUpstream};
535
536    use crate::secrets::config::SecretsConfig;
537    use crate::secrets::handle::SecretsHandle;
538
539    #[test]
540    fn regenerates_cached_domain_cert_when_near_expiry() {
541        let _ = rustls::crypto::ring::default_provider().install_default();
542        let state = TlsState::new(
543            TlsConfig::default(),
544            SecretsHandle::new(SecretsConfig::default()),
545        )
546        .unwrap();
547        let first = state.get_or_generate_cert("openrouter.ai").unwrap();
548        let original_expires_at = first.expires_at;
549
550        {
551            let mut cache = state.cert_cache.lock().unwrap();
552            let stale = Arc::new(DomainCert {
553                chain: first.chain.clone(),
554                key: first.key.clone_key(),
555                expires_at: OffsetDateTime::now_utc() + Duration::seconds(30),
556                server_config: first.server_config.clone(),
557            });
558            cache.put("openrouter.ai".to_string(), stale);
559        }
560
561        let refreshed = state.get_or_generate_cert("openrouter.ai").unwrap();
562        assert!(refreshed.expires_at > OffsetDateTime::now_utc() + Duration::hours(23));
563        assert!(refreshed.expires_at > original_expires_at - Duration::minutes(10));
564    }
565
566    #[test]
567    fn invalid_domain_cert_request_does_not_poison_cache() {
568        let _ = rustls::crypto::ring::default_provider().install_default();
569        let state = TlsState::new(
570            TlsConfig::default(),
571            SecretsHandle::new(SecretsConfig::default()),
572        )
573        .unwrap();
574
575        assert!(state.get_or_generate_cert("snowman.☃").is_err());
576        assert!(state.get_or_generate_cert("openrouter.ai").is_ok());
577    }
578
579    #[test]
580    fn default_ca_dir_uses_microsandbox_home_tls_subdir() {
581        let home = PathBuf::from("isolated-msb-home");
582
583        assert_eq!(
584            default_ca_dir_from_home(&home),
585            home.join(microsandbox_utils::TLS_SUBDIR)
586        );
587    }
588
589    #[test]
590    fn tls_state_rejects_incomplete_intercept_ca_config() {
591        let mut config = TlsConfig::default();
592        config.intercept_ca.cert_path = Some(PathBuf::from("/tmp/ca.crt"));
593
594        let err = match TlsState::new(config, SecretsHandle::new(SecretsConfig::default())) {
595            Ok(_) => panic!("incomplete CA config should fail"),
596            Err(err) => err,
597        };
598
599        assert!(matches!(err, TlsStateError::IncompleteInterceptCaConfig));
600    }
601
602    #[test]
603    fn tls_state_rejects_invalid_intercept_ca_pair() {
604        let dir = std::env::temp_dir().join(format!(
605            "microsandbox-invalid-intercept-ca-{}",
606            std::process::id()
607        ));
608        std::fs::create_dir_all(&dir).unwrap();
609        let cert_path = dir.join("ca.crt");
610        let key_path = dir.join("ca.key");
611        std::fs::write(&cert_path, b"not a cert").unwrap();
612        std::fs::write(&key_path, b"not a key").unwrap();
613
614        let mut config = TlsConfig::default();
615        config.intercept_ca.cert_path = Some(cert_path);
616        config.intercept_ca.key_path = Some(key_path);
617
618        let err = match TlsState::new(config, SecretsHandle::new(SecretsConfig::default())) {
619            Ok(_) => panic!("invalid CA config should fail"),
620            Err(err) => err,
621        };
622        let _ = std::fs::remove_dir_all(&dir);
623
624        assert!(matches!(err, TlsStateError::InvalidInterceptCa { .. }));
625    }
626
627    #[test]
628    fn domain_patterns_match_exact_and_wildcard_hosts() {
629        let exact = DomainPattern::new("api.internal.");
630        assert!(exact.matches_normalized("api.internal"));
631        assert!(!exact.matches_normalized("other.api.internal"));
632
633        let wildcard = DomainPattern::new("*.internal");
634        assert!(wildcard.matches_normalized("internal"));
635        assert!(wildcard.matches_normalized("api.internal"));
636        assert!(!wildcard.matches_normalized("notinternal"));
637    }
638
639    #[test]
640    fn domain_patterns_score_exact_as_more_specific() {
641        let exact = DomainPattern::new("api.internal");
642        let wildcard = DomainPattern::new("*.internal");
643
644        assert!(exact.specificity() > wildcard.specificity());
645    }
646
647    #[test]
648    fn scoped_upstream_settings_group_ca_and_verify_by_pattern() {
649        let mut config = TlsConfig::default();
650        config.scoped_upstream_ca_cert.push(ScopedUpstreamCaCert {
651            pattern: "*.internal".to_string(),
652            path: PathBuf::from("/tmp/one.pem"),
653        });
654        config.scoped_upstream_ca_cert.push(ScopedUpstreamCaCert {
655            pattern: "*.internal.".to_string(),
656            path: PathBuf::from("/tmp/two.pem"),
657        });
658        config.scoped_verify_upstream.push(ScopedVerifyUpstream {
659            pattern: "*.internal".to_string(),
660            verify: false,
661        });
662
663        let settings = grouped_scoped_upstream_settings(&config);
664
665        assert_eq!(settings.len(), 1);
666        assert_eq!(settings[0].pattern, "*.internal");
667        assert_eq!(
668            settings[0].ca_cert,
669            vec![PathBuf::from("/tmp/one.pem"), PathBuf::from("/tmp/two.pem")]
670        );
671        assert_eq!(settings[0].verify_upstream, Some(false));
672    }
673
674    #[test]
675    fn upstream_connector_for_selects_scoped_connector_for_matching_host() {
676        let _ = rustls::crypto::ring::default_provider().install_default();
677        let mut config = TlsConfig::default();
678        config.scoped_verify_upstream.push(ScopedVerifyUpstream {
679            pattern: "*.internal".to_string(),
680            verify: false,
681        });
682        let state = TlsState::new(config, SecretsHandle::new(SecretsConfig::default())).unwrap();
683
684        assert!(
685            state
686                .scoped_upstream_connector_for("api.internal")
687                .is_some()
688        );
689        assert!(
690            state
691                .scoped_upstream_connector_for("api.example.com")
692                .is_none()
693        );
694    }
695
696    #[test]
697    fn load_upstream_ca_certificates_keeps_certs_and_skips_other_sections() {
698        let ca = crate::tls::ca::CertAuthority::generate();
699        let path =
700            std::env::temp_dir().join(format!("msb-upstream-ca-test-{}.pem", std::process::id()));
701        // Bundle a private key and stray text around the certificate: only the
702        // certificate section must end up in the root store.
703        let mut bundle = ca.key_pem();
704        bundle.extend_from_slice(b"stray text between sections\n");
705        bundle.extend_from_slice(&ca.cert_pem());
706        std::fs::write(&path, &bundle).expect("write CA bundle");
707
708        let mut root_store = rustls::RootCertStore::empty();
709        load_upstream_ca_certificates(
710            &mut root_store,
711            &[path.clone(), PathBuf::from("/nonexistent/upstream-ca.pem")],
712        );
713        std::fs::remove_file(&path).ok();
714
715        assert_eq!(root_store.len(), 1);
716    }
717}