Skip to main content

faucet_common_mssql/
config.rs

1//! Shared MSSQL connection configuration and pure parsing/quoting helpers.
2//!
3//! No I/O lives here — connecting and pooling are in [`crate::pool`]. This
4//! module only holds the serde config types and the pure logic the source and
5//! sink both need (URL parsing, identifier quoting, the parameter ceiling).
6
7use std::path::PathBuf;
8
9use faucet_core::FaucetError;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13/// MSSQL's hard ceiling on bind parameters per request. A multi-row `INSERT`
14/// binds `rows * columns` parameters, so the sink auto-splits a batch into
15/// multiple statements whenever it would exceed this.
16pub const PARAM_LIMIT: usize = 2100;
17
18/// Shared connection configuration for the MSSQL source and sink.
19///
20/// Exactly one of [`connection_url`](Self::connection_url) or
21/// [`connection_string`](Self::connection_string) must be set. The
22/// `connection_url` form is parsed by faucet (host/port/database/credentials)
23/// and the [`tls`](Self::tls) block governs encryption. The `connection_string`
24/// form is an ADO.NET-style string handed straight to `tiberius`, with the
25/// `tls` block applied on top.
26///
27/// `max_connections` and `statement_timeout_secs` are intentionally *not* here —
28/// they default differently for the source (10 / 300) and sink (5 / 300), so
29/// each end owns them and passes them to [`crate::pool::build_pool`] /
30/// [`crate::pool::with_statement_timeout`].
31#[derive(Clone, Serialize, Deserialize, JsonSchema, Default)]
32pub struct MssqlConnectionConfig {
33    /// `mssql://user:pass@host:1433/database` URL form. Mutually exclusive with
34    /// [`connection_string`](Self::connection_string).
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub connection_url: Option<String>,
37    /// ADO.NET-style connection string handed straight to `tiberius`, e.g.
38    /// `Server=tcp:host,1433;Database=db;User Id=sa;Password=...;`. Mutually
39    /// exclusive with [`connection_url`](Self::connection_url).
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub connection_string: Option<String>,
42    /// TLS / encryption settings. Defaults to [`MssqlTlsMode::Prefer`].
43    #[serde(default)]
44    pub tls: MssqlTls,
45}
46
47/// TLS configuration for an MSSQL connection.
48///
49/// Matches the YAML shape `tls: { type: <mode>, ca_cert_path: <path> }`.
50#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
51pub struct MssqlTls {
52    /// Encryption mode. Defaults to [`MssqlTlsMode::Prefer`].
53    #[serde(rename = "type", default)]
54    pub mode: MssqlTlsMode,
55    /// Optional path to a CA certificate (PEM/DER) to trust for server
56    /// validation. Ignored when `mode` is [`MssqlTlsMode::Disable`].
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub ca_cert_path: Option<PathBuf>,
59}
60
61/// MSSQL encryption mode.
62#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
63#[serde(rename_all = "snake_case")]
64pub enum MssqlTlsMode {
65    /// Encrypt the connection if the server supports it (the safe modern
66    /// default). Maps to `tiberius` `EncryptionLevel::On`.
67    #[default]
68    Prefer,
69    /// Require encryption; fail if the server does not offer it. Maps to
70    /// `EncryptionLevel::Required`.
71    Require,
72    /// Encrypt and accept the server certificate without validating its chain
73    /// (self-signed dev servers). Maps to `EncryptionLevel::On` + `trust_cert()`.
74    /// **Insecure against MITM — never use in production.**
75    TrustServerCertificate,
76    /// No transport encryption. Maps to `EncryptionLevel::NotSupported`.
77    Disable,
78}
79
80/// Parsed parts of a `mssql://` connection URL.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub(crate) struct ConnectionParts {
83    pub host: String,
84    pub port: u16,
85    pub database: Option<String>,
86    pub username: String,
87    pub password: String,
88}
89
90impl MssqlConnectionConfig {
91    /// Validate that exactly one of `connection_url` / `connection_string` is
92    /// set.
93    pub fn validate(&self) -> Result<(), FaucetError> {
94        match (&self.connection_url, &self.connection_string) {
95            (Some(_), Some(_)) => Err(FaucetError::Config(
96                "MSSQL config sets both `connection_url` and `connection_string`; set exactly one"
97                    .into(),
98            )),
99            (None, None) => Err(FaucetError::Config(
100                "MSSQL config requires either `connection_url` or `connection_string`".into(),
101            )),
102            _ => Ok(()),
103        }
104    }
105}
106
107/// Parse a `mssql://user:password@host:port/database` URL into its parts.
108///
109/// The port defaults to 1433. The database is the first path segment (optional).
110/// Credentials are percent-decoded. Returns [`FaucetError::Config`] on a
111/// malformed URL or a missing host.
112pub(crate) fn parse_connection_url(raw: &str) -> Result<ConnectionParts, FaucetError> {
113    let url = url::Url::parse(raw)
114        .map_err(|e| FaucetError::Config(format!("invalid MSSQL connection_url: {e}")))?;
115
116    if url.scheme() != "mssql" && url.scheme() != "sqlserver" {
117        return Err(FaucetError::Config(format!(
118            "MSSQL connection_url scheme must be `mssql://`, got `{}://`",
119            url.scheme()
120        )));
121    }
122
123    let host = url
124        .host_str()
125        .filter(|h| !h.is_empty())
126        .ok_or_else(|| FaucetError::Config("MSSQL connection_url is missing a host".into()))?
127        .to_string();
128
129    let port = url.port().unwrap_or(1433);
130
131    let database = {
132        let seg = url.path().trim_start_matches('/');
133        if seg.is_empty() {
134            None
135        } else {
136            Some(
137                percent_decode(seg)
138                    .map_err(|e| FaucetError::Config(format!("invalid database in URL: {e}")))?,
139            )
140        }
141    };
142
143    let username = percent_decode(url.username())
144        .map_err(|e| FaucetError::Config(format!("invalid username in URL: {e}")))?;
145    let password = percent_decode(url.password().unwrap_or(""))
146        .map_err(|e| FaucetError::Config(format!("invalid password in URL: {e}")))?;
147
148    Ok(ConnectionParts {
149        host,
150        port,
151        database,
152        username,
153        password,
154    })
155}
156
157fn percent_decode(s: &str) -> Result<String, std::str::Utf8Error> {
158    percent_encoding::percent_decode_str(s)
159        .decode_utf8()
160        .map(|c| c.into_owned())
161}
162
163/// Quote an MSSQL identifier with bracket quoting (`[name]`), doubling any
164/// interior `]` per T-SQL rules. Rejects identifiers containing a NUL byte.
165///
166/// MSSQL idiom is `[brackets]`; this is why the source/sink do not reuse
167/// `faucet_core::util::quote_ident` (double-quote quoting).
168pub fn quote_ident_mssql(name: &str) -> Result<String, FaucetError> {
169    if name.contains('\0') {
170        return Err(FaucetError::Config(format!(
171            "invalid MSSQL identifier (contains NUL): {name:?}"
172        )));
173    }
174    Ok(format!("[{}]", name.replace(']', "]]")))
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn validate_accepts_exactly_one() {
183        let url_only = MssqlConnectionConfig {
184            connection_url: Some("mssql://sa:pw@host/db".into()),
185            ..Default::default()
186        };
187        assert!(url_only.validate().is_ok());
188
189        let str_only = MssqlConnectionConfig {
190            connection_string: Some("Server=host;Database=db".into()),
191            ..Default::default()
192        };
193        assert!(str_only.validate().is_ok());
194    }
195
196    #[test]
197    fn validate_rejects_both_and_neither() {
198        let both = MssqlConnectionConfig {
199            connection_url: Some("mssql://sa:pw@host/db".into()),
200            connection_string: Some("Server=host".into()),
201            ..Default::default()
202        };
203        assert!(both.validate().is_err());
204
205        let neither = MssqlConnectionConfig::default();
206        assert!(neither.validate().is_err());
207    }
208
209    #[test]
210    fn parse_url_extracts_all_parts() {
211        let parts = parse_connection_url("mssql://sa:s3cret@db.example.com:1433/sales").unwrap();
212        assert_eq!(parts.host, "db.example.com");
213        assert_eq!(parts.port, 1433);
214        assert_eq!(parts.database.as_deref(), Some("sales"));
215        assert_eq!(parts.username, "sa");
216        assert_eq!(parts.password, "s3cret");
217    }
218
219    #[test]
220    fn parse_url_defaults_port_and_optional_database() {
221        let parts = parse_connection_url("mssql://sa:pw@localhost").unwrap();
222        assert_eq!(parts.port, 1433);
223        assert_eq!(parts.database, None);
224    }
225
226    #[test]
227    fn parse_url_percent_decodes_credentials() {
228        // password "p@ss:w/rd" percent-encoded
229        let parts = parse_connection_url("mssql://us%65r:p%40ss%3Aw%2Frd@host/db").unwrap();
230        assert_eq!(parts.username, "user");
231        assert_eq!(parts.password, "p@ss:w/rd");
232    }
233
234    #[test]
235    fn parse_url_rejects_wrong_scheme_and_missing_host() {
236        assert!(parse_connection_url("postgres://sa:pw@host/db").is_err());
237        assert!(parse_connection_url("not a url").is_err());
238    }
239
240    #[test]
241    fn quote_ident_brackets_and_doubles_closing_bracket() {
242        assert_eq!(quote_ident_mssql("events").unwrap(), "[events]");
243        assert_eq!(quote_ident_mssql("dbo.events").unwrap(), "[dbo.events]");
244        assert_eq!(quote_ident_mssql("we[i]rd").unwrap(), "[we[i]]rd]");
245        assert!(quote_ident_mssql("bad\0name").is_err());
246    }
247}