Skip to main content

sqlx_gen/
error.rs

1use std::io;
2
3#[derive(Debug, thiserror::Error)]
4pub enum Error {
5    #[error("Database connection failed ({redacted_url}): {source}")]
6    Connection {
7        redacted_url: String,
8        #[source]
9        source: sqlx::Error,
10    },
11
12    #[error("Permission denied while introspecting: {detail}. Check the DB user's privileges on information_schema / pg_catalog / sqlite_master.")]
13    PermissionDenied { detail: String },
14
15    #[error("Schema or relation not found: {detail}. Check `--schemas` and ensure the database contains the expected tables.")]
16    SchemaNotFound { detail: String },
17
18    #[error("Database error: {0}")]
19    Database(#[from] sqlx::Error),
20
21    #[error("IO error: {0}")]
22    Io(#[from] io::Error),
23
24    #[error("{0}")]
25    Config(String),
26}
27
28pub type Result<T> = std::result::Result<T, Error>;
29
30/// Inspect a [`sqlx::Error`] and, if it carries a SQLSTATE we know how to
31/// explain, return a richer [`Error`] variant. Otherwise the input is wrapped
32/// in [`Error::Database`] unchanged so callers can keep using `?`.
33pub fn contextualize_sqlx_error(err: sqlx::Error) -> Error {
34    use sqlx::Error as Sx;
35    let code: Option<String> = match &err {
36        Sx::Database(db) => db.code().map(|c| c.to_string()),
37        _ => None,
38    };
39    if let Some(code) = code {
40        // PG: 42501 insufficient_privilege; MySQL: 42000 / 28000.
41        // PG: 42P01 undefined_table, 3F000 invalid_schema_name; MySQL: 42S02.
42        match code.as_str() {
43            "42501" | "28000" => {
44                return Error::PermissionDenied {
45                    detail: err.to_string(),
46                };
47            }
48            "42P01" | "3F000" | "42S02" => {
49                return Error::SchemaNotFound {
50                    detail: err.to_string(),
51                };
52            }
53            _ => {}
54        }
55    }
56    Error::Database(err)
57}
58
59/// Redact `user:password@host` → `user:****@host` in a database URL so it can
60/// be embedded in error messages and logs without leaking credentials.
61pub fn redact_url(url: &str) -> String {
62    let (scheme, rest) = match url.split_once("://") {
63        Some(pair) => pair,
64        None => return url.to_string(),
65    };
66    let (userinfo, host_part) = match rest.split_once('@') {
67        Some(pair) => pair,
68        None => return url.to_string(),
69    };
70    let redacted_userinfo = match userinfo.split_once(':') {
71        Some((user, _pw)) => format!("{}:****", user),
72        None => userinfo.to_string(),
73    };
74    format!("{}://{}@{}", scheme, redacted_userinfo, host_part)
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn redacts_password_in_postgres_url() {
83        assert_eq!(
84            redact_url("postgres://alice:s3cret@localhost:5432/db"),
85            "postgres://alice:****@localhost:5432/db"
86        );
87    }
88
89    #[test]
90    fn redacts_password_in_mysql_url() {
91        assert_eq!(
92            redact_url("mysql://root:hunter2@db:3306/app"),
93            "mysql://root:****@db:3306/app"
94        );
95    }
96
97    #[test]
98    fn redacts_password_in_postgresql_url() {
99        assert_eq!(
100            redact_url("postgresql://u:p@h/d"),
101            "postgresql://u:****@h/d"
102        );
103    }
104
105    #[test]
106    fn leaves_passwordless_sqlite_url_unchanged() {
107        assert_eq!(redact_url("sqlite:///tmp/test.db"), "sqlite:///tmp/test.db");
108    }
109
110    #[test]
111    fn leaves_no_userinfo_unchanged() {
112        assert_eq!(
113            redact_url("postgres://localhost/db"),
114            "postgres://localhost/db"
115        );
116    }
117
118    #[test]
119    fn leaves_userinfo_without_password_unchanged() {
120        assert_eq!(
121            redact_url("postgres://alice@localhost/db"),
122            "postgres://alice@localhost/db"
123        );
124    }
125
126    #[test]
127    fn leaves_non_url_string_unchanged() {
128        assert_eq!(redact_url("not-a-url"), "not-a-url");
129    }
130
131    #[test]
132    fn contextualize_non_database_error_wraps_unchanged() {
133        let err = sqlx::Error::PoolTimedOut;
134        match contextualize_sqlx_error(err) {
135            Error::Database(_) => {}
136            other => panic!("expected Database, got {:?}", other),
137        }
138    }
139}