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