Skip to main content

connectorx/sources/mysql/
connection.rs

1use super::errors::MySQLSourceError;
2use r2d2_mysql::mysql::{Opts, OptsBuilder, SslOpts};
3use std::collections::HashMap;
4use std::path::PathBuf;
5use url::Url;
6
7/// URL query parameters consumed here and stripped before the URL is handed to the
8/// `mysql` crate, which rejects any parameter it does not recognize.
9const SSL_PARAMS: &[&str] = &["ssl-mode", "sslmode", "ssl-ca"];
10
11/// Build the connection options for a MySQL URL, translating the `ssl-mode` (and
12/// `ssl-ca`) query parameters into a [`SslOpts`].
13///
14/// The `mysql` crate configures TLS programmatically via [`OptsBuilder::ssl_opts`] and
15/// errors on unknown URL parameters, so the SSL parameters are parsed and removed here
16/// rather than passed through to [`Opts::from_url`].
17pub fn build_opts(conn: &str) -> Result<OptsBuilder, MySQLSourceError> {
18    let url = Url::parse(conn)?;
19
20    let params: HashMap<String, String> = url
21        .query_pairs()
22        .map(|(k, v)| (k.to_ascii_lowercase(), v.into_owned()))
23        .collect();
24    let ssl_opts = ssl_opts_from_params(&params)?;
25
26    let opts = Opts::from_url(strip_ssl_params(&url).as_str())?;
27    Ok(OptsBuilder::from_opts(opts).ssl_opts(ssl_opts))
28}
29
30/// Translate a MySQL `ssl-mode` value into [`SslOpts`], returning `None` when TLS is not
31/// requested (no `ssl-mode`, or `ssl-mode=DISABLED`) so that behavior is unchanged for
32/// connection strings without SSL parameters.
33///
34/// `PREFERRED` is treated as `REQUIRED` (encrypt without verification): the `mysql` crate
35/// has no opportunistic mode that falls back to plaintext when the server lacks TLS.
36fn ssl_opts_from_params(
37    params: &HashMap<String, String>,
38) -> Result<Option<SslOpts>, MySQLSourceError> {
39    let mode = match params.get("ssl-mode").or_else(|| params.get("sslmode")) {
40        Some(mode) => mode,
41        None => return Ok(None),
42    };
43
44    let (accept_invalid_certs, skip_domain_validation) =
45        match mode.trim().to_ascii_uppercase().replace('-', "_").as_str() {
46            "DISABLED" => return Ok(None),
47            "PREFERRED" | "REQUIRED" => (true, true),
48            "VERIFY_CA" => (false, true),
49            "VERIFY_IDENTITY" => (false, false),
50            _ => return Err(MySQLSourceError::InvalidSslMode(mode.clone())),
51        };
52
53    let ssl_opts = SslOpts::default()
54        .with_danger_accept_invalid_certs(accept_invalid_certs)
55        .with_danger_skip_domain_validation(skip_domain_validation)
56        .with_root_cert_path(params.get("ssl-ca").map(PathBuf::from));
57    Ok(Some(ssl_opts))
58}
59
60fn strip_ssl_params(url: &Url) -> Url {
61    let kept: Vec<(String, String)> = url
62        .query_pairs()
63        .filter(|(k, _)| !SSL_PARAMS.contains(&k.to_ascii_lowercase().as_str()))
64        .map(|(k, v)| (k.into_owned(), v.into_owned()))
65        .collect();
66
67    let mut stripped = url.clone();
68    stripped.set_query(None);
69    for (k, v) in kept {
70        stripped.query_pairs_mut().append_pair(&k, &v);
71    }
72    stripped
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    fn ssl_opts(conn: &str) -> Option<SslOpts> {
80        let opts: Opts = build_opts(conn).unwrap().into();
81        opts.get_ssl_opts().cloned()
82    }
83
84    #[test]
85    fn no_ssl_mode_disables_tls() {
86        assert!(ssl_opts("mysql://user:pass@host:3306/db").is_none());
87        assert!(ssl_opts("mysql://user:pass@host:3306/db?ssl-mode=DISABLED").is_none());
88    }
89
90    #[test]
91    fn required_encrypts_without_verification() {
92        let opts = ssl_opts("mysql://user:pass@host:3306/db?ssl-mode=REQUIRED").unwrap();
93        assert!(opts.accept_invalid_certs());
94        assert!(opts.skip_domain_validation());
95        assert!(opts.root_cert_path().is_none());
96    }
97
98    #[test]
99    fn preferred_is_treated_as_required() {
100        assert_eq!(
101            ssl_opts("mysql://user:pass@host:3306/db?ssl-mode=PREFERRED"),
102            ssl_opts("mysql://user:pass@host:3306/db?ssl-mode=REQUIRED"),
103        );
104    }
105
106    #[test]
107    fn verify_ca_checks_chain_but_not_hostname() {
108        let opts = ssl_opts("mysql://user:pass@host:3306/db?ssl-mode=VERIFY_CA&ssl-ca=/tmp/ca.pem")
109            .unwrap();
110        assert!(!opts.accept_invalid_certs());
111        assert!(opts.skip_domain_validation());
112        assert_eq!(
113            opts.root_cert_path(),
114            Some(std::path::Path::new("/tmp/ca.pem"))
115        );
116    }
117
118    #[test]
119    fn verify_identity_checks_chain_and_hostname() {
120        let opts = ssl_opts("mysql://user:pass@host:3306/db?ssl-mode=VERIFY_IDENTITY").unwrap();
121        assert!(!opts.accept_invalid_certs());
122        assert!(!opts.skip_domain_validation());
123    }
124
125    #[test]
126    fn ssl_mode_is_case_insensitive_and_accepts_sslmode_alias() {
127        assert!(ssl_opts("mysql://user:pass@host:3306/db?ssl-mode=required").is_some());
128        assert!(ssl_opts("mysql://user:pass@host:3306/db?sslmode=Required").is_some());
129    }
130
131    #[test]
132    fn invalid_ssl_mode_errors() {
133        assert!(matches!(
134            build_opts("mysql://user:pass@host:3306/db?ssl-mode=bogus"),
135            Err(MySQLSourceError::InvalidSslMode(_))
136        ));
137    }
138
139    #[test]
140    fn ssl_params_are_stripped_so_the_url_parses_and_other_params_survive() {
141        // `ssl-ca` is not a parameter the mysql crate recognizes, so build_opts must
142        // strip it (otherwise the URL is rejected) while leaving non-SSL params intact.
143        let opts: Opts = build_opts(
144            "mysql://user:pass@host:3306/db?ssl-mode=REQUIRED&ssl-ca=/tmp/ca.pem&prefer_socket=false",
145        )
146        .unwrap()
147        .into();
148        assert!(opts.get_ssl_opts().is_some());
149        assert!(!opts.get_prefer_socket());
150    }
151}