Skip to main content

waypoint_core/
tls.rs

1//! TLS trust configuration, single-sourced across both engines.
2//!
3//! [`SslMode`] carries libpq's meanings, and this module is the one place that
4//! turns a mode plus an optional CA file into an actual trust decision — as a
5//! `rustls::ClientConfig` for PostgreSQL, or `mysql_async::SslOpts` for MySQL.
6//! Keeping both mappings here is what stops the two engines from drifting, the
7//! same rule `commands::migrate::select_pending` applies to pending selection.
8//!
9//! The ladder, matching libpq:
10//!
11//! | Mode | TLS | Chain | Hostname | Plaintext fallback |
12//! |---|---|---|---|---|
13//! | `disable` | no | — | — | — |
14//! | `prefer` | opportunistic | no | no | yes, with a warning |
15//! | `require` | mandatory | no | no | no |
16//! | `verify-ca` | mandatory | yes | no | no |
17//! | `verify-full` | mandatory | yes | yes | no |
18//!
19//! Note that `require` **encrypts without authenticating** — that is what
20//! libpq means by it. Reach for `verify-full` when you want a server you can
21//! actually trust.
22
23use crate::config::SslMode;
24use std::path::Path;
25
26#[cfg(feature = "postgres")]
27use crate::error::{Result, WaypointError};
28#[cfg(feature = "postgres")]
29use std::sync::Arc;
30
31// ── Connection-string sslmode extraction ─────────────────────────────────────
32
33/// libpq TLS parameters lifted out of a connection string.
34#[derive(Debug, Default, PartialEq, Eq)]
35pub struct EmbeddedSslParams {
36    /// The `sslmode=` value, if it parsed.
37    pub mode: Option<SslMode>,
38    /// The `sslrootcert=` path, if present.
39    pub root_cert: Option<std::path::PathBuf>,
40}
41
42/// Pull libpq's `sslmode=` and `sslrootcert=` out of a connection string,
43/// returning the remainder alongside the extracted values.
44///
45/// This exists because `tokio_postgres`'s parser understands only
46/// `disable`/`prefer`/`require` for `sslmode` — `verify-ca` and `verify-full`
47/// are a hard `InvalidValue` — and it rejects *any* key it does not recognise,
48/// which includes `sslrootcert`. So `postgres://…?sslmode=verify-full&sslrootcert=/ca.pem`,
49/// which is ordinary libpq and exactly what a JDBC-shaped connection string
50/// looks like, cannot be parsed at all. We take both values ourselves and hand
51/// tokio-postgres a string it accepts.
52///
53/// Handles both the URL form and the libpq `key=value` form, since
54/// `WaypointConfig::connection_string` emits the latter for field-based
55/// PostgreSQL configs. An unparseable `sslmode` is warned about and dropped
56/// rather than passed through to fail more confusingly later.
57///
58/// The `key=value` scan is quote-aware, so a parameter name appearing inside a
59/// quoted password value is left alone.
60pub fn parse_url_sslmode(conn_string: &str) -> (String, EmbeddedSslParams) {
61    if let Some(q) = conn_string.find('?') {
62        parse_query_form(conn_string, q)
63    } else {
64        parse_keyvalue_form(conn_string)
65    }
66}
67
68/// Is this a libpq TLS key we consume ourselves?
69fn is_ssl_key(k: &str) -> bool {
70    k.eq_ignore_ascii_case("sslmode") || k.eq_ignore_ascii_case("sslrootcert")
71}
72
73fn store_param(out: &mut EmbeddedSslParams, key: &str, raw: &str) {
74    let value = raw.trim().trim_matches('\'');
75    if key.eq_ignore_ascii_case("sslmode") {
76        match value.parse::<SslMode>() {
77            Ok(mode) => out.mode = Some(mode),
78            Err(e) => log::warn!("{} (from the connection string); ignoring it.", e),
79        }
80    } else if !value.is_empty() {
81        out.root_cert = Some(std::path::PathBuf::from(value));
82    }
83}
84
85fn parse_query_form(conn_string: &str, q: usize) -> (String, EmbeddedSslParams) {
86    let (base, query) = conn_string.split_at(q);
87    let query = &query[1..];
88
89    let mut kept: Vec<&str> = Vec::new();
90    let mut out = EmbeddedSslParams::default();
91    let mut found = false;
92
93    for pair in query.split('&') {
94        match pair.split_once('=') {
95            Some((k, v)) if is_ssl_key(k) => {
96                found = true;
97                store_param(&mut out, k, v);
98            }
99            // Anything we do not consume is re-emitted verbatim, so percent
100            // encoding in values such as `options=-c%20search_path%3Dfoo`
101            // round-trips untouched.
102            _ => kept.push(pair),
103        }
104    }
105
106    if !found {
107        return (conn_string.to_string(), out);
108    }
109
110    let rebuilt = if kept.is_empty() {
111        base.to_string()
112    } else {
113        format!("{}?{}", base, kept.join("&"))
114    };
115    (rebuilt, out)
116}
117
118fn parse_keyvalue_form(conn_string: &str) -> (String, EmbeddedSslParams) {
119    let lowered = conn_string.to_lowercase();
120    // Only rebuild the string when there is something to remove — that keeps
121    // the overwhelmingly common no-TLS-params case byte-for-byte untouched.
122    if !lowered.contains("sslmode=") && !lowered.contains("sslrootcert=") {
123        return (conn_string.to_string(), EmbeddedSslParams::default());
124    }
125
126    let mut kept: Vec<&str> = Vec::new();
127    let mut out = EmbeddedSslParams::default();
128    let mut found = false;
129    let mut quoted = false;
130    let mut consumed = 0usize;
131
132    for token in conn_string.split_whitespace() {
133        // Track whether we are inside a single-quoted value (libpq quotes
134        // passwords containing spaces), so `password='a sslmode=b'` is not
135        // mistaken for a real parameter.
136        let inside = quoted;
137        let offset = conn_string[consumed..].find(token).unwrap_or(0);
138        consumed += offset + token.len();
139        quoted ^= token.matches('\'').count() % 2 == 1;
140
141        if !inside
142            && let Some((k, v)) = token.split_once('=')
143            && is_ssl_key(k)
144        {
145            found = true;
146            store_param(&mut out, k, v);
147            continue;
148        }
149        kept.push(token);
150    }
151
152    if found {
153        (kept.join(" "), out)
154    } else {
155        (conn_string.to_string(), out)
156    }
157}
158
159/// Reconcile a `sslrootcert=` found in the connection string with the
160/// configured path, using the same precedence rule as [`reconcile_ssl_mode`].
161pub fn reconcile_root_cert(
162    configured: Option<&Path>,
163    from_url: Option<std::path::PathBuf>,
164) -> Option<std::path::PathBuf> {
165    match configured {
166        Some(p) => Some(p.to_path_buf()),
167        None => from_url,
168    }
169}
170
171/// Reconcile a `sslmode=` found in the connection string with the configured
172/// [`SslMode`].
173///
174/// A configured mode other than the default `prefer` is taken as a deliberate
175/// choice and wins. Otherwise the connection string's value applies — which is
176/// what makes a bare `postgres://…?sslmode=verify-full` behave the way its
177/// author expects.
178pub fn reconcile_ssl_mode(configured: SslMode, from_url: Option<SslMode>) -> SslMode {
179    match from_url {
180        Some(url_mode) if configured == SslMode::Prefer => {
181            if url_mode != configured {
182                log::debug!(
183                    "Using sslmode '{}' from the connection string (ssl_mode is at its default).",
184                    url_mode
185                );
186            }
187            url_mode
188        }
189        Some(url_mode) if url_mode != configured => {
190            log::debug!(
191                "Connection string requests sslmode '{}', but ssl_mode is set to '{}'; \
192                 the configured value wins.",
193                url_mode,
194                configured
195            );
196            configured
197        }
198        _ => configured,
199    }
200}
201
202// ── PostgreSQL: rustls ───────────────────────────────────────────────────────
203
204/// Load the trust anchors to verify the server against.
205///
206/// With no `ssl_root_cert`, this is the compiled-in Mozilla bundle. With one,
207/// it is **only** the certificates in that file — matching libpq's
208/// `sslrootcert`, which replaces the default trust store rather than adding to
209/// it.
210///
211/// Every failure is an error naming the path. Falling back to the default
212/// roots when a CA file cannot be read would silently verify against the wrong
213/// trust anchors, which is precisely the class of bug this module exists to
214/// remove.
215#[cfg(feature = "postgres")]
216pub fn load_root_store(ssl_root_cert: Option<&Path>) -> Result<rustls::RootCertStore> {
217    let Some(path) = ssl_root_cert else {
218        return Ok(rustls::RootCertStore::from_iter(
219            webpki_roots::TLS_SERVER_ROOTS.iter().cloned(),
220        ));
221    };
222
223    let pem = std::fs::read(path).map_err(|e| {
224        WaypointError::ConfigError(format!(
225            "Failed to read ssl_root_cert '{}': {}",
226            path.display(),
227            e
228        ))
229    })?;
230
231    // rustls re-exports `pki_types`, whose PEM iterator filters by section
232    // kind — so a bundle carrying a private key alongside the CA loads
233    // cleanly, and a file holding *only* a key correctly reports no
234    // certificates. That saves pulling in `rustls-pemfile` as a dependency.
235    use rustls::pki_types::pem::PemObject;
236
237    let mut store = rustls::RootCertStore::empty();
238    let mut count = 0usize;
239
240    for cert in rustls::pki_types::CertificateDer::pem_slice_iter(&pem) {
241        let cert = cert.map_err(|e| {
242            WaypointError::ConfigError(format!(
243                "Failed to parse ssl_root_cert '{}': {}",
244                path.display(),
245                e
246            ))
247        })?;
248        store.add(cert).map_err(|e| {
249            WaypointError::ConfigError(format!(
250                "Certificate in ssl_root_cert '{}' was rejected: {}",
251                path.display(),
252                e
253            ))
254        })?;
255        count += 1;
256    }
257
258    if count == 0 {
259        return Err(WaypointError::ConfigError(format!(
260            "ssl_root_cert '{}' contains no certificates. It must be a PEM file \
261             with at least one CERTIFICATE block.",
262            path.display()
263        )));
264    }
265
266    log::debug!(
267        "Loaded {} CA certificate(s) from {}; the built-in trust store is not used.",
268        count,
269        path.display()
270    );
271    Ok(store)
272}
273
274/// A verifier that accepts any certificate.
275///
276/// Backs `prefer` and `require`, which libpq defines as encrypting without
277/// authenticating the server. Signature checking is still delegated to the
278/// crypto provider — it is the *identity* of the peer that goes unchecked, not
279/// the integrity of the handshake.
280#[cfg(feature = "postgres")]
281#[derive(Debug)]
282struct NoVerifier {
283    provider: Arc<rustls::crypto::CryptoProvider>,
284}
285
286#[cfg(feature = "postgres")]
287impl rustls::client::danger::ServerCertVerifier for NoVerifier {
288    fn verify_server_cert(
289        &self,
290        _end_entity: &rustls::pki_types::CertificateDer<'_>,
291        _intermediates: &[rustls::pki_types::CertificateDer<'_>],
292        _server_name: &rustls::pki_types::ServerName<'_>,
293        _ocsp_response: &[u8],
294        _now: rustls::pki_types::UnixTime,
295    ) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
296        Ok(rustls::client::danger::ServerCertVerified::assertion())
297    }
298
299    fn verify_tls12_signature(
300        &self,
301        message: &[u8],
302        cert: &rustls::pki_types::CertificateDer<'_>,
303        dss: &rustls::DigitallySignedStruct,
304    ) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
305        rustls::crypto::verify_tls12_signature(
306            message,
307            cert,
308            dss,
309            &self.provider.signature_verification_algorithms,
310        )
311    }
312
313    fn verify_tls13_signature(
314        &self,
315        message: &[u8],
316        cert: &rustls::pki_types::CertificateDer<'_>,
317        dss: &rustls::DigitallySignedStruct,
318    ) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
319        rustls::crypto::verify_tls13_signature(
320            message,
321            cert,
322            dss,
323            &self.provider.signature_verification_algorithms,
324        )
325    }
326
327    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
328        self.provider
329            .signature_verification_algorithms
330            .supported_schemes()
331    }
332}
333
334/// A verifier that checks the certificate chain but not the hostname.
335///
336/// Backs `verify-ca`. Everything is delegated to the standard webpki verifier;
337/// only a name mismatch is converted into success.
338#[cfg(feature = "postgres")]
339#[derive(Debug)]
340struct NoHostnameVerifier {
341    inner: Arc<rustls::client::WebPkiServerVerifier>,
342}
343
344#[cfg(feature = "postgres")]
345impl rustls::client::danger::ServerCertVerifier for NoHostnameVerifier {
346    fn verify_server_cert(
347        &self,
348        end_entity: &rustls::pki_types::CertificateDer<'_>,
349        intermediates: &[rustls::pki_types::CertificateDer<'_>],
350        server_name: &rustls::pki_types::ServerName<'_>,
351        ocsp_response: &[u8],
352        now: rustls::pki_types::UnixTime,
353    ) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
354        match self.inner.verify_server_cert(
355            end_entity,
356            intermediates,
357            server_name,
358            ocsp_response,
359            now,
360        ) {
361            Ok(v) => Ok(v),
362            // rustls carries the name mismatch in two shapes — the bare
363            // variant and the newer one with diagnostic context. Matching only
364            // the first would leave verify-ca failing closed on exactly the
365            // certificates it is supposed to accept.
366            Err(rustls::Error::InvalidCertificate(
367                rustls::CertificateError::NotValidForName
368                | rustls::CertificateError::NotValidForNameContext { .. },
369            )) => {
370                log::debug!(
371                    "Server certificate is not valid for the requested name; \
372                     accepted anyway because ssl_mode is 'verify-ca'."
373                );
374                Ok(rustls::client::danger::ServerCertVerified::assertion())
375            }
376            Err(e) => Err(e),
377        }
378    }
379
380    fn verify_tls12_signature(
381        &self,
382        message: &[u8],
383        cert: &rustls::pki_types::CertificateDer<'_>,
384        dss: &rustls::DigitallySignedStruct,
385    ) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
386        self.inner.verify_tls12_signature(message, cert, dss)
387    }
388
389    fn verify_tls13_signature(
390        &self,
391        message: &[u8],
392        cert: &rustls::pki_types::CertificateDer<'_>,
393        dss: &rustls::DigitallySignedStruct,
394    ) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
395        self.inner.verify_tls13_signature(message, cert, dss)
396    }
397
398    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
399        self.inner.supported_verify_schemes()
400    }
401}
402
403/// Build the rustls client configuration for a given mode.
404///
405/// The provider is pinned to `ring` — see the `deny.toml` ban on `aws-lc-sys`
406/// and the note in CLAUDE.md about why rustls' default feature set is off.
407#[cfg(feature = "postgres")]
408pub fn make_rustls_config(
409    ssl_mode: SslMode,
410    ssl_root_cert: Option<&Path>,
411) -> Result<rustls::ClientConfig> {
412    let provider = Arc::new(rustls::crypto::ring::default_provider());
413
414    let builder = rustls::ClientConfig::builder_with_provider(provider.clone())
415        .with_safe_default_protocol_versions()
416        .map_err(|e| {
417            WaypointError::ConfigError(format!("Failed to configure TLS protocol versions: {}", e))
418        })?;
419
420    let config = match ssl_mode {
421        // `disable` never reaches here — the caller uses NoTls.
422        SslMode::Disable | SslMode::Prefer | SslMode::Require => builder
423            .dangerous()
424            .with_custom_certificate_verifier(Arc::new(NoVerifier { provider }))
425            .with_no_client_auth(),
426        SslMode::VerifyCa => {
427            let roots = Arc::new(load_root_store(ssl_root_cert)?);
428            let inner =
429                rustls::client::WebPkiServerVerifier::builder_with_provider(roots, provider)
430                    .build()
431                    .map_err(|e| {
432                        WaypointError::ConfigError(format!(
433                            "Failed to build certificate verifier: {}",
434                            e
435                        ))
436                    })?;
437            builder
438                .dangerous()
439                .with_custom_certificate_verifier(Arc::new(NoHostnameVerifier { inner }))
440                .with_no_client_auth()
441        }
442        SslMode::VerifyFull => builder
443            .with_root_certificates(load_root_store(ssl_root_cert)?)
444            .with_no_client_auth(),
445    };
446
447    Ok(config)
448}
449
450// ── MySQL: mysql_async SslOpts ───────────────────────────────────────────────
451
452/// Map an [`SslMode`] onto `mysql_async`'s TLS options.
453///
454/// `None` means plaintext. Note that a `Some(_)` makes TLS **mandatory** —
455/// mysql_async has no opportunistic mode — so `prefer` relies on the caller
456/// probing the connection and retrying without TLS; see
457/// `db::connect_mysql_pool`.
458#[cfg(feature = "mysql")]
459pub fn make_mysql_ssl_opts(
460    ssl_mode: SslMode,
461    ssl_root_cert: Option<&std::path::Path>,
462) -> Option<mysql_async::SslOpts> {
463    if ssl_mode == SslMode::Disable {
464        return None;
465    }
466
467    let mut opts = mysql_async::SslOpts::default();
468
469    if ssl_mode.verifies_certificate() {
470        if let Some(path) = ssl_root_cert {
471            opts = opts
472                .with_root_certs(vec![path.to_path_buf().into()])
473                // Replace the built-in roots rather than supplement them, so
474                // this matches libpq's sslrootcert and the PostgreSQL path.
475                .with_disable_built_in_roots(true);
476        }
477        opts = opts.with_danger_accept_invalid_certs(false);
478        // verify-ca is *asked* to check the chain but not the name. The call
479        // below is currently a no-op: mysql_async 0.37 detects a name mismatch
480        // by testing whether the rustls error's Display contains
481        // "NotValidForName", and rustls 0.23 renders that error as
482        // "certificate not valid for name …" — the literal never appears. So
483        // verify-ca on MySQL actually behaves like verify-full.
484        //
485        // We keep the call (it costs nothing and becomes correct the moment
486        // mysql_async matches on the enum instead of the string) and warn, so
487        // an operator whose certificate CN does not match gets told why the
488        // connection failed instead of being quietly refused. Failing closed
489        // is the right direction; failing closed *silently* is not.
490        opts = opts.with_danger_skip_domain_validation(ssl_mode == SslMode::VerifyCa);
491        if ssl_mode == SslMode::VerifyCa {
492            log::warn!(
493                "ssl_mode = 'verify-ca' on MySQL: the driver cannot currently skip \
494                 hostname validation, so the certificate name will be checked too \
495                 (as if 'verify-full'). This is stricter than requested and may \
496                 reject a certificate issued to a different name."
497            );
498        }
499    } else {
500        // prefer / require: encrypt, do not authenticate.
501        opts = opts
502            .with_danger_accept_invalid_certs(true)
503            .with_danger_skip_domain_validation(true);
504    }
505
506    Some(opts)
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    // ── parse_url_sslmode ────────────────────────────────────────────────────
514
515    #[test]
516    fn test_parse_url_sslmode_absent_is_untouched() {
517        let s = "postgres://u:p@host:5432/db";
518        let (out, p) = parse_url_sslmode(s);
519        assert_eq!(out, s);
520        assert_eq!(p, EmbeddedSslParams::default());
521    }
522
523    #[test]
524    fn test_parse_url_sslmode_query_form() {
525        let (out, p) = parse_url_sslmode("postgres://u@host/db?sslmode=require");
526        assert_eq!(out, "postgres://u@host/db");
527        assert_eq!(p.mode, Some(SslMode::Require));
528    }
529
530    #[test]
531    fn test_parse_url_sslmode_extracts_verify_full() {
532        // The whole point: tokio-postgres cannot parse this value itself.
533        let (out, p) = parse_url_sslmode("postgres://u@host/db?sslmode=verify-full");
534        assert_eq!(out, "postgres://u@host/db");
535        assert_eq!(p.mode, Some(SslMode::VerifyFull));
536    }
537
538    #[test]
539    fn test_parse_url_sslmode_extracts_root_cert() {
540        // tokio-postgres rejects `sslrootcert` as an unknown option, so this
541        // has to come out of the string too.
542        let (out, p) = parse_url_sslmode("postgres://u@host/db?sslrootcert=/etc/ssl/ca.pem");
543        assert_eq!(out, "postgres://u@host/db");
544        assert_eq!(
545            p.root_cert,
546            Some(std::path::PathBuf::from("/etc/ssl/ca.pem"))
547        );
548    }
549
550    #[test]
551    fn test_parse_url_sslmode_extracts_both_params() {
552        let (out, p) = parse_url_sslmode(
553            "postgres://u@host/db?sslmode=verify-full&sslrootcert=/ca.pem&application_name=wp",
554        );
555        assert_eq!(out, "postgres://u@host/db?application_name=wp");
556        assert_eq!(p.mode, Some(SslMode::VerifyFull));
557        assert_eq!(p.root_cert, Some(std::path::PathBuf::from("/ca.pem")));
558    }
559
560    #[test]
561    fn test_parse_url_sslmode_keeps_other_query_params() {
562        let (out, p) =
563            parse_url_sslmode("postgres://u@host/db?sslmode=verify-ca&keepalives=1&foo=bar");
564        assert_eq!(out, "postgres://u@host/db?keepalives=1&foo=bar");
565        assert_eq!(p.mode, Some(SslMode::VerifyCa));
566    }
567
568    #[test]
569    fn test_parse_url_sslmode_preserves_encoded_values_verbatim() {
570        // Kept pairs are re-emitted byte-for-byte, so percent encoding in an
571        // unrelated value survives the round trip.
572        let (out, _) =
573            parse_url_sslmode("postgres://h/db?options=-c%20search_path%3Dfoo&sslmode=require");
574        assert_eq!(out, "postgres://h/db?options=-c%20search_path%3Dfoo");
575    }
576
577    #[test]
578    fn test_parse_url_sslmode_is_case_insensitive() {
579        let (out, p) = parse_url_sslmode("postgres://u@host/db?SSLMode=Verify-Full");
580        assert_eq!(out, "postgres://u@host/db");
581        assert_eq!(p.mode, Some(SslMode::VerifyFull));
582    }
583
584    #[test]
585    fn test_parse_url_sslmode_keyvalue_form() {
586        let (out, p) =
587            parse_url_sslmode("host=db port=5432 sslmode=verify-full user=admin dbname=app");
588        assert_eq!(out, "host=db port=5432 user=admin dbname=app");
589        assert_eq!(p.mode, Some(SslMode::VerifyFull));
590    }
591
592    #[test]
593    fn test_parse_url_sslmode_keyvalue_extracts_root_cert() {
594        let (out, p) = parse_url_sslmode("host=db sslrootcert=/ca.pem dbname=app");
595        assert_eq!(out, "host=db dbname=app");
596        assert_eq!(p.root_cert, Some(std::path::PathBuf::from("/ca.pem")));
597    }
598
599    #[test]
600    fn test_parse_url_sslmode_keyvalue_ignores_quoted_value() {
601        // A password that happens to contain "sslmode=" must not be treated as
602        // a parameter, and nothing should be stripped.
603        let s = "host=db user=admin password='a sslmode=require b' dbname=app";
604        let (out, p) = parse_url_sslmode(s);
605        assert_eq!(out, s);
606        assert_eq!(p, EmbeddedSslParams::default());
607    }
608
609    #[test]
610    fn test_parse_url_sslmode_unparseable_is_dropped() {
611        let (out, p) = parse_url_sslmode("postgres://u@host/db?sslmode=banana");
612        assert_eq!(out, "postgres://u@host/db");
613        assert_eq!(p.mode, None);
614    }
615
616    #[test]
617    fn test_parse_url_sslmode_allow_is_dropped() {
618        // `allow` is rejected by FromStr, so it is warned about and ignored
619        // rather than being silently treated as `prefer`.
620        let (_, p) = parse_url_sslmode("postgres://u@host/db?sslmode=allow");
621        assert_eq!(p.mode, None);
622    }
623
624    /// The bug the pre-pass exists to fix: tokio-postgres cannot parse either
625    /// of these, and the stripped remainder must be something it accepts.
626    #[cfg(feature = "postgres")]
627    #[test]
628    fn test_stripped_string_is_parseable_by_tokio_postgres() {
629        for raw in [
630            "postgres://u@host/db?sslmode=verify-full",
631            "postgres://u@host/db?sslrootcert=/ca.pem",
632            "host=db sslmode=verify-ca dbname=app",
633        ] {
634            assert!(
635                raw.parse::<tokio_postgres::Config>().is_err(),
636                "expected tokio-postgres to reject {raw}"
637            );
638            let (cleaned, _) = parse_url_sslmode(raw);
639            assert!(
640                cleaned.parse::<tokio_postgres::Config>().is_ok(),
641                "tokio-postgres rejected the cleaned string {cleaned}"
642            );
643        }
644    }
645
646    #[test]
647    fn test_reconcile_root_cert_config_wins() {
648        let configured = std::path::PathBuf::from("/config/ca.pem");
649        let from_url = std::path::PathBuf::from("/url/ca.pem");
650        assert_eq!(
651            reconcile_root_cert(Some(&configured), Some(from_url.clone())),
652            Some(configured.clone())
653        );
654        assert_eq!(
655            reconcile_root_cert(None, Some(from_url.clone())),
656            Some(from_url)
657        );
658        assert_eq!(reconcile_root_cert(None, None), None);
659    }
660
661    // ── reconcile_ssl_mode ───────────────────────────────────────────────────
662
663    #[test]
664    fn test_reconcile_url_wins_when_config_is_default() {
665        assert_eq!(
666            reconcile_ssl_mode(SslMode::Prefer, Some(SslMode::VerifyFull)),
667            SslMode::VerifyFull
668        );
669    }
670
671    #[test]
672    fn test_reconcile_config_wins_when_set() {
673        assert_eq!(
674            reconcile_ssl_mode(SslMode::VerifyFull, Some(SslMode::Require)),
675            SslMode::VerifyFull
676        );
677        assert_eq!(
678            reconcile_ssl_mode(SslMode::Disable, Some(SslMode::Require)),
679            SslMode::Disable
680        );
681    }
682
683    #[test]
684    fn test_reconcile_without_url_mode_keeps_config() {
685        assert_eq!(reconcile_ssl_mode(SslMode::Require, None), SslMode::Require);
686        assert_eq!(reconcile_ssl_mode(SslMode::Prefer, None), SslMode::Prefer);
687    }
688
689    // ── load_root_store ──────────────────────────────────────────────────────
690
691    #[cfg(feature = "postgres")]
692    mod root_store {
693        use super::*;
694        use std::io::Write;
695
696        /// A syntactically valid self-signed certificate, for trust-store
697        /// loading tests only — it is never presented to a server.
698        const TEST_CA_PEM: &str = include_str!("../tests/fixtures/test-ca.pem");
699
700        fn write_temp(contents: &str) -> tempfile::NamedTempFile {
701            let mut f = tempfile::NamedTempFile::new().unwrap();
702            f.write_all(contents.as_bytes()).unwrap();
703            f.flush().unwrap();
704            f
705        }
706
707        #[test]
708        fn test_load_root_store_defaults_to_builtin_roots() {
709            let store = load_root_store(None).unwrap();
710            assert!(
711                !store.is_empty(),
712                "the built-in Mozilla bundle should be non-empty"
713            );
714        }
715
716        #[test]
717        fn test_load_root_store_reads_custom_ca() {
718            let f = write_temp(TEST_CA_PEM);
719            let store = load_root_store(Some(f.path())).unwrap();
720            // Exactly the supplied certificate — the built-in roots are
721            // replaced, not supplemented.
722            assert_eq!(store.len(), 1);
723        }
724
725        #[test]
726        fn test_load_root_store_missing_file_errors() {
727            let err = load_root_store(Some(Path::new("/nonexistent/ca.pem"))).unwrap_err();
728            let msg = err.to_string();
729            assert!(msg.contains("ssl_root_cert"), "got: {}", msg);
730            assert!(msg.contains("/nonexistent/ca.pem"), "got: {}", msg);
731        }
732
733        #[test]
734        fn test_load_root_store_empty_file_errors() {
735            let f = write_temp("");
736            let err = load_root_store(Some(f.path())).unwrap_err();
737            assert!(err.to_string().contains("no certificates"), "got: {}", err);
738        }
739
740        #[test]
741        fn test_load_root_store_pem_without_certificates_errors() {
742            // A well-formed PEM carrying the wrong kind of block.
743            let f = write_temp(
744                "-----BEGIN PRIVATE KEY-----\nMIIBVQIBADANBg==\n-----END PRIVATE KEY-----\n",
745            );
746            let err = load_root_store(Some(f.path())).unwrap_err();
747            assert!(err.to_string().contains("no certificates"), "got: {}", err);
748        }
749
750        #[test]
751        fn test_load_root_store_malformed_pem_errors() {
752            let f = write_temp("-----BEGIN CERTIFICATE-----\nnot base64 at all!!\n");
753            let err = load_root_store(Some(f.path())).unwrap_err();
754            let msg = err.to_string();
755            assert!(
756                msg.contains("parse") || msg.contains("no certificates"),
757                "got: {}",
758                msg
759            );
760        }
761
762        #[test]
763        fn test_make_rustls_config_all_modes_build() {
764            let f = write_temp(TEST_CA_PEM);
765            for mode in [
766                SslMode::Prefer,
767                SslMode::Require,
768                SslMode::VerifyCa,
769                SslMode::VerifyFull,
770            ] {
771                assert!(
772                    make_rustls_config(mode, Some(f.path())).is_ok(),
773                    "mode {} failed to build",
774                    mode
775                );
776            }
777        }
778
779        #[test]
780        fn test_make_rustls_config_propagates_ca_errors() {
781            // A verifying mode must refuse to build rather than quietly fall
782            // back to the built-in roots.
783            let bad = Path::new("/nonexistent/ca.pem");
784            assert!(make_rustls_config(SslMode::VerifyFull, Some(bad)).is_err());
785            assert!(make_rustls_config(SslMode::VerifyCa, Some(bad)).is_err());
786            // Non-verifying modes never read the file, so they still build.
787            assert!(make_rustls_config(SslMode::Require, Some(bad)).is_ok());
788        }
789    }
790
791    // ── MySQL SslOpts mapping ────────────────────────────────────────────────
792
793    #[cfg(feature = "mysql")]
794    mod mysql_opts {
795        use super::*;
796
797        #[test]
798        fn test_mysql_ssl_opts_disable_is_none() {
799            assert!(make_mysql_ssl_opts(SslMode::Disable, None).is_none());
800        }
801
802        #[test]
803        fn test_mysql_ssl_opts_non_verifying_modes_skip_checks() {
804            for mode in [SslMode::Prefer, SslMode::Require] {
805                let opts = make_mysql_ssl_opts(mode, None).unwrap();
806                assert!(opts.accept_invalid_certs(), "mode {}", mode);
807                assert!(opts.skip_domain_validation(), "mode {}", mode);
808            }
809        }
810
811        #[test]
812        fn test_mysql_ssl_opts_verify_ca_checks_chain_not_name() {
813            let opts = make_mysql_ssl_opts(SslMode::VerifyCa, None).unwrap();
814            assert!(!opts.accept_invalid_certs());
815            assert!(opts.skip_domain_validation());
816        }
817
818        #[test]
819        fn test_mysql_ssl_opts_verify_full_checks_everything() {
820            let opts = make_mysql_ssl_opts(SslMode::VerifyFull, None).unwrap();
821            assert!(!opts.accept_invalid_certs());
822            assert!(!opts.skip_domain_validation());
823        }
824
825        #[test]
826        fn test_mysql_ssl_opts_custom_ca_replaces_builtin_roots() {
827            let path = std::path::Path::new("/etc/ssl/my-ca.pem");
828            let opts = make_mysql_ssl_opts(SslMode::VerifyFull, Some(path)).unwrap();
829            assert_eq!(opts.root_certs().len(), 1);
830            assert!(opts.disable_built_in_roots());
831
832            // Without a CA the built-in roots stay in play.
833            let opts = make_mysql_ssl_opts(SslMode::VerifyFull, None).unwrap();
834            assert!(opts.root_certs().is_empty());
835            assert!(!opts.disable_built_in_roots());
836        }
837
838        #[test]
839        fn test_mysql_ssl_opts_ignores_ca_for_non_verifying_modes() {
840            let path = std::path::Path::new("/etc/ssl/my-ca.pem");
841            let opts = make_mysql_ssl_opts(SslMode::Require, Some(path)).unwrap();
842            assert!(opts.root_certs().is_empty());
843        }
844    }
845}