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    auto_scaling_support: std::sync::OnceLock<bool>,
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            auto_scaling_support: std::sync::OnceLock::new(),
197        })
198    }
199
200    /// Whether the region exposes the autoscaling-strategy introspection view.
201    pub(crate) async fn supports_auto_scaling_strategies(&self) -> Result<bool, ConnectionError> {
202        if let Some(supported) = self.auto_scaling_support.get() {
203            return Ok(*supported);
204        }
205        let row = self
206            .query_one(
207                r#"
208                SELECT EXISTS(
209                    SELECT 1
210                    FROM mz_catalog.mz_objects AS o
211                    JOIN mz_catalog.mz_schemas AS s ON o.schema_id = s.id
212                    WHERE o.name = 'mz_cluster_auto_scaling_strategies'
213                      AND o.type = 'materialized-view'
214                      AND s.name = 'mz_internal'
215                ) AS exists
216                "#,
217                &[],
218            )
219            .await?;
220        let supported: bool = row.get("exists");
221        let _ = self.auto_scaling_support.set(supported);
222        Ok(supported)
223    }
224
225    /// Get the profile used for this connection.
226    pub fn profile(&self) -> &Profile {
227        &self.profile
228    }
229
230    /// Start a transaction on the underlying connection.
231    pub(crate) async fn begin_transaction(&mut self) -> Result<Transaction<'_>, ConnectionError> {
232        self.client
233            .transaction()
234            .await
235            .map_err(ConnectionError::Query)
236    }
237
238    /// Access deployment lifecycle operations.
239    pub fn deployments(&self) -> DeploymentsClient<'_> {
240        DeploymentsClient { client: self }
241    }
242
243    /// Access mutable deployment lifecycle operations.
244    pub fn deployments_mut(&mut self) -> DeploymentsClientMut<'_> {
245        DeploymentsClientMut { client: self }
246    }
247
248    /// Access metadata and object introspection operations.
249    pub fn introspection(&self) -> IntrospectionClient<'_> {
250        IntrospectionClient { client: self }
251    }
252
253    /// Access database validation operations.
254    pub fn validation(&self) -> ValidationClient<'_> {
255        ValidationClient { client: self }
256    }
257
258    /// Access type/column introspection operations.
259    pub fn types(&self) -> TypeInfoClient<'_> {
260        TypeInfoClient { client: self }
261    }
262
263    /// Access provisioning operations for databases, schemas, and clusters.
264    pub fn provisioning(&self) -> ProvisioningClient<'_> {
265        ProvisioningClient { client: self }
266    }
267
268    /// Access developer overlay manifest operations.
269    pub fn dev_overlays(&self) -> DevOverlaysClient<'_> {
270        DevOverlaysClient { client: self }
271    }
272
273    /// Execute a SQL statement that doesn't return rows.
274    pub async fn execute(
275        &self,
276        statement: &str,
277        params: &[&(dyn ToSql + Sync)],
278    ) -> Result<u64, ConnectionError> {
279        mz_postgres_util::execute(
280            &self.client,
281            Sql::raw_unchecked(statement.to_string()),
282            params,
283        )
284        .await
285        .map_err(ConnectionError::from)
286    }
287
288    /// Execute a SQL query and return the resulting rows.
289    pub async fn query_one(
290        &self,
291        statement: &str,
292        params: &[&(dyn ToSql + Sync)],
293    ) -> Result<Row, ConnectionError> {
294        mz_postgres_util::query_one(
295            &self.client,
296            Sql::raw_unchecked(statement.to_string()),
297            params,
298        )
299        .await
300        .map_err(ConnectionError::from)
301    }
302
303    /// Execute a SQL query and return the resulting rows.
304    pub async fn query(
305        &self,
306        statement: &str,
307        params: &[&(dyn ToSql + Sync)],
308    ) -> Result<Vec<Row>, ConnectionError> {
309        mz_postgres_util::query(
310            &self.client,
311            Sql::raw_unchecked(statement.to_string()),
312            params,
313        )
314        .await
315        .map_err(ConnectionError::from)
316    }
317
318    /// Execute a SQL statement using the simple query protocol (text-only, no binary encoding).
319    pub async fn simple_query(
320        &self,
321        query: &str,
322    ) -> Result<Vec<SimpleQueryMessage>, ConnectionError> {
323        mz_postgres_util::simple_query(&self.client, Sql::raw_unchecked(query.to_string()))
324            .await
325            .map_err(ConnectionError::from)
326    }
327
328    /// Execute one or more SQL statements that don't return rows, using the simple query protocol.
329    pub async fn batch_execute(&self, query: &str) -> Result<(), ConnectionError> {
330        mz_postgres_util::batch_execute(&self.client, Sql::raw_unchecked(query.to_string()))
331            .await
332            .map_err(ConnectionError::from)
333    }
334}
335
336/// Platform CA bundle candidates, walked in order by `build_connector` when
337/// `sslmode` resolves to `verify-ca` / `verify-full` and the profile does not
338/// set `sslrootcert`. Kept in sync with libpq-like installations on our
339/// supported platforms.
340const DEFAULT_CA_PATHS: &[&str] = &[
341    "/etc/ssl/cert.pem",                    // macOS system
342    "/opt/homebrew/etc/openssl@3/cert.pem", // macOS Homebrew ARM
343    "/usr/local/etc/openssl@3/cert.pem",    // macOS Homebrew Intel
344    "/opt/homebrew/etc/openssl/cert.pem",   // macOS Homebrew ARM (older)
345    "/usr/local/etc/openssl/cert.pem",      // macOS Homebrew Intel (older)
346    "/etc/ssl/certs/ca-certificates.crt",   // Debian/Ubuntu
347    "/etc/pki/tls/certs/ca-bundle.crt",     // RHEL/CentOS
348    "/etc/ssl/ca-bundle.pem",               // OpenSUSE
349];
350
351/// The default `SslMode` applied when a profile does not set `sslmode`.
352///
353/// Loopback hosts get `Prefer` so local Mz (which does not offer TLS) works
354/// without explicit config. Everything else gets `Require` — TLS is required
355/// but certificate verification is not. Users who want verification set
356/// `sslmode = "verify-ca"` or `sslmode = "verify-full"` explicitly.
357pub(crate) fn default_sslmode(host: &str) -> SslMode {
358    if is_loopback_host(host) {
359        SslMode::Prefer
360    } else {
361        SslMode::Require
362    }
363}
364
365/// Returns `true` if `host` names the loopback interface.
366///
367/// Recognizes `localhost`, any address in `127.0.0.0/8`, and `::1` (with or
368/// without URL-style brackets). Used by the SQL TLS defaults and by
369/// `mz-deploy mcp` to pick `http://` vs `https://`.
370pub(crate) fn is_loopback_host(host: &str) -> bool {
371    if host == "localhost" {
372        return true;
373    }
374    let unbracketed = host
375        .strip_prefix('[')
376        .and_then(|s| s.strip_suffix(']'))
377        .unwrap_or(host);
378    if let Ok(ip) = unbracketed.parse::<std::net::IpAddr>() {
379        return ip.is_loopback();
380    }
381    false
382}
383
384fn tokio_ssl_mode(mode: SslMode) -> tokio_postgres::config::SslMode {
385    use tokio_postgres::config::SslMode as TokioMode;
386    match mode {
387        SslMode::Disable => TokioMode::Disable,
388        SslMode::Prefer => TokioMode::Prefer,
389        SslMode::Require | SslMode::VerifyCa | SslMode::VerifyFull => TokioMode::Require,
390    }
391}
392
393/// How `verify-full` should match the cert's SAN entries.
394#[derive(Debug)]
395enum HostCheck {
396    /// Match a DNS name. Host parsed as a non-IP string.
397    Dns(String),
398    /// Match an IPv4 or IPv6 literal. Host parsed as `IpAddr`.
399    Ip(std::net::IpAddr),
400}
401
402/// Pure-data representation of the TLS setup for a connection, derived from
403/// a profile's effective `SslMode` and `sslrootcert`.
404#[derive(Debug)]
405enum ConnectorSpec {
406    NoTls,
407    Tls {
408        verify: openssl::ssl::SslVerifyMode,
409        host_check: Option<HostCheck>,
410        ca_source: CaSource,
411    },
412}
413
414/// Where the CA bundle comes from for verifying the server cert, or the
415/// absence thereof for non-verifying modes.
416#[derive(Debug)]
417enum CaSource {
418    /// `disable` / `prefer` / `require` — no CA is loaded.
419    None,
420    /// Explicit path from the profile's `sslrootcert` field.
421    Explicit(std::path::PathBuf),
422    /// Path discovered by walking `DEFAULT_CA_PATHS`.
423    Hunted(std::path::PathBuf),
424    /// Fallback to OpenSSL's compiled-in default verify paths
425    /// (`set_default_verify_paths`). Used only when the hunt finds nothing
426    /// and no explicit path is set.
427    DefaultVerifyPaths,
428}
429
430/// Runtime-ready connector variant handed to `tokio_postgres::Config::connect`.
431enum Connector {
432    NoTls,
433    Tls(postgres_openssl::MakeTlsConnector),
434}
435
436impl std::fmt::Debug for Connector {
437    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438        match self {
439            Connector::NoTls => write!(f, "Connector::NoTls"),
440            Connector::Tls(_) => write!(f, "Connector::Tls(...)"),
441        }
442    }
443}
444
445/// Plan the TLS setup for a connection from the resolved (mode, CA) inputs.
446///
447/// Pure: does no network I/O and — aside from the injected `ca_exists`
448/// predicate — does no filesystem I/O. Returns a [`ConnectorSpec`] that
449/// [`build_connector`] then materializes into an OpenSSL context.
450///
451/// `hunt_candidates` is the ordered list of default CA paths to probe when
452/// `sslrootcert` is not set. In production this is [`DEFAULT_CA_PATHS`];
453/// tests pass their own list plus a stubbed `ca_exists` predicate.
454fn plan_connector(
455    mode: SslMode,
456    sslrootcert: Option<&std::path::Path>,
457    host: &str,
458    hunt_candidates: &[&std::path::Path],
459    ca_exists: impl Fn(&std::path::Path) -> bool,
460) -> Result<ConnectorSpec, ConnectionError> {
461    use openssl::ssl::SslVerifyMode;
462
463    match mode {
464        SslMode::Disable => Ok(ConnectorSpec::NoTls),
465        SslMode::Prefer | SslMode::Require => Ok(ConnectorSpec::Tls {
466            verify: SslVerifyMode::NONE,
467            host_check: None,
468            ca_source: CaSource::None,
469        }),
470        SslMode::VerifyCa | SslMode::VerifyFull => {
471            let ca_source = resolve_ca_source(sslrootcert, hunt_candidates, ca_exists)?;
472            let host_check = if matches!(mode, SslMode::VerifyFull) {
473                Some(match host.parse::<std::net::IpAddr>() {
474                    Ok(ip) => HostCheck::Ip(ip),
475                    Err(_) => HostCheck::Dns(host.to_string()),
476                })
477            } else {
478                None
479            };
480            Ok(ConnectorSpec::Tls {
481                verify: SslVerifyMode::PEER,
482                host_check,
483                ca_source,
484            })
485        }
486    }
487}
488
489fn resolve_ca_source(
490    explicit: Option<&std::path::Path>,
491    hunt_candidates: &[&std::path::Path],
492    ca_exists: impl Fn(&std::path::Path) -> bool,
493) -> Result<CaSource, ConnectionError> {
494    if let Some(path) = explicit {
495        if ca_exists(path) {
496            return Ok(CaSource::Explicit(path.to_path_buf()));
497        } else {
498            return Err(ConnectionError::TlsCaNotFound);
499        }
500    }
501    for candidate in hunt_candidates {
502        if ca_exists(candidate) {
503            return Ok(CaSource::Hunted(candidate.to_path_buf()));
504        }
505    }
506    Ok(CaSource::DefaultVerifyPaths)
507}
508
509/// Convert a [`ConnectorSpec`] into a runtime [`Connector`] by wiring up the
510/// OpenSSL context. All filesystem I/O for CAs happens here.
511fn build_connector(spec: ConnectorSpec) -> Result<Connector, ConnectionError> {
512    use openssl::ssl::{SslConnector, SslMethod};
513
514    match spec {
515        ConnectorSpec::NoTls => Ok(Connector::NoTls),
516        ConnectorSpec::Tls {
517            verify,
518            host_check,
519            ca_source,
520        } => {
521            let mut builder = SslConnector::builder(SslMethod::tls()).map_err(|e| {
522                ConnectionError::Message(format!("Failed to create TLS builder: {}", e))
523            })?;
524
525            match ca_source {
526                CaSource::None => {}
527                CaSource::Explicit(path) | CaSource::Hunted(path) => {
528                    builder
529                        .set_ca_file(&path)
530                        .map_err(|_| ConnectionError::TlsCaNotFound)?;
531                }
532                CaSource::DefaultVerifyPaths => {
533                    builder
534                        .set_default_verify_paths()
535                        .map_err(|_| ConnectionError::TlsCaNotFound)?;
536                }
537            }
538
539            builder.set_verify(verify);
540
541            if let Some(check) = host_check {
542                let param = builder.verify_param_mut();
543                match check {
544                    HostCheck::Dns(name) => {
545                        param
546                            .set_host(&name)
547                            .map_err(|e| ConnectionError::Message(format!("{}", e)))?;
548                    }
549                    HostCheck::Ip(ip) => {
550                        param
551                            .set_ip(ip)
552                            .map_err(|e| ConnectionError::Message(format!("{}", e)))?;
553                    }
554                }
555            }
556
557            Ok(Connector::Tls(postgres_openssl::MakeTlsConnector::new(
558                builder.build(),
559            )))
560        }
561    }
562}
563
564/// Classify a `tokio_postgres::Error` surfaced from `Config::connect(...)`
565/// into the most specific `ConnectionError` variant.
566///
567/// Rules:
568/// - OpenSSL error found in the source chain + `mode` is `verify-*` →
569///   [`ConnectionError::TlsVerification`] (with `hostname_suffix` if the
570///   OpenSSL message names a hostname / IP mismatch).
571/// - `mode` is `require` / `verify-*` and the error message indicates the
572///   server refused TLS → [`ConnectionError::TlsRequiredNotSupported`].
573/// - Otherwise → [`ConnectionError::Connect`].
574fn classify_connect_error(
575    source: tokio_postgres::Error,
576    profile: &Profile,
577    mode: SslMode,
578) -> ConnectionError {
579    // Caller has already gone through `require_host()` to attempt the
580    // connection that produced this error, so `host` must be `Some` here.
581    let host = profile.host.clone().unwrap_or_default();
582    if matches!(mode, SslMode::VerifyCa | SslMode::VerifyFull) {
583        if let Some(ssl_msg) = ssl_error_in_chain(&source) {
584            let hostname_suffix = if ssl_msg.contains("hostname mismatch")
585                || ssl_msg.contains("Hostname mismatch")
586                || ssl_msg.contains("IP address mismatch")
587            {
588                " (hostname mismatch)"
589            } else {
590                ""
591            };
592            return ConnectionError::TlsVerification {
593                host,
594                port: profile.port,
595                hostname_suffix,
596                source,
597            };
598        }
599    }
600
601    if matches!(
602        mode,
603        SslMode::Require | SslMode::VerifyCa | SslMode::VerifyFull
604    ) && message_indicates_tls_refused(&source)
605    {
606        return ConnectionError::TlsRequiredNotSupported {
607            host,
608            port: profile.port,
609            source,
610        };
611    }
612
613    ConnectionError::Connect {
614        host,
615        port: profile.port,
616        source,
617    }
618}
619
620/// Walk the source chain of a `tokio_postgres::Error` and return the string
621/// form of the first `openssl::error::ErrorStack` found.
622fn ssl_error_in_chain(err: &tokio_postgres::Error) -> Option<String> {
623    let mut cur: &(dyn std::error::Error + 'static) = err;
624    while let Some(source) = std::error::Error::source(cur) {
625        if source.is::<openssl::error::ErrorStack>() {
626            return Some(source.to_string());
627        }
628        cur = source;
629    }
630    None
631}
632
633/// Heuristic: does the error look like "server said no to our TLS request"?
634///
635/// `tokio_postgres` surfaces this as an io error or a "server does not
636/// support TLS" message depending on version. We string-match the Display
637/// form because the typed variants are not all public.
638fn message_indicates_tls_refused(err: &tokio_postgres::Error) -> bool {
639    matches_tls_refused_message(&err.to_string())
640}
641
642/// Pure string check for the substrings `tokio_postgres` produces when the
643/// server refuses the TLS startup request (responds `'N'` to the SSL byte).
644///
645/// Extracted from `message_indicates_tls_refused` so we can unit-test the
646/// substring list — the caller takes `&tokio_postgres::Error`, which has no
647/// public constructor.
648fn matches_tls_refused_message(msg: &str) -> bool {
649    msg.contains("TLS was required")
650        || msg.contains("server does not support TLS")
651        || msg.contains("server does not support SSL")
652}
653
654/// Escape a value for embedding inside the libpq `options` connection
655/// parameter.
656///
657/// Within the `options` string, spaces separate `-c key=value` tokens unless
658/// escaped, and backslash is the escape character. Only spaces and backslashes
659/// are special; all other characters are literal.
660fn escape_options_value(value: &str) -> String {
661    let mut out = String::with_capacity(value.len());
662    for c in value.chars() {
663        match c {
664            '\\' => out.push_str(r"\\"),
665            ' ' => out.push_str(r"\ "),
666            other => out.push(other),
667        }
668    }
669    out
670}
671
672/// Build the inner value of the libpq `options` connection parameter from a
673/// profile's options map.
674///
675/// Produces a space-separated string of `-c key=value` tokens in sorted-key
676/// order, with each value inner-escaped per [`escape_options_value`].
677/// Returns `None` when the map is empty so the caller can omit the fragment.
678pub(crate) fn build_options_string(options: &BTreeMap<String, String>) -> Option<String> {
679    if options.is_empty() {
680        return None;
681    }
682    let joined = options
683        .iter()
684        .map(|(k, v)| format!("-c {k}={}", escape_options_value(v)))
685        .collect::<Vec<_>>()
686        .join(" ");
687    Some(joined)
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    #[mz_ore::test]
695    fn test_escape_options_value_plain() {
696        assert_eq!(escape_options_value("prod"), "prod");
697    }
698
699    #[mz_ore::test]
700    fn test_escape_options_value_space() {
701        assert_eq!(escape_options_value("prod cluster"), r"prod\ cluster");
702    }
703
704    #[mz_ore::test]
705    fn test_escape_options_value_backslash() {
706        assert_eq!(escape_options_value(r"a\b"), r"a\\b");
707    }
708
709    #[mz_ore::test]
710    fn test_escape_options_value_mixed() {
711        // Space then backslash
712        assert_eq!(escape_options_value(r"a \b"), r"a\ \\b");
713    }
714
715    #[mz_ore::test]
716    fn test_build_options_string_empty() {
717        let options: BTreeMap<String, String> = BTreeMap::new();
718        assert_eq!(build_options_string(&options), None);
719    }
720
721    #[mz_ore::test]
722    fn test_build_options_string_single() {
723        let mut options = BTreeMap::new();
724        options.insert("cluster".to_string(), "prod".to_string());
725        assert_eq!(
726            build_options_string(&options),
727            Some("-c cluster=prod".to_string())
728        );
729    }
730
731    #[mz_ore::test]
732    fn test_build_options_string_multiple_sorted() {
733        let mut options = BTreeMap::new();
734        // Insert in reverse order to verify BTreeMap iteration sorts keys.
735        options.insert("search_path".to_string(), "public".to_string());
736        options.insert("cluster".to_string(), "prod".to_string());
737        assert_eq!(
738            build_options_string(&options),
739            Some("-c cluster=prod -c search_path=public".to_string())
740        );
741    }
742
743    #[mz_ore::test]
744    fn test_build_options_string_escapes_value_space() {
745        let mut options = BTreeMap::new();
746        options.insert("cluster".to_string(), "prod cluster".to_string());
747        assert_eq!(
748            build_options_string(&options),
749            Some(r"-c cluster=prod\ cluster".to_string())
750        );
751    }
752
753    #[mz_ore::test]
754    fn test_build_options_string_escapes_value_backslash() {
755        let mut options = BTreeMap::new();
756        options.insert("cluster".to_string(), r"a\b".to_string());
757        assert_eq!(
758            build_options_string(&options),
759            Some(r"-c cluster=a\\b".to_string())
760        );
761    }
762
763    use std::path::Path;
764
765    #[mz_ore::test]
766    fn plan_disable_produces_notls() {
767        let spec = plan_connector(SslMode::Disable, None, "example.com", &[], |_| false).unwrap();
768        assert!(matches!(spec, ConnectorSpec::NoTls));
769    }
770
771    #[mz_ore::test]
772    fn plan_prefer_and_require_have_verify_none_and_no_ca() {
773        for mode in [SslMode::Prefer, SslMode::Require] {
774            let spec = plan_connector(mode, None, "example.com", &[], |_| true).unwrap();
775            match spec {
776                ConnectorSpec::Tls {
777                    verify,
778                    host_check,
779                    ca_source,
780                } => {
781                    assert_eq!(verify, openssl::ssl::SslVerifyMode::NONE);
782                    assert!(host_check.is_none());
783                    assert!(matches!(ca_source, CaSource::None));
784                }
785                ConnectorSpec::NoTls => panic!("expected Tls for {:?}, got NoTls", mode),
786            }
787        }
788    }
789
790    #[mz_ore::test]
791    fn plan_verify_ca_has_peer_verify_no_host_check() {
792        let spec = plan_connector(
793            SslMode::VerifyCa,
794            None,
795            "example.com",
796            &[Path::new("/does/not/exist"), Path::new("/tmp/fake-ca.pem")],
797            |p| p == Path::new("/tmp/fake-ca.pem"),
798        )
799        .unwrap();
800        match spec {
801            ConnectorSpec::Tls {
802                verify,
803                host_check,
804                ca_source,
805            } => {
806                assert_eq!(verify, openssl::ssl::SslVerifyMode::PEER);
807                assert!(host_check.is_none());
808                assert!(
809                    matches!(ca_source, CaSource::Hunted(p) if p == Path::new("/tmp/fake-ca.pem"))
810                );
811            }
812            ConnectorSpec::NoTls => panic!("expected Tls, got NoTls"),
813        }
814    }
815
816    #[mz_ore::test]
817    fn plan_verify_full_dns_host_check() {
818        let spec = plan_connector(
819            SslMode::VerifyFull,
820            None,
821            "example.com",
822            &[Path::new("/tmp/fake-ca.pem")],
823            |_| true,
824        )
825        .unwrap();
826        match spec {
827            ConnectorSpec::Tls {
828                host_check: Some(HostCheck::Dns(ref name)),
829                ..
830            } => assert_eq!(name, "example.com"),
831            other => panic!("expected Tls with Dns host check, got {:?}", other),
832        }
833    }
834
835    #[mz_ore::test]
836    fn plan_verify_full_ip_host_check() {
837        let spec = plan_connector(
838            SslMode::VerifyFull,
839            None,
840            "10.0.0.5",
841            &[Path::new("/tmp/fake-ca.pem")],
842            |_| true,
843        )
844        .unwrap();
845        match spec {
846            ConnectorSpec::Tls {
847                host_check: Some(HostCheck::Ip(ip)),
848                ..
849            } => assert_eq!(ip, "10.0.0.5".parse::<std::net::IpAddr>().unwrap()),
850            other => panic!("expected Tls with Ip host check, got {:?}", other),
851        }
852    }
853
854    #[mz_ore::test]
855    fn plan_explicit_sslrootcert_wins_over_hunt() {
856        let explicit = std::path::PathBuf::from("/my/ca.pem");
857        let spec = plan_connector(
858            SslMode::VerifyCa,
859            Some(&explicit),
860            "example.com",
861            &[Path::new("/tmp/should-be-ignored.pem")],
862            |p| p == explicit.as_path(),
863        )
864        .unwrap();
865        match spec {
866            ConnectorSpec::Tls {
867                ca_source: CaSource::Explicit(p),
868                ..
869            } => assert_eq!(p, explicit),
870            other => panic!("expected Tls/Explicit, got {:?}", other),
871        }
872    }
873
874    #[mz_ore::test]
875    fn plan_explicit_sslrootcert_missing_is_ca_not_found() {
876        let explicit = std::path::PathBuf::from("/no/such/file.pem");
877        let err = plan_connector(
878            SslMode::VerifyCa,
879            Some(&explicit),
880            "example.com",
881            &[Path::new("/tmp/fake-ca.pem")],
882            |_| false,
883        )
884        .unwrap_err();
885        assert!(matches!(err, ConnectionError::TlsCaNotFound));
886    }
887
888    #[mz_ore::test]
889    fn plan_no_ca_sources_at_all_falls_back_to_default_verify_paths() {
890        let spec = plan_connector(
891            SslMode::VerifyFull,
892            None,
893            "example.com",
894            &[Path::new("/nope1"), Path::new("/nope2")],
895            |_| false,
896        )
897        .unwrap();
898        match spec {
899            ConnectorSpec::Tls {
900                ca_source: CaSource::DefaultVerifyPaths,
901                ..
902            } => {}
903            other => panic!("expected Tls/DefaultVerifyPaths, got {:?}", other),
904        }
905    }
906
907    #[mz_ore::test]
908    fn build_disable_returns_notls() {
909        let connector = build_connector(ConnectorSpec::NoTls).unwrap();
910        assert!(matches!(connector, Connector::NoTls));
911    }
912
913    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
914    #[mz_ore::test]
915    fn build_prefer_returns_tls_no_ca_work() {
916        let connector = build_connector(ConnectorSpec::Tls {
917            verify: openssl::ssl::SslVerifyMode::NONE,
918            host_check: None,
919            ca_source: CaSource::None,
920        })
921        .unwrap();
922        assert!(matches!(connector, Connector::Tls(_)));
923    }
924
925    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
926    #[mz_ore::test]
927    fn build_explicit_missing_ca_returns_ca_not_found() {
928        let err = build_connector(ConnectorSpec::Tls {
929            verify: openssl::ssl::SslVerifyMode::PEER,
930            host_check: None,
931            ca_source: CaSource::Explicit(std::path::PathBuf::from("/absolutely/not/a/real/file")),
932        })
933        .unwrap_err();
934        assert!(matches!(err, ConnectionError::TlsCaNotFound));
935    }
936
937    #[mz_ore::test]
938    fn matches_tls_refused_tls_was_required() {
939        assert!(matches_tls_refused_message(
940            "some prefix: TLS was required but not provided"
941        ));
942    }
943
944    #[mz_ore::test]
945    fn matches_tls_refused_does_not_support_tls() {
946        assert!(matches_tls_refused_message(
947            "error: server does not support TLS"
948        ));
949    }
950
951    #[mz_ore::test]
952    fn matches_tls_refused_does_not_support_ssl() {
953        assert!(matches_tls_refused_message(
954            "error: server does not support SSL"
955        ));
956    }
957
958    #[mz_ore::test]
959    fn matches_tls_refused_unrelated_message() {
960        assert!(!matches_tls_refused_message("connection refused"));
961        assert!(!matches_tls_refused_message("database does not exist"));
962        assert!(!matches_tls_refused_message(""));
963    }
964}