Skip to main content

mz_deploy/client/
connection.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Database client for mz-deploy.
11//!
12//! This module provides the main `Client` struct for interacting with Materialize.
13//! The client handles connection management and delegates specialized operations
14//! to domain-specific sub-clients.
15//!
16//! ## Sub-Client Architecture
17//!
18//! Operations are grouped into domain sub-clients accessed via accessor methods
19//! on `Client`. Each sub-client borrows the `Client` and provides a focused API:
20//!
21//! | Sub-client | Accessor | Responsibility |
22//! |------------|----------|---------------|
23//! | `DeploymentsClient` | `.deployments()` | Deployment lifecycle (stage, promote, abort) |
24//! | `DeploymentsClientMut` | `.deployments_mut()` | Mutable deployment ops (SUBSCRIBE cursors) |
25//! | `IntrospectionClient` | `.introspection()` | Read-only catalog metadata queries |
26//! | `ValidationClient` | `.validation()` | Pre-deployment environment checks |
27//! | `TypeInfoClient` | `.types()` | Column/type introspection for type checking |
28//! | `ProvisioningClient` | `.provisioning()` | Idempotent DDL for databases, schemas, clusters |
29//!
30//! ## TLS Policy
31//!
32//! Per-profile `sslmode` with libpq semantics (`disable`, `prefer`, `require`,
33//! `verify-ca`, `verify-full`). When unset, loopback hosts default to
34//! `prefer` and everything else defaults to `require`. See the design at
35//! `docs/superpowers/specs/2026-04-22-profile-tls-design.md` for the behavior
36//! table and migration notes.
37
38use 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
46/// Database client for interacting with Materialize.
47///
48/// The `Client` struct provides methods for:
49/// - Connecting to the database
50/// - Schema and cluster management
51/// - Deployment tracking
52/// - Database introspection
53/// - Project validation
54pub struct Client {
55    client: PgClient,
56    profile: Profile,
57    default_replication_factor: std::sync::OnceLock<u32>,
58}
59
60/// Domain sub-client for deployment lifecycle operations.
61pub struct DeploymentsClient<'a> {
62    pub(crate) client: &'a Client,
63}
64
65/// Domain sub-client for deployment operations that require mutable client access.
66pub struct DeploymentsClientMut<'a> {
67    pub(crate) client: &'a mut Client,
68}
69
70/// Domain sub-client for metadata and object introspection operations.
71pub struct IntrospectionClient<'a> {
72    pub(crate) client: &'a Client,
73}
74
75/// Domain sub-client for project and privilege validation operations.
76pub struct ValidationClient<'a> {
77    pub(crate) client: &'a Client,
78}
79
80/// Domain sub-client for column/type introspection used by type checking and tests.
81pub struct TypeInfoClient<'a> {
82    pub(crate) client: &'a Client,
83}
84
85/// Domain sub-client for provisioning databases, schemas, and clusters.
86pub struct ProvisioningClient<'a> {
87    pub(crate) client: &'a Client,
88}
89
90/// Domain sub-client for developer overlay manifest operations.
91pub struct DevOverlaysClient<'a> {
92    pub(crate) client: &'a Client,
93}
94
95const APPLICATION_NAME: &str = "mz-deploy";
96
97impl Client {
98    /// Connect to the database using a Profile directly.
99    ///
100    /// TLS behavior is driven by `profile.sslmode`; when unset, loopback hosts
101    /// default to `prefer` and everything else defaults to `require`. Verification
102    /// (`verify-ca` / `verify-full`) sources CAs from `profile.sslrootcert`, then
103    /// the platform CA hunt, then OpenSSL's compiled-in defaults.
104    ///
105    /// Every connection is pinned to `_mz_deploy_server` via libpq options;
106    /// any user-supplied `cluster` in profile.options is silently overridden.
107    /// The unit-test runtime uses `connect_with_profile_no_pin` instead —
108    /// its ephemeral Docker container has no `_mz_deploy_server` cluster.
109    pub async fn connect_with_profile(profile: Profile) -> Result<Self, ConnectionError> {
110        Self::connect_with_profile_inner(profile, /* pin_server_cluster */ true).await
111    }
112
113    /// Connect without pinning the session cluster to `_mz_deploy_server`.
114    ///
115    /// Used in two places where `_mz_deploy_server` is not yet (or never)
116    /// present:
117    /// - The ephemeral Docker container used by unit-test execution.
118    /// - `setup::run`, which is the command that creates the cluster.
119    ///
120    /// Uses whatever cluster the profile or server default selects.
121    /// Deliberately `pub(crate)` so nothing outside the crate can bypass
122    /// the production session-cluster pin.
123    pub(crate) async fn connect_with_profile_no_pin(
124        profile: Profile,
125    ) -> Result<Self, ConnectionError> {
126        Self::connect_with_profile_inner(profile, /* pin_server_cluster */ 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        // `config.connect(NoTls)` and `config.connect(tls)` return `Connection`s
166        // parameterized over different TLS stream types that can't unify. We box
167        // both to a common `dyn Future` so there's a single spawn site below.
168        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    /// The replication factor the server gives a managed cluster whose
201    /// definition omits `REPLICATION FACTOR`.
202    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    /// Get the profile used for this connection.
221    pub fn profile(&self) -> &Profile {
222        &self.profile
223    }
224
225    /// Start a transaction on the underlying connection.
226    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    /// Access deployment lifecycle operations.
234    pub fn deployments(&self) -> DeploymentsClient<'_> {
235        DeploymentsClient { client: self }
236    }
237
238    /// Access mutable deployment lifecycle operations.
239    pub fn deployments_mut(&mut self) -> DeploymentsClientMut<'_> {
240        DeploymentsClientMut { client: self }
241    }
242
243    /// Access metadata and object introspection operations.
244    pub fn introspection(&self) -> IntrospectionClient<'_> {
245        IntrospectionClient { client: self }
246    }
247
248    /// Access database validation operations.
249    pub fn validation(&self) -> ValidationClient<'_> {
250        ValidationClient { client: self }
251    }
252
253    /// Access type/column introspection operations.
254    pub fn types(&self) -> TypeInfoClient<'_> {
255        TypeInfoClient { client: self }
256    }
257
258    /// Access provisioning operations for databases, schemas, and clusters.
259    pub fn provisioning(&self) -> ProvisioningClient<'_> {
260        ProvisioningClient { client: self }
261    }
262
263    /// Access developer overlay manifest operations.
264    pub fn dev_overlays(&self) -> DevOverlaysClient<'_> {
265        DevOverlaysClient { client: self }
266    }
267
268    /// Execute a SQL statement that doesn't return rows.
269    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    /// Execute a SQL query and return the resulting rows.
284    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    /// Execute a SQL query and return the resulting rows.
299    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    /// Execute a SQL statement using the simple query protocol (text-only, no binary encoding).
314    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    /// Execute one or more SQL statements that don't return rows, using the simple query protocol.
324    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
331/// Platform CA bundle candidates, walked in order by `build_connector` when
332/// `sslmode` resolves to `verify-ca` / `verify-full` and the profile does not
333/// set `sslrootcert`. Kept in sync with libpq-like installations on our
334/// supported platforms.
335const DEFAULT_CA_PATHS: &[&str] = &[
336    "/etc/ssl/cert.pem",                    // macOS system
337    "/opt/homebrew/etc/openssl@3/cert.pem", // macOS Homebrew ARM
338    "/usr/local/etc/openssl@3/cert.pem",    // macOS Homebrew Intel
339    "/opt/homebrew/etc/openssl/cert.pem",   // macOS Homebrew ARM (older)
340    "/usr/local/etc/openssl/cert.pem",      // macOS Homebrew Intel (older)
341    "/etc/ssl/certs/ca-certificates.crt",   // Debian/Ubuntu
342    "/etc/pki/tls/certs/ca-bundle.crt",     // RHEL/CentOS
343    "/etc/ssl/ca-bundle.pem",               // OpenSUSE
344];
345
346/// The default `SslMode` applied when a profile does not set `sslmode`.
347///
348/// Loopback hosts get `Prefer` so local Mz (which does not offer TLS) works
349/// without explicit config. Everything else gets `Require` — TLS is required
350/// but certificate verification is not. Users who want verification set
351/// `sslmode = "verify-ca"` or `sslmode = "verify-full"` explicitly.
352pub(crate) fn default_sslmode(host: &str) -> SslMode {
353    if is_loopback_host(host) {
354        SslMode::Prefer
355    } else {
356        SslMode::Require
357    }
358}
359
360/// Returns `true` if `host` names the loopback interface.
361///
362/// Recognizes `localhost`, any address in `127.0.0.0/8`, and `::1` (with or
363/// without URL-style brackets). Used by the SQL TLS defaults and by
364/// `mz-deploy mcp` to pick `http://` vs `https://`.
365pub(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/// How `verify-full` should match the cert's SAN entries.
389#[derive(Debug)]
390enum HostCheck {
391    /// Match a DNS name. Host parsed as a non-IP string.
392    Dns(String),
393    /// Match an IPv4 or IPv6 literal. Host parsed as `IpAddr`.
394    Ip(std::net::IpAddr),
395}
396
397/// Pure-data representation of the TLS setup for a connection, derived from
398/// a profile's effective `SslMode` and `sslrootcert`.
399#[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/// Where the CA bundle comes from for verifying the server cert, or the
410/// absence thereof for non-verifying modes.
411#[derive(Debug)]
412enum CaSource {
413    /// `disable` / `prefer` / `require` — no CA is loaded.
414    None,
415    /// Explicit path from the profile's `sslrootcert` field.
416    Explicit(std::path::PathBuf),
417    /// Path discovered by walking `DEFAULT_CA_PATHS`.
418    Hunted(std::path::PathBuf),
419    /// Fallback to OpenSSL's compiled-in default verify paths
420    /// (`set_default_verify_paths`). Used only when the hunt finds nothing
421    /// and no explicit path is set.
422    DefaultVerifyPaths,
423}
424
425/// Runtime-ready connector variant handed to `tokio_postgres::Config::connect`.
426enum 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
440/// Plan the TLS setup for a connection from the resolved (mode, CA) inputs.
441///
442/// Pure: does no network I/O and — aside from the injected `ca_exists`
443/// predicate — does no filesystem I/O. Returns a [`ConnectorSpec`] that
444/// [`build_connector`] then materializes into an OpenSSL context.
445///
446/// `hunt_candidates` is the ordered list of default CA paths to probe when
447/// `sslrootcert` is not set. In production this is [`DEFAULT_CA_PATHS`];
448/// tests pass their own list plus a stubbed `ca_exists` predicate.
449fn 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
504/// Convert a [`ConnectorSpec`] into a runtime [`Connector`] by wiring up the
505/// OpenSSL context. All filesystem I/O for CAs happens here.
506fn 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
559/// Classify a `tokio_postgres::Error` surfaced from `Config::connect(...)`
560/// into the most specific `ConnectionError` variant.
561///
562/// Rules:
563/// - OpenSSL error found in the source chain + `mode` is `verify-*` →
564///   [`ConnectionError::TlsVerification`] (with `hostname_suffix` if the
565///   OpenSSL message names a hostname / IP mismatch).
566/// - `mode` is `require` / `verify-*` and the error message indicates the
567///   server refused TLS → [`ConnectionError::TlsRequiredNotSupported`].
568/// - Otherwise → [`ConnectionError::Connect`].
569fn classify_connect_error(
570    source: tokio_postgres::Error,
571    profile: &Profile,
572    mode: SslMode,
573) -> ConnectionError {
574    // Caller has already gone through `require_host()` to attempt the
575    // connection that produced this error, so `host` must be `Some` here.
576    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
615/// Walk the source chain of a `tokio_postgres::Error` and return the string
616/// form of the first `openssl::error::ErrorStack` found.
617fn 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
628/// Heuristic: does the error look like "server said no to our TLS request"?
629///
630/// `tokio_postgres` surfaces this as an io error or a "server does not
631/// support TLS" message depending on version. We string-match the Display
632/// form because the typed variants are not all public.
633fn message_indicates_tls_refused(err: &tokio_postgres::Error) -> bool {
634    matches_tls_refused_message(&err.to_string())
635}
636
637/// Pure string check for the substrings `tokio_postgres` produces when the
638/// server refuses the TLS startup request (responds `'N'` to the SSL byte).
639///
640/// Extracted from `message_indicates_tls_refused` so we can unit-test the
641/// substring list — the caller takes `&tokio_postgres::Error`, which has no
642/// public constructor.
643fn 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
649/// Escape a value for embedding inside the libpq `options` connection
650/// parameter.
651///
652/// Within the `options` string, spaces separate `-c key=value` tokens unless
653/// escaped, and backslash is the escape character. Only spaces and backslashes
654/// are special; all other characters are literal.
655fn 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
667/// Build the inner value of the libpq `options` connection parameter from a
668/// profile's options map.
669///
670/// Produces a space-separated string of `-c key=value` tokens in sorted-key
671/// order, with each value inner-escaped per [`escape_options_value`].
672/// Returns `None` when the map is empty so the caller can omit the fragment.
673pub(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        // Space then backslash
707        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        // Insert in reverse order to verify BTreeMap iteration sorts keys.
730        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)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
909    #[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)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
921    #[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}