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
30pub 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 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
59pub 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}