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