1#[derive(Debug)]
2pub enum ExecError {
3 Cancelled,
4 Connect(Box<ConnectError>),
5 Config {
6 message: String,
7 hint: Option<String>,
8 },
9 InvalidParams(String),
10 #[allow(dead_code)]
14 ResultTooLarge {
15 row_count: usize,
16 payload_bytes: usize,
17 },
18 Sql {
19 sqlstate: String,
20 message: String,
21 detail: Option<String>,
22 hint: Option<String>,
23 position: Option<String>,
24 },
25 Internal(String),
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ConnectError {
30 pub error: String,
31 pub sqlstate: Option<String>,
32 pub message: Option<String>,
33 pub detail: Option<String>,
34 pub hint: Option<String>,
35 pub retryable: bool,
36}
37
38impl ConnectError {
39 pub fn new(error: impl Into<String>) -> Self {
40 let error = error.into();
41 Self {
42 hint: connect_hint_for_message(&error).or_else(|| Some(default_connect_hint())),
43 retryable: connect_retryable_for_message(&error),
44 error,
45 sqlstate: None,
46 message: None,
47 detail: None,
48 }
49 }
50
51 pub fn from_pg_error(prefix: &str, err: tokio_postgres::Error) -> Self {
52 if let Some(db) = err.as_db_error() {
53 let sqlstate = db.code().code().to_string();
54 let pg_hint = db.hint().map(std::string::ToString::to_string);
55 let hint = pg_hint
56 .clone()
57 .or_else(|| connect_hint_for_sqlstate(&sqlstate, db.message()));
58 return Self {
59 error: format!("{prefix}: {}", db.message()),
60 sqlstate: Some(sqlstate),
61 message: Some(db.message().to_string()),
62 detail: db.detail().map(std::string::ToString::to_string),
63 hint,
64 retryable: connect_retryable_for_sqlstate(db.code().code()),
65 };
66 }
67
68 let error = format!("{prefix}: {}", format_error_chain(&err));
69 Self {
70 hint: connect_hint_for_message(&error).or_else(|| Some(default_connect_hint())),
71 retryable: connect_retryable_for_message(&error),
72 error,
73 sqlstate: None,
74 message: None,
75 detail: None,
76 }
77 }
78}
79
80impl From<String> for ConnectError {
81 fn from(value: String) -> Self {
82 Self::new(value)
83 }
84}
85
86impl From<&str> for ConnectError {
87 fn from(value: &str) -> Self {
88 Self::new(value)
89 }
90}
91
92pub(super) fn map_pg_error(err: tokio_postgres::Error) -> ExecError {
93 if let Some(db) = err.as_db_error() {
94 return ExecError::Sql {
95 sqlstate: db.code().code().to_string(),
96 message: db.message().to_string(),
97 detail: db.detail().map(std::string::ToString::to_string),
98 hint: db.hint().map(std::string::ToString::to_string),
99 position: db.position().map(|p| match p {
100 tokio_postgres::error::ErrorPosition::Original(pos) => pos.to_string(),
101 tokio_postgres::error::ErrorPosition::Internal { position, .. } => {
102 position.to_string()
103 }
104 }),
105 };
106 }
107 ExecError::Internal(err.to_string())
108}
109
110pub(super) fn map_connect_error(err: tokio_postgres::Error) -> ExecError {
111 ExecError::Connect(Box::new(ConnectError::from_pg_error("connect failed", err)))
112}
113
114impl From<crate::conn::ConnectionConfigError> for ExecError {
115 fn from(err: crate::conn::ConnectionConfigError) -> Self {
116 ExecError::Config {
117 message: err.message().to_string(),
118 hint: err.hint().map(std::string::ToString::to_string),
119 }
120 }
121}
122
123fn format_error_chain(err: &(dyn std::error::Error + 'static)) -> String {
124 let mut out = err.to_string();
125 let mut source = err.source();
126 while let Some(err) = source {
127 let part = err.to_string();
128 if !part.is_empty() && !out.contains(&part) {
129 out.push_str(": ");
130 out.push_str(&part);
131 }
132 source = err.source();
133 }
134 out
135}
136
137fn connect_retryable_for_sqlstate(sqlstate: &str) -> bool {
138 sqlstate.starts_with("08")
139 || matches!(
140 sqlstate,
141 "57P03" | "53300" | "53400" | "58000" | "58030" )
147}
148
149fn connect_hint_for_sqlstate(sqlstate: &str, message: &str) -> Option<String> {
150 let hint = match sqlstate {
151 "28P01" => {
152 "password authentication failed; check --user and --password-secret-env PGPASSWORD, or use an authentication method accepted by pg_hba.conf"
153 }
154 "28000" => {
155 if message.contains("role") && message.contains("does not exist") {
156 "PostgreSQL rejected the role; check --user/PGUSER, create the role, or for local peer auth use a matching OS user or --ssh-sudo-user postgres with --ssh-remote-socket"
157 } else {
158 "PostgreSQL authentication or authorization failed; check pg_hba.conf, --user/PGUSER, database access, and whether peer/password auth is expected"
159 }
160 }
161 "3D000" => {
162 "database does not exist; check --dbname/PGDATABASE or connect to the postgres maintenance database to inspect available databases"
163 }
164 "57P03" => {
165 "PostgreSQL is not accepting connections yet; retry after the service finishes starting or leaves recovery/maintenance"
166 }
167 "53300" => {
168 "PostgreSQL has too many active connections; wait, terminate idle sessions, or raise max_connections/pool limits"
169 }
170 "53400" => {
171 "PostgreSQL rejected the connection because a configured limit was exceeded; inspect server logs and connection limits"
172 }
173 state if state.starts_with("08") => {
174 "connection exception from PostgreSQL; check host/port/socket path, SSH tunnel, listener status, and network reachability"
175 }
176 _ => return None,
177 };
178 Some(hint.to_string())
179}
180
181fn connect_hint_for_message(message: &str) -> Option<String> {
182 if message.contains("password missing") {
183 if message.contains("container") {
184 return Some("PostgreSQL requested password authentication but no password was provided; set --password-secret-env PGPASSWORD or --password-secret, or use peer auth over the container socket: --container-user <db-os-user> --host /var/run/postgresql (the --container-user, e.g. postgres, must match the database role)".to_string());
185 }
186 return Some("PostgreSQL requested password authentication but no password was provided; set --password-secret-env PGPASSWORD or --password-secret, or use a peer/socket authentication path".to_string());
187 }
188 if message.contains("error connecting to server") && message.contains("Operation not permitted")
189 {
190 return Some("local sandbox or OS policy blocked opening the PostgreSQL connection; in Codex request escalation, or check host/port/socket reachability outside the sandbox".to_string());
191 }
192 if message.contains("allocate ssh local port") && message.contains("Operation not permitted") {
193 return Some("local sandbox or OS policy blocked binding the SSH tunnel port; in Codex request escalation, or use SSH sudo Unix-socket bridge mode when appropriate".to_string());
194 }
195 if message.contains("single PostgreSQL host and port") {
196 return Some("SSH and container transports currently target one PostgreSQL endpoint; use a DSN/conninfo with one host, or choose one host explicitly with discrete connection fields".to_string());
197 }
198 if message.contains("container bridge") || message.contains("container transport") {
199 return Some("check the container target, runtime access, driver selection, and whether PostgreSQL is listening on the requested container-internal host/port or socket".to_string());
200 }
201 if message.contains("explicit remote PostgreSQL Unix socket") {
202 return Some("pass --ssh-remote-socket /var/run/postgresql/.s.PGSQL.5432, or set --host/PGHOST to the remote socket directory when not using sudo bridge mode".to_string());
203 }
204 if message.contains(".s.PGSQL") && message.contains("No such file or directory") {
205 return Some("the PostgreSQL Unix socket path does not exist; check the server socket directory, port-derived socket filename, and whether PostgreSQL is running".to_string());
206 }
207 if message.contains("ssh tunnel") || message.contains("start ssh") {
208 return Some("check SSH reachability/options and whether PostgreSQL is listening on the requested remote host/port or socket".to_string());
209 }
210 None
211}
212
213fn connect_retryable_for_message(message: &str) -> bool {
214 !(message.contains("password missing")
215 || message.contains("single PostgreSQL host and port")
216 || message.contains("cannot combine a PostgreSQL Unix socket with hostaddr")
217 || message.contains("explicit remote PostgreSQL Unix socket"))
218}
219
220fn default_connect_hint() -> String {
221 "check the DSN/conninfo or --host/--port/PGHOST/PGPORT; for remote local-only PostgreSQL use --ssh user@server (DSN/conninfo targets are interpreted from the final SSH host); for container-local PostgreSQL use --container TARGET; for containers on an SSH host combine --ssh user@server --container TARGET; for sudo-only Unix-socket access use --ssh-sudo-user with an explicit --ssh-remote-socket, or set --host/PGHOST to the remote socket directory".to_string()
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn connect_hints_classify_common_sqlstates() {
230 let auth = connect_hint_for_sqlstate("28P01", "password authentication failed");
231 assert!(
232 auth.as_deref()
233 .unwrap_or_default()
234 .contains("password authentication failed")
235 );
236 assert!(!connect_retryable_for_sqlstate("28P01"));
237
238 let missing_role = connect_hint_for_sqlstate("28000", "role \"root\" does not exist");
239 assert!(
240 missing_role
241 .as_deref()
242 .unwrap_or_default()
243 .contains("--user")
244 );
245 assert!(!connect_retryable_for_sqlstate("28000"));
246
247 let db = connect_hint_for_sqlstate("3D000", "database does not exist");
248 assert!(db.as_deref().unwrap_or_default().contains("--dbname"));
249 assert!(!connect_retryable_for_sqlstate("3D000"));
250
251 let startup = connect_hint_for_sqlstate("57P03", "cannot connect now");
252 assert!(startup.as_deref().unwrap_or_default().contains("retry"));
253 assert!(connect_retryable_for_sqlstate("57P03"));
254 }
255
256 #[test]
257 fn connect_hints_classify_transport_messages() {
258 let password = ConnectError::new("connect failed: invalid configuration: password missing");
259 assert!(
260 password
261 .hint
262 .as_deref()
263 .unwrap_or_default()
264 .contains("PGPASSWORD")
265 );
266 assert!(!password.retryable);
267
268 let container_password = connect_hint_for_message(
269 "connect through container bridge failed: invalid configuration: password missing",
270 );
271 let container_password = container_password.as_deref().unwrap_or_default();
272 assert!(container_password.contains("--container-user"));
273 assert!(container_password.contains("/var/run/postgresql"));
274
275 let sandbox = connect_hint_for_message(
276 "allocate ssh local port on 127.0.0.1 failed: Operation not permitted",
277 );
278 assert!(sandbox.as_deref().unwrap_or_default().contains("sandbox"));
279
280 let tcp_sandbox = connect_hint_for_message(
281 "connect failed: error connecting to server: Operation not permitted (os error 1)",
282 );
283 assert!(
284 tcp_sandbox
285 .as_deref()
286 .unwrap_or_default()
287 .contains("sandbox")
288 );
289
290 let socket = connect_hint_for_message(
291 "--ssh-sudo-user requires an explicit remote PostgreSQL Unix socket",
292 );
293 assert!(
294 socket
295 .as_deref()
296 .unwrap_or_default()
297 .contains("--ssh-remote-socket")
298 );
299 }
300}