Skip to main content

mcp_postgres/
tls.rs

1//! TLS support for PostgreSQL connections.
2//!
3//! TLS is opt-in via the connection string's `sslmode`. When `sslmode` is
4//! `require`, `verify-ca`, `verify-full`, or `prefer`, the pool uses a rustls
5//! connector with the system's native root certificates. Otherwise (the
6//! default, or `sslmode=disable`/`allow`) connections stay plaintext, matching
7//! the previous behavior exactly.
8
9use rustls::ClientConfig;
10use tokio_postgres_rustls::MakeRustlsConnect;
11
12/// Return `true` if the connection string opts into TLS via `sslmode`.
13pub fn wants_tls(connection_string: &str) -> bool {
14    sslmode(connection_string)
15        .map(|m| {
16            matches!(
17                m.as_str(),
18                "require" | "verify-ca" | "verify-full" | "prefer"
19            )
20        })
21        .unwrap_or(false)
22}
23
24/// Extract the `sslmode` value from a key=value or URL-style connection string.
25fn sslmode(connection_string: &str) -> Option<String> {
26    // Handle both "key=value ..." and "postgres://...?sslmode=..." forms by
27    // scanning for the sslmode token anywhere after a '=' delimiter.
28    let lower = connection_string.to_ascii_lowercase();
29    let idx = lower.find("sslmode=")?;
30    let rest = &lower[idx + "sslmode=".len()..];
31    let end = rest.find([' ', '&', '\'']).unwrap_or(rest.len());
32    Some(rest[..end].trim().to_string())
33}
34
35/// Install the rustls `ring` crypto provider as the process default.
36///
37/// Idempotent — only the first install in the process wins; later calls are
38/// ignored. Call this anywhere a rustls-backed client may be built (Postgres
39/// TLS via [`make_connector`], or the data-import HTTP client which uses reqwest
40/// with `rustls-no-provider`) so the process never lacks a default
41/// `CryptoProvider`. Keeping it in the library — rather than only in the binary's
42/// `main` — ensures library consumers get correct TLS too.
43pub fn ensure_crypto_provider() {
44    let _ = rustls::crypto::ring::default_provider().install_default();
45}
46
47/// Build a rustls connector loading the OS trust store.
48///
49/// Installs the ring crypto provider as the process default on first call
50/// (idempotent — a second install is ignored).
51pub fn make_connector() -> anyhow::Result<MakeRustlsConnect> {
52    // Safe to call repeatedly; only the first install wins.
53    ensure_crypto_provider();
54
55    let mut roots = rustls::RootCertStore::empty();
56    let result = rustls_native_certs::load_native_certs();
57    if !result.errors.is_empty() {
58        tracing::warn!(
59            "Some native root certificates failed to load: {:?}",
60            result.errors
61        );
62    }
63    for cert in result.certs {
64        // Skip individual malformed certs rather than failing the whole pool.
65        let _ = roots.add(cert);
66    }
67    if roots.is_empty() {
68        anyhow::bail!("No native root certificates available for TLS verification");
69    }
70
71    let config = ClientConfig::builder()
72        .with_root_certificates(roots)
73        .with_no_client_auth();
74
75    Ok(MakeRustlsConnect::new(config))
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn test_sslmode_url_form() {
84        assert_eq!(
85            sslmode("postgres://u:p@h/db?sslmode=require"),
86            Some("require".to_string())
87        );
88    }
89
90    #[test]
91    fn test_sslmode_kv_form() {
92        assert_eq!(
93            sslmode("host=localhost sslmode=verify-full dbname=x"),
94            Some("verify-full".to_string())
95        );
96    }
97
98    #[test]
99    fn test_wants_tls() {
100        assert!(wants_tls("postgres://h/db?sslmode=require"));
101        assert!(wants_tls("sslmode=verify-ca"));
102        assert!(wants_tls("sslmode=prefer"));
103        assert!(!wants_tls("postgres://h/db?sslmode=disable"));
104        assert!(!wants_tls("postgres://h/db")); // default: plaintext
105        assert!(!wants_tls("host=localhost dbname=x"));
106    }
107}