Skip to main content

agent_first_psql/
conn.rs

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    // An administrator-locked readonly profile pins the endpoint, and the CLI
56    // already rejects every connection flag. Environment variables were the one
57    // remaining way to move such an executable off its target, so a pinned
58    // session reads none of them — including PGSSLMODE, which would otherwise
59    // let a caller downgrade TLS on a connection it does not control.
60    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            // Connect to the local tunnel address while retaining the original
223            // hostname for PostgreSQL TLS SNI.
224            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
234/// TLS server name for a stdio-bridged connection.
235///
236/// Derived from the endpoint the bridge actually targets, so it is never empty:
237/// OpenSSL rejects a zero-length SNI name outright, which would turn a hostless
238/// DSN into a TLS handshake failure. An IP literal or a Unix-socket target
239/// yields an address rather than a name, and the TLS backend then skips SNI —
240/// which is the correct behavior for both.
241pub(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        // Supported sslmode=prefer/require encrypts without certificate
252        // verification. verify-ca/verify-full are rejected during config parse.
253        .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;