Skip to main content

agent_first_psql/
conn.rs

1use crate::types::{RuntimeConfig, SessionConfig};
2use std::error::Error as _;
3use tokio_postgres::Config;
4use tokio_postgres::config::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.";
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct ConnectionConfigError {
10    message: String,
11    hint: Option<String>,
12}
13
14impl ConnectionConfigError {
15    pub fn new(message: impl Into<String>, hint: Option<String>) -> Self {
16        Self {
17            message: message.into(),
18            hint,
19        }
20    }
21
22    pub fn message(&self) -> &str {
23        &self.message
24    }
25
26    pub fn hint(&self) -> Option<&str> {
27        self.hint.as_deref()
28    }
29}
30
31impl std::fmt::Display for ConnectionConfigError {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        f.write_str(&self.message)
34    }
35}
36
37impl std::error::Error for ConnectionConfigError {}
38
39pub fn resolve_session_name(cfg: &RuntimeConfig, requested: Option<&str>) -> String {
40    requested
41        .map(std::string::ToString::to_string)
42        .unwrap_or_else(|| cfg.default_session.clone())
43}
44
45pub fn resolve_pg_config(cfg: &SessionConfig) -> Result<Config, ConnectionConfigError> {
46    if let Some(dsn) = cfg
47        .dsn_secret
48        .clone()
49        .or_else(|| std::env::var("AFPSQL_DSN_SECRET").ok())
50    {
51        validate_dsn_ssl_options(&dsn)?;
52        return dsn.parse().map_err(|e| map_pg_config_parse_error("dsn", e));
53    }
54
55    if let Some(conninfo) = cfg
56        .conninfo_secret
57        .clone()
58        .or_else(|| std::env::var("AFPSQL_CONNINFO_SECRET").ok())
59    {
60        validate_conninfo_ssl_options(&conninfo)?;
61        return conninfo
62            .parse()
63            .map_err(|e| map_pg_config_parse_error("conninfo", e));
64    }
65
66    let host = cfg
67        .host
68        .clone()
69        .or_else(|| std::env::var("AFPSQL_HOST").ok())
70        .or_else(|| std::env::var("PGHOST").ok())
71        .unwrap_or_else(|| "127.0.0.1".to_string());
72    let port = cfg
73        .port
74        .or_else(|| {
75            std::env::var("AFPSQL_PORT")
76                .ok()
77                .and_then(|s| s.parse().ok())
78        })
79        .or_else(|| std::env::var("PGPORT").ok().and_then(|s| s.parse().ok()))
80        .unwrap_or(5432);
81    let user = cfg
82        .user
83        .clone()
84        .or_else(|| std::env::var("AFPSQL_USER").ok())
85        .or_else(|| std::env::var("PGUSER").ok())
86        .unwrap_or_else(|| "postgres".to_string());
87    let dbname = cfg
88        .dbname
89        .clone()
90        .or_else(|| std::env::var("AFPSQL_DBNAME").ok())
91        .or_else(|| std::env::var("PGDATABASE").ok())
92        .unwrap_or_else(|| "postgres".to_string());
93    let password = cfg
94        .password_secret
95        .clone()
96        .or_else(|| std::env::var("AFPSQL_PASSWORD_SECRET").ok())
97        .or_else(|| std::env::var("PGPASSWORD").ok());
98
99    let mut pg_cfg = Config::new();
100    pg_cfg.host(host).port(port).user(user).dbname(dbname);
101    if let Some(pw) = password {
102        pg_cfg.password(pw);
103    }
104    if let Some(sslmode) = env_nonempty("PGSSLMODE") {
105        apply_sslmode(&mut pg_cfg, "PGSSLMODE", &sslmode)?;
106    }
107    Ok(pg_cfg)
108}
109
110fn env_nonempty(name: &str) -> Option<String> {
111    std::env::var(name).ok().filter(|value| !value.is_empty())
112}
113
114pub fn libpq_env_fallbacks_in_use(cfg: &SessionConfig) -> Vec<&'static str> {
115    if cfg.dsn_secret.is_some() || cfg.conninfo_secret.is_some() {
116        return Vec::new();
117    }
118    if std::env::var("AFPSQL_DSN_SECRET").is_ok() || std::env::var("AFPSQL_CONNINFO_SECRET").is_ok()
119    {
120        return Vec::new();
121    }
122    let mut used = Vec::new();
123    if cfg.host.is_none()
124        && std::env::var("AFPSQL_HOST").is_err()
125        && env_nonempty("PGHOST").is_some()
126    {
127        used.push("PGHOST");
128    }
129    if cfg.port.is_none()
130        && std::env::var("AFPSQL_PORT").is_err()
131        && env_nonempty("PGPORT").is_some()
132    {
133        used.push("PGPORT");
134    }
135    if cfg.user.is_none()
136        && std::env::var("AFPSQL_USER").is_err()
137        && env_nonempty("PGUSER").is_some()
138    {
139        used.push("PGUSER");
140    }
141    if cfg.dbname.is_none()
142        && std::env::var("AFPSQL_DBNAME").is_err()
143        && env_nonempty("PGDATABASE").is_some()
144    {
145        used.push("PGDATABASE");
146    }
147    if cfg.password_secret.is_none()
148        && std::env::var("AFPSQL_PASSWORD_SECRET").is_err()
149        && env_nonempty("PGPASSWORD").is_some()
150    {
151        used.push("PGPASSWORD");
152    }
153    if env_nonempty("PGSSLMODE").is_some() {
154        used.push("PGSSLMODE");
155    }
156    used
157}
158
159fn validate_dsn_ssl_options(dsn: &str) -> Result<(), ConnectionConfigError> {
160    let Some(query) = dsn.split_once('?').map(|(_, query)| query) else {
161        return Ok(());
162    };
163    let query = query.split('#').next().unwrap_or(query);
164    for part in query.split('&') {
165        let (key, value) = part.split_once('=').unwrap_or((part, ""));
166        validate_ssl_option(key, value, "dsn")?;
167    }
168    Ok(())
169}
170
171fn validate_conninfo_ssl_options(conninfo: &str) -> Result<(), ConnectionConfigError> {
172    for (key, value) in parse_conninfo_pairs(conninfo) {
173        validate_ssl_option(&key, &value, "conninfo")?;
174    }
175    Ok(())
176}
177
178fn validate_ssl_option(key: &str, value: &str, source: &str) -> Result<(), ConnectionConfigError> {
179    match key {
180        "sslmode" => validate_sslmode(source, value),
181        "sslnegotiation" if value == "postgres" => Ok(()),
182        "sslnegotiation" => Err(ConnectionConfigError::new(
183            format!("unsupported {source} TLS option `sslnegotiation={value}`"),
184            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()),
185        )),
186        "sslrootcert" | "sslcert" | "sslkey" | "sslpassword" | "sslcrl" | "sslcrldir"
187        | "sslcertmode" | "sslsni" | "ssl_min_protocol_version" | "ssl_max_protocol_version"
188        => Err(unsupported_ssl_option(source, key)),
189        _ => Ok(()),
190    }
191}
192
193fn apply_sslmode(
194    pg_cfg: &mut Config,
195    source: &str,
196    value: &str,
197) -> Result<(), ConnectionConfigError> {
198    validate_sslmode(source, value)?;
199    let mode = match value {
200        "disable" => SslMode::Disable,
201        "prefer" => SslMode::Prefer,
202        "require" => SslMode::Require,
203        _ => return Err(unsupported_sslmode(source, value)),
204    };
205    pg_cfg.ssl_mode(mode);
206    Ok(())
207}
208
209fn validate_sslmode(source: &str, value: &str) -> Result<(), ConnectionConfigError> {
210    match value {
211        "disable" | "prefer" | "require" => Ok(()),
212        _ => Err(unsupported_sslmode(source, value)),
213    }
214}
215
216fn unsupported_sslmode(source: &str, value: &str) -> ConnectionConfigError {
217    ConnectionConfigError::new(
218        format!(
219            "unsupported {source} sslmode `{value}`; supported values are disable, prefer, require"
220        ),
221        Some(SUPPORTED_SSLMODE_HINT.to_string()),
222    )
223}
224
225fn unsupported_ssl_option(source: &str, key: &str) -> ConnectionConfigError {
226    ConnectionConfigError::new(
227        format!("unsupported {source} TLS option `{key}`"),
228        Some(SUPPORTED_SSLMODE_HINT.to_string()),
229    )
230}
231
232fn map_pg_config_parse_error(source: &str, err: tokio_postgres::Error) -> ConnectionConfigError {
233    let cause = err.source().map(std::string::ToString::to_string);
234    if let Some(cause) = cause.as_deref() {
235        if cause == "invalid value for option `sslmode`" {
236            return ConnectionConfigError::new(
237                format!("unsupported {source} sslmode"),
238                Some(SUPPORTED_SSLMODE_HINT.to_string()),
239            );
240        }
241        if let Some(key) = cause
242            .strip_prefix("unknown option `")
243            .and_then(|rest| rest.strip_suffix('`'))
244            && is_unsupported_ssl_option(key)
245        {
246            return unsupported_ssl_option(source, key);
247        }
248    }
249
250    let detail = cause
251        .map(|cause| format!("{err}: {cause}"))
252        .unwrap_or_else(|| err.to_string());
253    ConnectionConfigError::new(format!("invalid {source}: {detail}"), None)
254}
255
256fn is_unsupported_ssl_option(key: &str) -> bool {
257    matches!(
258        key,
259        "sslrootcert"
260            | "sslcert"
261            | "sslkey"
262            | "sslpassword"
263            | "sslcrl"
264            | "sslcrldir"
265            | "sslcertmode"
266            | "sslsni"
267            | "ssl_min_protocol_version"
268            | "ssl_max_protocol_version"
269            | "sslnegotiation"
270    )
271}
272
273fn parse_conninfo_pairs(input: &str) -> Vec<(String, String)> {
274    let bytes = input.as_bytes();
275    let mut pairs = Vec::new();
276    let mut i = 0usize;
277
278    while i < bytes.len() {
279        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
280            i += 1;
281        }
282        if i >= bytes.len() {
283            break;
284        }
285
286        let key_start = i;
287        while i < bytes.len() && bytes[i] != b'=' && !bytes[i].is_ascii_whitespace() {
288            i += 1;
289        }
290        let key = &input[key_start..i];
291        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
292            i += 1;
293        }
294        if i >= bytes.len() || bytes[i] != b'=' {
295            break;
296        }
297        i += 1;
298        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
299            i += 1;
300        }
301
302        let mut value = String::new();
303        if i < bytes.len() && bytes[i] == b'\'' {
304            i += 1;
305            while i < bytes.len() {
306                match bytes[i] {
307                    b'\\' if i + 1 < bytes.len() => {
308                        i += 1;
309                        value.push(bytes[i] as char);
310                        i += 1;
311                    }
312                    b'\'' => {
313                        i += 1;
314                        break;
315                    }
316                    b => {
317                        value.push(b as char);
318                        i += 1;
319                    }
320                }
321            }
322        } else {
323            while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
324                if bytes[i] == b'\\' && i + 1 < bytes.len() {
325                    i += 1;
326                }
327                value.push(bytes[i] as char);
328                i += 1;
329            }
330        }
331
332        pairs.push((key.to_string(), value));
333    }
334
335    pairs
336}
337
338#[cfg(test)]
339#[path = "../tests/support/unit_conn.rs"]
340mod tests;