faucet_common_mssql/
config.rs1use std::path::PathBuf;
8
9use faucet_core::FaucetError;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13pub const PARAM_LIMIT: usize = 2100;
17
18#[derive(Clone, Serialize, Deserialize, JsonSchema, Default)]
32pub struct MssqlConnectionConfig {
33 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub connection_url: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub connection_string: Option<String>,
42 #[serde(default)]
44 pub tls: MssqlTls,
45}
46
47#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
51pub struct MssqlTls {
52 #[serde(rename = "type", default)]
54 pub mode: MssqlTlsMode,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub ca_cert_path: Option<PathBuf>,
59}
60
61#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
63#[serde(rename_all = "snake_case")]
64pub enum MssqlTlsMode {
65 #[default]
68 Prefer,
69 Require,
72 TrustServerCertificate,
76 Disable,
78}
79
80#[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 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
107pub(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
163pub 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 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}