1use crate::client::errors::ConnectionError;
39use crate::config::{Profile, SslMode};
40use crate::info;
41use mz_postgres_util::Sql;
42use std::collections::BTreeMap;
43use tokio_postgres::types::ToSql;
44use tokio_postgres::{Client as PgClient, NoTls, Row, SimpleQueryMessage, Transaction};
45
46pub struct Client {
55 client: PgClient,
56 profile: Profile,
57 default_replication_factor: std::sync::OnceLock<u32>,
58}
59
60pub struct DeploymentsClient<'a> {
62 pub(crate) client: &'a Client,
63}
64
65pub struct DeploymentsClientMut<'a> {
67 pub(crate) client: &'a mut Client,
68}
69
70pub struct IntrospectionClient<'a> {
72 pub(crate) client: &'a Client,
73}
74
75pub struct ValidationClient<'a> {
77 pub(crate) client: &'a Client,
78}
79
80pub struct TypeInfoClient<'a> {
82 pub(crate) client: &'a Client,
83}
84
85pub struct ProvisioningClient<'a> {
87 pub(crate) client: &'a Client,
88}
89
90pub struct DevOverlaysClient<'a> {
92 pub(crate) client: &'a Client,
93}
94
95const APPLICATION_NAME: &str = "mz-deploy";
96
97impl Client {
98 pub async fn connect_with_profile(profile: Profile) -> Result<Self, ConnectionError> {
110 Self::connect_with_profile_inner(profile, true).await
111 }
112
113 pub(crate) async fn connect_with_profile_no_pin(
124 profile: Profile,
125 ) -> Result<Self, ConnectionError> {
126 Self::connect_with_profile_inner(profile, false).await
127 }
128
129 async fn connect_with_profile_inner(
130 profile: Profile,
131 pin_server_cluster: bool,
132 ) -> Result<Self, ConnectionError> {
133 let host = profile.require_host()?;
134 let mut config = tokio_postgres::Config::new();
135 config.host(host);
136 config.port(profile.port);
137 config.user(&profile.username);
138 config.dbname("materialize");
139 if let Some(password) = &profile.password {
140 config.password(password.as_str());
141 }
142 config.application_name(APPLICATION_NAME);
143
144 let mut effective_options = profile.options.clone();
145 if pin_server_cluster {
146 effective_options.insert(
147 "cluster".to_string(),
148 crate::client::SERVER_CLUSTER_NAME.to_string(),
149 );
150 }
151 if let Some(inner) = build_options_string(&effective_options) {
152 config.options(&inner);
153 }
154
155 let mode = profile.sslmode.unwrap_or_else(|| default_sslmode(host));
156 let hunt: Vec<&std::path::Path> =
157 DEFAULT_CA_PATHS.iter().map(std::path::Path::new).collect();
158 let spec = plan_connector(mode, profile.sslrootcert.as_deref(), host, &hunt, |p| {
159 p.exists()
160 })?;
161 let connector = build_connector(spec)?;
162
163 config.ssl_mode(tokio_ssl_mode(mode));
164
165 type BoxConnection =
169 Box<dyn Future<Output = Result<(), tokio_postgres::Error>> + Send + Unpin>;
170 let (client, connection): (PgClient, BoxConnection) = match connector {
171 Connector::NoTls => {
172 let (client, connection) = config
173 .connect(NoTls)
174 .await
175 .map_err(|source| classify_connect_error(source, &profile, mode))?;
176 (client, Box::new(connection))
177 }
178 Connector::Tls(tls) => {
179 let (client, connection) = config
180 .connect(tls)
181 .await
182 .map_err(|source| classify_connect_error(source, &profile, mode))?;
183 (client, Box::new(connection))
184 }
185 };
186
187 mz_ore::task::spawn(|| "mz-deploy-connection", async move {
188 if let Err(e) = connection.await {
189 info!("connection error: {}", e);
190 }
191 });
192
193 Ok(Client {
194 client,
195 profile,
196 default_replication_factor: std::sync::OnceLock::new(),
197 })
198 }
199
200 pub(crate) async fn default_cluster_replication_factor(&self) -> Result<u32, ConnectionError> {
203 if let Some(factor) = self.default_replication_factor.get() {
204 return Ok(*factor);
205 }
206 let row = self
207 .query_one("SHOW default_cluster_replication_factor", &[])
208 .await?;
209 let raw: String = row.get(0);
210 let factor = raw.parse().map_err(|_| {
211 ConnectionError::Message(format!(
212 "invalid default_cluster_replication_factor '{}'",
213 raw
214 ))
215 })?;
216 let _ = self.default_replication_factor.set(factor);
217 Ok(factor)
218 }
219
220 pub fn profile(&self) -> &Profile {
222 &self.profile
223 }
224
225 pub(crate) async fn begin_transaction(&mut self) -> Result<Transaction<'_>, ConnectionError> {
227 self.client
228 .transaction()
229 .await
230 .map_err(ConnectionError::Query)
231 }
232
233 pub fn deployments(&self) -> DeploymentsClient<'_> {
235 DeploymentsClient { client: self }
236 }
237
238 pub fn deployments_mut(&mut self) -> DeploymentsClientMut<'_> {
240 DeploymentsClientMut { client: self }
241 }
242
243 pub fn introspection(&self) -> IntrospectionClient<'_> {
245 IntrospectionClient { client: self }
246 }
247
248 pub fn validation(&self) -> ValidationClient<'_> {
250 ValidationClient { client: self }
251 }
252
253 pub fn types(&self) -> TypeInfoClient<'_> {
255 TypeInfoClient { client: self }
256 }
257
258 pub fn provisioning(&self) -> ProvisioningClient<'_> {
260 ProvisioningClient { client: self }
261 }
262
263 pub fn dev_overlays(&self) -> DevOverlaysClient<'_> {
265 DevOverlaysClient { client: self }
266 }
267
268 pub async fn execute(
270 &self,
271 statement: &str,
272 params: &[&(dyn ToSql + Sync)],
273 ) -> Result<u64, ConnectionError> {
274 mz_postgres_util::execute(
275 &self.client,
276 Sql::raw_unchecked(statement.to_string()),
277 params,
278 )
279 .await
280 .map_err(ConnectionError::from)
281 }
282
283 pub async fn query_one(
285 &self,
286 statement: &str,
287 params: &[&(dyn ToSql + Sync)],
288 ) -> Result<Row, ConnectionError> {
289 mz_postgres_util::query_one(
290 &self.client,
291 Sql::raw_unchecked(statement.to_string()),
292 params,
293 )
294 .await
295 .map_err(ConnectionError::from)
296 }
297
298 pub async fn query(
300 &self,
301 statement: &str,
302 params: &[&(dyn ToSql + Sync)],
303 ) -> Result<Vec<Row>, ConnectionError> {
304 mz_postgres_util::query(
305 &self.client,
306 Sql::raw_unchecked(statement.to_string()),
307 params,
308 )
309 .await
310 .map_err(ConnectionError::from)
311 }
312
313 pub async fn simple_query(
315 &self,
316 query: &str,
317 ) -> Result<Vec<SimpleQueryMessage>, ConnectionError> {
318 mz_postgres_util::simple_query(&self.client, Sql::raw_unchecked(query.to_string()))
319 .await
320 .map_err(ConnectionError::from)
321 }
322
323 pub async fn batch_execute(&self, query: &str) -> Result<(), ConnectionError> {
325 mz_postgres_util::batch_execute(&self.client, Sql::raw_unchecked(query.to_string()))
326 .await
327 .map_err(ConnectionError::from)
328 }
329}
330
331const DEFAULT_CA_PATHS: &[&str] = &[
336 "/etc/ssl/cert.pem", "/opt/homebrew/etc/openssl@3/cert.pem", "/usr/local/etc/openssl@3/cert.pem", "/opt/homebrew/etc/openssl/cert.pem", "/usr/local/etc/openssl/cert.pem", "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/ca-bundle.pem", ];
345
346pub(crate) fn default_sslmode(host: &str) -> SslMode {
353 if is_loopback_host(host) {
354 SslMode::Prefer
355 } else {
356 SslMode::Require
357 }
358}
359
360pub(crate) fn is_loopback_host(host: &str) -> bool {
366 if host == "localhost" {
367 return true;
368 }
369 let unbracketed = host
370 .strip_prefix('[')
371 .and_then(|s| s.strip_suffix(']'))
372 .unwrap_or(host);
373 if let Ok(ip) = unbracketed.parse::<std::net::IpAddr>() {
374 return ip.is_loopback();
375 }
376 false
377}
378
379fn tokio_ssl_mode(mode: SslMode) -> tokio_postgres::config::SslMode {
380 use tokio_postgres::config::SslMode as TokioMode;
381 match mode {
382 SslMode::Disable => TokioMode::Disable,
383 SslMode::Prefer => TokioMode::Prefer,
384 SslMode::Require | SslMode::VerifyCa | SslMode::VerifyFull => TokioMode::Require,
385 }
386}
387
388#[derive(Debug)]
390enum HostCheck {
391 Dns(String),
393 Ip(std::net::IpAddr),
395}
396
397#[derive(Debug)]
400enum ConnectorSpec {
401 NoTls,
402 Tls {
403 verify: openssl::ssl::SslVerifyMode,
404 host_check: Option<HostCheck>,
405 ca_source: CaSource,
406 },
407}
408
409#[derive(Debug)]
412enum CaSource {
413 None,
415 Explicit(std::path::PathBuf),
417 Hunted(std::path::PathBuf),
419 DefaultVerifyPaths,
423}
424
425enum Connector {
427 NoTls,
428 Tls(postgres_openssl::MakeTlsConnector),
429}
430
431impl std::fmt::Debug for Connector {
432 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433 match self {
434 Connector::NoTls => write!(f, "Connector::NoTls"),
435 Connector::Tls(_) => write!(f, "Connector::Tls(...)"),
436 }
437 }
438}
439
440fn plan_connector(
450 mode: SslMode,
451 sslrootcert: Option<&std::path::Path>,
452 host: &str,
453 hunt_candidates: &[&std::path::Path],
454 ca_exists: impl Fn(&std::path::Path) -> bool,
455) -> Result<ConnectorSpec, ConnectionError> {
456 use openssl::ssl::SslVerifyMode;
457
458 match mode {
459 SslMode::Disable => Ok(ConnectorSpec::NoTls),
460 SslMode::Prefer | SslMode::Require => Ok(ConnectorSpec::Tls {
461 verify: SslVerifyMode::NONE,
462 host_check: None,
463 ca_source: CaSource::None,
464 }),
465 SslMode::VerifyCa | SslMode::VerifyFull => {
466 let ca_source = resolve_ca_source(sslrootcert, hunt_candidates, ca_exists)?;
467 let host_check = if matches!(mode, SslMode::VerifyFull) {
468 Some(match host.parse::<std::net::IpAddr>() {
469 Ok(ip) => HostCheck::Ip(ip),
470 Err(_) => HostCheck::Dns(host.to_string()),
471 })
472 } else {
473 None
474 };
475 Ok(ConnectorSpec::Tls {
476 verify: SslVerifyMode::PEER,
477 host_check,
478 ca_source,
479 })
480 }
481 }
482}
483
484fn resolve_ca_source(
485 explicit: Option<&std::path::Path>,
486 hunt_candidates: &[&std::path::Path],
487 ca_exists: impl Fn(&std::path::Path) -> bool,
488) -> Result<CaSource, ConnectionError> {
489 if let Some(path) = explicit {
490 if ca_exists(path) {
491 return Ok(CaSource::Explicit(path.to_path_buf()));
492 } else {
493 return Err(ConnectionError::TlsCaNotFound);
494 }
495 }
496 for candidate in hunt_candidates {
497 if ca_exists(candidate) {
498 return Ok(CaSource::Hunted(candidate.to_path_buf()));
499 }
500 }
501 Ok(CaSource::DefaultVerifyPaths)
502}
503
504fn build_connector(spec: ConnectorSpec) -> Result<Connector, ConnectionError> {
507 use openssl::ssl::{SslConnector, SslMethod};
508
509 match spec {
510 ConnectorSpec::NoTls => Ok(Connector::NoTls),
511 ConnectorSpec::Tls {
512 verify,
513 host_check,
514 ca_source,
515 } => {
516 let mut builder = SslConnector::builder(SslMethod::tls()).map_err(|e| {
517 ConnectionError::Message(format!("Failed to create TLS builder: {}", e))
518 })?;
519
520 match ca_source {
521 CaSource::None => {}
522 CaSource::Explicit(path) | CaSource::Hunted(path) => {
523 builder
524 .set_ca_file(&path)
525 .map_err(|_| ConnectionError::TlsCaNotFound)?;
526 }
527 CaSource::DefaultVerifyPaths => {
528 builder
529 .set_default_verify_paths()
530 .map_err(|_| ConnectionError::TlsCaNotFound)?;
531 }
532 }
533
534 builder.set_verify(verify);
535
536 if let Some(check) = host_check {
537 let param = builder.verify_param_mut();
538 match check {
539 HostCheck::Dns(name) => {
540 param
541 .set_host(&name)
542 .map_err(|e| ConnectionError::Message(format!("{}", e)))?;
543 }
544 HostCheck::Ip(ip) => {
545 param
546 .set_ip(ip)
547 .map_err(|e| ConnectionError::Message(format!("{}", e)))?;
548 }
549 }
550 }
551
552 Ok(Connector::Tls(postgres_openssl::MakeTlsConnector::new(
553 builder.build(),
554 )))
555 }
556 }
557}
558
559fn classify_connect_error(
570 source: tokio_postgres::Error,
571 profile: &Profile,
572 mode: SslMode,
573) -> ConnectionError {
574 let host = profile.host.clone().unwrap_or_default();
577 if matches!(mode, SslMode::VerifyCa | SslMode::VerifyFull) {
578 if let Some(ssl_msg) = ssl_error_in_chain(&source) {
579 let hostname_suffix = if ssl_msg.contains("hostname mismatch")
580 || ssl_msg.contains("Hostname mismatch")
581 || ssl_msg.contains("IP address mismatch")
582 {
583 " (hostname mismatch)"
584 } else {
585 ""
586 };
587 return ConnectionError::TlsVerification {
588 host,
589 port: profile.port,
590 hostname_suffix,
591 source,
592 };
593 }
594 }
595
596 if matches!(
597 mode,
598 SslMode::Require | SslMode::VerifyCa | SslMode::VerifyFull
599 ) && message_indicates_tls_refused(&source)
600 {
601 return ConnectionError::TlsRequiredNotSupported {
602 host,
603 port: profile.port,
604 source,
605 };
606 }
607
608 ConnectionError::Connect {
609 host,
610 port: profile.port,
611 source,
612 }
613}
614
615fn ssl_error_in_chain(err: &tokio_postgres::Error) -> Option<String> {
618 let mut cur: &(dyn std::error::Error + 'static) = err;
619 while let Some(source) = std::error::Error::source(cur) {
620 if source.is::<openssl::error::ErrorStack>() {
621 return Some(source.to_string());
622 }
623 cur = source;
624 }
625 None
626}
627
628fn message_indicates_tls_refused(err: &tokio_postgres::Error) -> bool {
634 matches_tls_refused_message(&err.to_string())
635}
636
637fn matches_tls_refused_message(msg: &str) -> bool {
644 msg.contains("TLS was required")
645 || msg.contains("server does not support TLS")
646 || msg.contains("server does not support SSL")
647}
648
649fn escape_options_value(value: &str) -> String {
656 let mut out = String::with_capacity(value.len());
657 for c in value.chars() {
658 match c {
659 '\\' => out.push_str(r"\\"),
660 ' ' => out.push_str(r"\ "),
661 other => out.push(other),
662 }
663 }
664 out
665}
666
667pub(crate) fn build_options_string(options: &BTreeMap<String, String>) -> Option<String> {
674 if options.is_empty() {
675 return None;
676 }
677 let joined = options
678 .iter()
679 .map(|(k, v)| format!("-c {k}={}", escape_options_value(v)))
680 .collect::<Vec<_>>()
681 .join(" ");
682 Some(joined)
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688
689 #[mz_ore::test]
690 fn test_escape_options_value_plain() {
691 assert_eq!(escape_options_value("prod"), "prod");
692 }
693
694 #[mz_ore::test]
695 fn test_escape_options_value_space() {
696 assert_eq!(escape_options_value("prod cluster"), r"prod\ cluster");
697 }
698
699 #[mz_ore::test]
700 fn test_escape_options_value_backslash() {
701 assert_eq!(escape_options_value(r"a\b"), r"a\\b");
702 }
703
704 #[mz_ore::test]
705 fn test_escape_options_value_mixed() {
706 assert_eq!(escape_options_value(r"a \b"), r"a\ \\b");
708 }
709
710 #[mz_ore::test]
711 fn test_build_options_string_empty() {
712 let options: BTreeMap<String, String> = BTreeMap::new();
713 assert_eq!(build_options_string(&options), None);
714 }
715
716 #[mz_ore::test]
717 fn test_build_options_string_single() {
718 let mut options = BTreeMap::new();
719 options.insert("cluster".to_string(), "prod".to_string());
720 assert_eq!(
721 build_options_string(&options),
722 Some("-c cluster=prod".to_string())
723 );
724 }
725
726 #[mz_ore::test]
727 fn test_build_options_string_multiple_sorted() {
728 let mut options = BTreeMap::new();
729 options.insert("search_path".to_string(), "public".to_string());
731 options.insert("cluster".to_string(), "prod".to_string());
732 assert_eq!(
733 build_options_string(&options),
734 Some("-c cluster=prod -c search_path=public".to_string())
735 );
736 }
737
738 #[mz_ore::test]
739 fn test_build_options_string_escapes_value_space() {
740 let mut options = BTreeMap::new();
741 options.insert("cluster".to_string(), "prod cluster".to_string());
742 assert_eq!(
743 build_options_string(&options),
744 Some(r"-c cluster=prod\ cluster".to_string())
745 );
746 }
747
748 #[mz_ore::test]
749 fn test_build_options_string_escapes_value_backslash() {
750 let mut options = BTreeMap::new();
751 options.insert("cluster".to_string(), r"a\b".to_string());
752 assert_eq!(
753 build_options_string(&options),
754 Some(r"-c cluster=a\\b".to_string())
755 );
756 }
757
758 use std::path::Path;
759
760 #[mz_ore::test]
761 fn plan_disable_produces_notls() {
762 let spec = plan_connector(SslMode::Disable, None, "example.com", &[], |_| false).unwrap();
763 assert!(matches!(spec, ConnectorSpec::NoTls));
764 }
765
766 #[mz_ore::test]
767 fn plan_prefer_and_require_have_verify_none_and_no_ca() {
768 for mode in [SslMode::Prefer, SslMode::Require] {
769 let spec = plan_connector(mode, None, "example.com", &[], |_| true).unwrap();
770 match spec {
771 ConnectorSpec::Tls {
772 verify,
773 host_check,
774 ca_source,
775 } => {
776 assert_eq!(verify, openssl::ssl::SslVerifyMode::NONE);
777 assert!(host_check.is_none());
778 assert!(matches!(ca_source, CaSource::None));
779 }
780 ConnectorSpec::NoTls => panic!("expected Tls for {:?}, got NoTls", mode),
781 }
782 }
783 }
784
785 #[mz_ore::test]
786 fn plan_verify_ca_has_peer_verify_no_host_check() {
787 let spec = plan_connector(
788 SslMode::VerifyCa,
789 None,
790 "example.com",
791 &[Path::new("/does/not/exist"), Path::new("/tmp/fake-ca.pem")],
792 |p| p == Path::new("/tmp/fake-ca.pem"),
793 )
794 .unwrap();
795 match spec {
796 ConnectorSpec::Tls {
797 verify,
798 host_check,
799 ca_source,
800 } => {
801 assert_eq!(verify, openssl::ssl::SslVerifyMode::PEER);
802 assert!(host_check.is_none());
803 assert!(
804 matches!(ca_source, CaSource::Hunted(p) if p == Path::new("/tmp/fake-ca.pem"))
805 );
806 }
807 ConnectorSpec::NoTls => panic!("expected Tls, got NoTls"),
808 }
809 }
810
811 #[mz_ore::test]
812 fn plan_verify_full_dns_host_check() {
813 let spec = plan_connector(
814 SslMode::VerifyFull,
815 None,
816 "example.com",
817 &[Path::new("/tmp/fake-ca.pem")],
818 |_| true,
819 )
820 .unwrap();
821 match spec {
822 ConnectorSpec::Tls {
823 host_check: Some(HostCheck::Dns(ref name)),
824 ..
825 } => assert_eq!(name, "example.com"),
826 other => panic!("expected Tls with Dns host check, got {:?}", other),
827 }
828 }
829
830 #[mz_ore::test]
831 fn plan_verify_full_ip_host_check() {
832 let spec = plan_connector(
833 SslMode::VerifyFull,
834 None,
835 "10.0.0.5",
836 &[Path::new("/tmp/fake-ca.pem")],
837 |_| true,
838 )
839 .unwrap();
840 match spec {
841 ConnectorSpec::Tls {
842 host_check: Some(HostCheck::Ip(ip)),
843 ..
844 } => assert_eq!(ip, "10.0.0.5".parse::<std::net::IpAddr>().unwrap()),
845 other => panic!("expected Tls with Ip host check, got {:?}", other),
846 }
847 }
848
849 #[mz_ore::test]
850 fn plan_explicit_sslrootcert_wins_over_hunt() {
851 let explicit = std::path::PathBuf::from("/my/ca.pem");
852 let spec = plan_connector(
853 SslMode::VerifyCa,
854 Some(&explicit),
855 "example.com",
856 &[Path::new("/tmp/should-be-ignored.pem")],
857 |p| p == explicit.as_path(),
858 )
859 .unwrap();
860 match spec {
861 ConnectorSpec::Tls {
862 ca_source: CaSource::Explicit(p),
863 ..
864 } => assert_eq!(p, explicit),
865 other => panic!("expected Tls/Explicit, got {:?}", other),
866 }
867 }
868
869 #[mz_ore::test]
870 fn plan_explicit_sslrootcert_missing_is_ca_not_found() {
871 let explicit = std::path::PathBuf::from("/no/such/file.pem");
872 let err = plan_connector(
873 SslMode::VerifyCa,
874 Some(&explicit),
875 "example.com",
876 &[Path::new("/tmp/fake-ca.pem")],
877 |_| false,
878 )
879 .unwrap_err();
880 assert!(matches!(err, ConnectionError::TlsCaNotFound));
881 }
882
883 #[mz_ore::test]
884 fn plan_no_ca_sources_at_all_falls_back_to_default_verify_paths() {
885 let spec = plan_connector(
886 SslMode::VerifyFull,
887 None,
888 "example.com",
889 &[Path::new("/nope1"), Path::new("/nope2")],
890 |_| false,
891 )
892 .unwrap();
893 match spec {
894 ConnectorSpec::Tls {
895 ca_source: CaSource::DefaultVerifyPaths,
896 ..
897 } => {}
898 other => panic!("expected Tls/DefaultVerifyPaths, got {:?}", other),
899 }
900 }
901
902 #[mz_ore::test]
903 fn build_disable_returns_notls() {
904 let connector = build_connector(ConnectorSpec::NoTls).unwrap();
905 assert!(matches!(connector, Connector::NoTls));
906 }
907
908 #[cfg_attr(miri, ignore)] #[mz_ore::test]
910 fn build_prefer_returns_tls_no_ca_work() {
911 let connector = build_connector(ConnectorSpec::Tls {
912 verify: openssl::ssl::SslVerifyMode::NONE,
913 host_check: None,
914 ca_source: CaSource::None,
915 })
916 .unwrap();
917 assert!(matches!(connector, Connector::Tls(_)));
918 }
919
920 #[cfg_attr(miri, ignore)] #[mz_ore::test]
922 fn build_explicit_missing_ca_returns_ca_not_found() {
923 let err = build_connector(ConnectorSpec::Tls {
924 verify: openssl::ssl::SslVerifyMode::PEER,
925 host_check: None,
926 ca_source: CaSource::Explicit(std::path::PathBuf::from("/absolutely/not/a/real/file")),
927 })
928 .unwrap_err();
929 assert!(matches!(err, ConnectionError::TlsCaNotFound));
930 }
931
932 #[mz_ore::test]
933 fn matches_tls_refused_tls_was_required() {
934 assert!(matches_tls_refused_message(
935 "some prefix: TLS was required but not provided"
936 ));
937 }
938
939 #[mz_ore::test]
940 fn matches_tls_refused_does_not_support_tls() {
941 assert!(matches_tls_refused_message(
942 "error: server does not support TLS"
943 ));
944 }
945
946 #[mz_ore::test]
947 fn matches_tls_refused_does_not_support_ssl() {
948 assert!(matches_tls_refused_message(
949 "error: server does not support SSL"
950 ));
951 }
952
953 #[mz_ore::test]
954 fn matches_tls_refused_unrelated_message() {
955 assert!(!matches_tls_refused_message("connection refused"));
956 assert!(!matches_tls_refused_message("database does not exist"));
957 assert!(!matches_tls_refused_message(""));
958 }
959}