1use crate::types::{RuntimeConfig, SessionConfig};
2use std::error::Error as _;
3use tokio_postgres::Config;
4use tokio_postgres::config::{Host, SslMode};
5
6const SUPPORTED_SSLMODE_HINT: &str = "afpsql supports sslmode=disable, prefer, and require. It does not implement libpq verify-ca/verify-full or client certificate options yet; use psql/libpq when certificate verification or client certificates are required.";
7const DEFAULT_POSTGRES_HOST: &str = "127.0.0.1";
8const DEFAULT_POSTGRES_PORT: u16 = 5432;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub(crate) enum PostgresEndpoint {
12 Tcp { host: String, port: u16 },
13 UnixSocket { directory: String, port: u16 },
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ConnectionConfigError {
18 message: String,
19 hint: Option<String>,
20}
21
22impl ConnectionConfigError {
23 pub fn new(message: impl Into<String>, hint: Option<String>) -> Self {
24 Self {
25 message: message.into(),
26 hint,
27 }
28 }
29
30 pub fn message(&self) -> &str {
31 &self.message
32 }
33
34 pub fn hint(&self) -> Option<&str> {
35 self.hint.as_deref()
36 }
37}
38
39impl std::fmt::Display for ConnectionConfigError {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 f.write_str(&self.message)
42 }
43}
44
45impl std::error::Error for ConnectionConfigError {}
46
47pub fn resolve_session_name(cfg: &RuntimeConfig, requested: Option<&str>) -> String {
48 requested
49 .map(std::string::ToString::to_string)
50 .unwrap_or_else(|| cfg.default_session.clone())
51}
52
53pub fn resolve_pg_config(cfg: &SessionConfig) -> Result<Config, ConnectionConfigError> {
54 let env = |name: &str| {
60 if cfg.profile_pinned {
61 None
62 } else {
63 env_nonempty(name)
64 }
65 };
66
67 if let Some(dsn) = cfg.dsn_secret.clone().or_else(|| env("AFPSQL_DSN_SECRET")) {
68 validate_dsn_ssl_options(&dsn)?;
69 return dsn.parse().map_err(|e| map_pg_config_parse_error("dsn", e));
70 }
71
72 if let Some(conninfo) = cfg
73 .conninfo_secret
74 .clone()
75 .or_else(|| env("AFPSQL_CONNINFO_SECRET"))
76 {
77 validate_conninfo_ssl_options(&conninfo)?;
78 return conninfo
79 .parse()
80 .map_err(|e| map_pg_config_parse_error("conninfo", e));
81 }
82
83 let host = cfg
84 .host
85 .clone()
86 .or_else(|| env("AFPSQL_HOST"))
87 .or_else(|| env("PGHOST"))
88 .unwrap_or_else(|| "127.0.0.1".to_string());
89 let port = cfg
90 .port
91 .or_else(|| env("AFPSQL_PORT").and_then(|s| s.parse().ok()))
92 .or_else(|| env("PGPORT").and_then(|s| s.parse().ok()))
93 .unwrap_or(5432);
94 let user = cfg
95 .user
96 .clone()
97 .or_else(|| env("AFPSQL_USER"))
98 .or_else(|| env("PGUSER"))
99 .unwrap_or_else(|| "postgres".to_string());
100 let dbname = cfg
101 .dbname
102 .clone()
103 .or_else(|| env("AFPSQL_DBNAME"))
104 .or_else(|| env("PGDATABASE"))
105 .unwrap_or_else(|| "postgres".to_string());
106 let password = cfg
107 .password_secret
108 .clone()
109 .or_else(|| env("AFPSQL_PASSWORD_SECRET"))
110 .or_else(|| env("PGPASSWORD"));
111
112 let mut pg_cfg = Config::new();
113 pg_cfg.host(host).port(port).user(user).dbname(dbname);
114 if let Some(pw) = password {
115 pg_cfg.password(pw);
116 }
117 if let Some(sslmode) = env("PGSSLMODE") {
118 apply_sslmode(&mut pg_cfg, "PGSSLMODE", &sslmode)?;
119 }
120 Ok(pg_cfg)
121}
122
123pub(crate) fn resolve_single_postgres_endpoint(
124 pg_cfg: &Config,
125 transport_name: &str,
126) -> Result<PostgresEndpoint, String> {
127 let hosts = pg_cfg.get_hosts();
128 let hostaddrs = pg_cfg.get_hostaddrs();
129 let ports = pg_cfg.get_ports();
130 if hosts.len() > 1 || hostaddrs.len() > 1 || ports.len() > 1 {
131 return Err(format!(
132 "{transport_name} supports a single PostgreSQL host and port; the connection source resolved to multiple targets"
133 ));
134 }
135
136 let port = ports.first().copied().unwrap_or(DEFAULT_POSTGRES_PORT);
137 if let Some(hostaddr) = hostaddrs.first() {
138 #[cfg(unix)]
139 if matches!(hosts.first(), Some(Host::Unix(_))) {
140 return Err(format!(
141 "{transport_name} cannot combine a PostgreSQL Unix socket with hostaddr"
142 ));
143 }
144 return Ok(PostgresEndpoint::Tcp {
145 host: hostaddr.to_string(),
146 port,
147 });
148 }
149
150 match hosts.first() {
151 Some(Host::Tcp(host)) if host.starts_with('/') => Ok(PostgresEndpoint::UnixSocket {
152 directory: host.clone(),
153 port,
154 }),
155 Some(Host::Tcp(host)) => Ok(PostgresEndpoint::Tcp {
156 host: host.clone(),
157 port,
158 }),
159 #[cfg(unix)]
160 Some(Host::Unix(path)) => Ok(PostgresEndpoint::UnixSocket {
161 directory: path.to_string_lossy().into_owned(),
162 port,
163 }),
164 None => Ok(PostgresEndpoint::Tcp {
165 host: DEFAULT_POSTGRES_HOST.to_string(),
166 port,
167 }),
168 }
169}
170
171pub(crate) fn postgres_tls_server_name(endpoint: &PostgresEndpoint) -> String {
179 match endpoint {
180 PostgresEndpoint::Tcp { host, .. } => host.clone(),
181 PostgresEndpoint::UnixSocket { .. } => DEFAULT_POSTGRES_HOST.to_string(),
182 }
183}
184
185pub(crate) fn make_supported_tls()
186-> Result<postgres_native_tls::MakeTlsConnector, native_tls::Error> {
187 let tls = native_tls::TlsConnector::builder()
188 .danger_accept_invalid_certs(true)
191 .danger_accept_invalid_hostnames(true)
192 .build()?;
193 Ok(postgres_native_tls::MakeTlsConnector::new(tls))
194}
195
196fn env_nonempty(name: &str) -> Option<String> {
197 std::env::var(name).ok().filter(|value| !value.is_empty())
198}
199
200pub fn libpq_env_fallbacks_in_use(cfg: &SessionConfig) -> Vec<&'static str> {
201 if cfg.profile_pinned {
205 return Vec::new();
206 }
207 if cfg.dsn_secret.is_some() || cfg.conninfo_secret.is_some() {
208 return Vec::new();
209 }
210 if std::env::var("AFPSQL_DSN_SECRET").is_ok() || std::env::var("AFPSQL_CONNINFO_SECRET").is_ok()
211 {
212 return Vec::new();
213 }
214 let mut used = Vec::new();
215 if cfg.host.is_none()
216 && std::env::var("AFPSQL_HOST").is_err()
217 && env_nonempty("PGHOST").is_some()
218 {
219 used.push("PGHOST");
220 }
221 if cfg.port.is_none()
222 && std::env::var("AFPSQL_PORT").is_err()
223 && env_nonempty("PGPORT").is_some()
224 {
225 used.push("PGPORT");
226 }
227 if cfg.user.is_none()
228 && std::env::var("AFPSQL_USER").is_err()
229 && env_nonempty("PGUSER").is_some()
230 {
231 used.push("PGUSER");
232 }
233 if cfg.dbname.is_none()
234 && std::env::var("AFPSQL_DBNAME").is_err()
235 && env_nonempty("PGDATABASE").is_some()
236 {
237 used.push("PGDATABASE");
238 }
239 if cfg.password_secret.is_none()
240 && std::env::var("AFPSQL_PASSWORD_SECRET").is_err()
241 && env_nonempty("PGPASSWORD").is_some()
242 {
243 used.push("PGPASSWORD");
244 }
245 if env_nonempty("PGSSLMODE").is_some() {
246 used.push("PGSSLMODE");
247 }
248 used
249}
250
251fn validate_dsn_ssl_options(dsn: &str) -> Result<(), ConnectionConfigError> {
252 let Some(query) = dsn.split_once('?').map(|(_, query)| query) else {
253 return Ok(());
254 };
255 let query = query.split('#').next().unwrap_or(query);
256 for part in query.split('&') {
257 let (key, value) = part.split_once('=').unwrap_or((part, ""));
258 validate_ssl_option(key, value, "dsn")?;
259 }
260 Ok(())
261}
262
263fn validate_conninfo_ssl_options(conninfo: &str) -> Result<(), ConnectionConfigError> {
264 for (key, value) in parse_conninfo_pairs(conninfo) {
265 validate_ssl_option(&key, &value, "conninfo")?;
266 }
267 Ok(())
268}
269
270fn validate_ssl_option(key: &str, value: &str, source: &str) -> Result<(), ConnectionConfigError> {
271 match key {
272 "sslmode" => validate_sslmode(source, value),
273 "sslnegotiation" if value == "postgres" => Ok(()),
274 "sslnegotiation" => Err(ConnectionConfigError::new(
275 format!("unsupported {source} TLS option `sslnegotiation={value}`"),
276 Some("afpsql supports PostgreSQL's standard TLS negotiation path only; remove sslnegotiation=direct or use psql/libpq for PostgreSQL 17 direct TLS negotiation.".to_string()),
277 )),
278 "sslrootcert" | "sslcert" | "sslkey" | "sslpassword" | "sslcrl" | "sslcrldir"
279 | "sslcertmode" | "sslsni" | "ssl_min_protocol_version" | "ssl_max_protocol_version"
280 => Err(unsupported_ssl_option(source, key)),
281 _ => Ok(()),
282 }
283}
284
285fn apply_sslmode(
286 pg_cfg: &mut Config,
287 source: &str,
288 value: &str,
289) -> Result<(), ConnectionConfigError> {
290 validate_sslmode(source, value)?;
291 let mode = match value {
292 "disable" => SslMode::Disable,
293 "prefer" => SslMode::Prefer,
294 "require" => SslMode::Require,
295 _ => return Err(unsupported_sslmode(source, value)),
296 };
297 pg_cfg.ssl_mode(mode);
298 Ok(())
299}
300
301fn validate_sslmode(source: &str, value: &str) -> Result<(), ConnectionConfigError> {
302 match value {
303 "disable" | "prefer" | "require" => Ok(()),
304 _ => Err(unsupported_sslmode(source, value)),
305 }
306}
307
308fn unsupported_sslmode(source: &str, value: &str) -> ConnectionConfigError {
309 ConnectionConfigError::new(
310 format!(
311 "unsupported {source} sslmode `{value}`; supported values are disable, prefer, require"
312 ),
313 Some(SUPPORTED_SSLMODE_HINT.to_string()),
314 )
315}
316
317fn unsupported_ssl_option(source: &str, key: &str) -> ConnectionConfigError {
318 ConnectionConfigError::new(
319 format!("unsupported {source} TLS option `{key}`"),
320 Some(SUPPORTED_SSLMODE_HINT.to_string()),
321 )
322}
323
324fn map_pg_config_parse_error(source: &str, err: tokio_postgres::Error) -> ConnectionConfigError {
325 let cause = err.source().map(std::string::ToString::to_string);
326 if let Some(cause) = cause.as_deref() {
327 if cause == "invalid value for option `sslmode`" {
328 return ConnectionConfigError::new(
329 format!("unsupported {source} sslmode"),
330 Some(SUPPORTED_SSLMODE_HINT.to_string()),
331 );
332 }
333 if let Some(key) = cause
334 .strip_prefix("unknown option `")
335 .and_then(|rest| rest.strip_suffix('`'))
336 && is_unsupported_ssl_option(key)
337 {
338 return unsupported_ssl_option(source, key);
339 }
340 }
341
342 let detail = cause
343 .map(|cause| format!("{err}: {cause}"))
344 .unwrap_or_else(|| err.to_string());
345 ConnectionConfigError::new(format!("invalid {source}: {detail}"), None)
346}
347
348fn is_unsupported_ssl_option(key: &str) -> bool {
349 matches!(
350 key,
351 "sslrootcert"
352 | "sslcert"
353 | "sslkey"
354 | "sslpassword"
355 | "sslcrl"
356 | "sslcrldir"
357 | "sslcertmode"
358 | "sslsni"
359 | "ssl_min_protocol_version"
360 | "ssl_max_protocol_version"
361 | "sslnegotiation"
362 )
363}
364
365fn parse_conninfo_pairs(input: &str) -> Vec<(String, String)> {
366 let bytes = input.as_bytes();
367 let mut pairs = Vec::new();
368 let mut i = 0usize;
369
370 while i < bytes.len() {
371 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
372 i += 1;
373 }
374 if i >= bytes.len() {
375 break;
376 }
377
378 let key_start = i;
379 while i < bytes.len() && bytes[i] != b'=' && !bytes[i].is_ascii_whitespace() {
380 i += 1;
381 }
382 let key = &input[key_start..i];
383 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
384 i += 1;
385 }
386 if i >= bytes.len() || bytes[i] != b'=' {
387 break;
388 }
389 i += 1;
390 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
391 i += 1;
392 }
393
394 let mut value = String::new();
395 if i < bytes.len() && bytes[i] == b'\'' {
396 i += 1;
397 while i < bytes.len() {
398 match bytes[i] {
399 b'\\' if i + 1 < bytes.len() => {
400 i += 1;
401 value.push(bytes[i] as char);
402 i += 1;
403 }
404 b'\'' => {
405 i += 1;
406 break;
407 }
408 b => {
409 value.push(b as char);
410 i += 1;
411 }
412 }
413 }
414 } else {
415 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
416 if bytes[i] == b'\\' && i + 1 < bytes.len() {
417 i += 1;
418 }
419 value.push(bytes[i] as char);
420 i += 1;
421 }
422 }
423
424 pairs.push((key.to_string(), value));
425 }
426
427 pairs
428}
429
430#[cfg(test)]
431#[path = "../tests/support/unit_conn.rs"]
432mod tests;