rustango 0.60.7

Batteries-included web framework for Rust: ORM + migrations + auto-admin + multi-tenancy + audit log + auth (sessions, JWT, OAuth2/OIDC, HMAC) + APIs (ViewSet, OpenAPI auto-derive, JSON:API) + jobs (in-mem + Postgres) + email + media (S3 / R2 / B2 / MinIO + presigned uploads + collections + tags) + production middleware (CSRF, CSP, rate-limiting, compression, idempotency, etc.).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
//! Turning a driver's connect failure into something an operator can act
//! on.
//!
//! `sqlx` reports what went wrong at its own layer, which is often three
//! layers below the thing the reader has to change. The motivating case
//! (#1306) cost a reporter a debugging session:
//!
//! ```text
//! encountered unexpected or invalid data: Postgres protocol error
//! (reading ErrorResponse): Postgres returned a non-UTF-8 string for its
//! error message. This is most likely due to an error that occurred
//! during authentication and the default lc_messages locale is not
//! binary-compatible with UTF-8.
//! ```
//!
//! What had actually happened: a local PostgreSQL install was already on
//! 5432, so the Docker container was never reached at all. The local
//! server answered, rejected the container's credentials, and returned
//! the rejection in cp1252. Every word of that message is true and none
//! of it points at the port conflict — and it names `lc_messages`, which
//! sends the reader off configuring server locales.
//!
//! So: classify the failure into something that names the fix.
//!
//! ## Classification is on the variant, not the message
//!
//! Matching driver strings is brittle and localised. Every rule here
//! keys on an `sqlx::Error` variant or a `SQLSTATE`/errno, both of which
//! are stable and language-independent. The driver's own text is still
//! carried through as `detail` — it is genuinely useful once you know
//! which *kind* of problem you are reading about.
//!
//! ## Credentials never appear
//!
//! A connection URL has a password in it, and these strings end up in
//! terminals, logs and HTTP responses.
//! [`redact`](crate::sql::connect_diagnosis::redact) is applied to every
//! endpoint this module renders.

use std::fmt;

/// What went wrong, in terms of what the operator has to change.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectFault {
    /// Nothing answered: connection refused, host not found, network
    /// unreachable, or the attempt timed out.
    Unreachable,
    /// Something answered but the conversation made no sense — the
    /// classic symptom of a *different* server on the expected port.
    UnexpectedServer,
    /// TLS could not be negotiated.
    TlsRejected,
    /// Reached the server; it refused the credentials.
    AuthFailed,
    /// Reached the server and authenticated; the named database is not
    /// there, or (MySQL 1044) this user may not see it.
    NoSuchDatabase,
    /// Connected fine, but this role may not do what a tenant needs —
    /// create tables, most importantly. The failure a naive `SELECT 1`
    /// check sails straight past and a migration then dies on.
    PermissionDenied,
    /// Nothing above matched.
    Other,
}

impl ConnectFault {
    /// One line naming what to change. Deliberately imperative: the
    /// reader is stuck, and a description of the symptom does not help
    /// them.
    #[must_use]
    pub fn advice(self) -> &'static str {
        match self {
            Self::Unreachable => {
                "nothing is listening there — check the host and port, that the server is \
                 running, and that a firewall or container network is not in the way"
            }
            // The #1306 hint. Leading with the port conflict because on
            // a developer machine that is overwhelmingly what it is.
            Self::UnexpectedServer => {
                "something other than the expected database answered on this port — often a \
                 local PostgreSQL or MySQL install shadowing a Docker container. Check what \
                 is actually listening, and that the URL's scheme matches that server"
            }
            Self::TlsRejected => {
                "TLS could not be negotiated — check the `sslmode`/`ssl-mode` in the URL and \
                 whether the server requires (or refuses) encryption"
            }
            Self::AuthFailed => {
                "the server refused these credentials — check the username and password"
            }
            Self::NoSuchDatabase => {
                "the server is reachable but this user sees no such database — create it, \
                 grant this user access to it, or fix the name in the URL"
            }
            Self::PermissionDenied => {
                "connected, but this role cannot create tables — grant it schema-level CREATE, \
                 or migrations will fail partway through"
            }
            Self::Other => "the connection attempt failed",
        }
    }
}

/// A classified connect failure, ready to show someone.
#[derive(Debug, Clone)]
pub struct ConnectDiagnosis {
    pub fault: ConnectFault,
    /// Where we tried, with any password removed.
    pub endpoint: String,
    /// The driver's own message. Kept because it is the detail that
    /// distinguishes two failures of the same kind.
    pub detail: String,
}

impl ConnectDiagnosis {
    /// Classify `err`, which came from trying to connect to `url`.
    #[must_use]
    pub fn of(url: &str, err: &sqlx::Error) -> Self {
        Self {
            fault: classify(err),
            endpoint: redact(url),
            detail: err.to_string(),
        }
    }

    /// Build one directly — for a fault detected by a probe rather than
    /// raised by the driver, such as a `CREATE TABLE` that came back
    /// denied.
    #[must_use]
    pub fn new(fault: ConnectFault, url: &str, detail: impl Into<String>) -> Self {
        Self {
            fault,
            endpoint: redact(url),
            detail: detail.into(),
        }
    }
}

impl ConnectDiagnosis {
    /// The advice and the endpoint, without the driver's text: for
    /// anything stored or shown past the person probing (#2212).
    #[must_use]
    pub fn summary(&self) -> String {
        format!("{} (tried {})", self.fault.advice(), self.endpoint)
    }
}

impl fmt::Display for ConnectDiagnosis {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // Advice first: it is the part that is actionable, and the part
        // a reader skimming a wall of output needs to land on.
        write!(
            f,
            "{} (tried {}) — driver said: {}",
            self.fault.advice(),
            self.endpoint,
            self.detail
        )
    }
}

/// Classify an `sqlx::Error` raised while connecting.
fn classify(err: &sqlx::Error) -> ConnectFault {
    match err {
        // A protocol error *at connect time* means the bytes coming back
        // were not what this driver speaks. On a dev machine that is
        // nearly always another server on the port. (#1306's non-UTF-8
        // `ErrorResponse` lands here, as does pointing a `postgres://`
        // URL at a MySQL server.)
        sqlx::Error::Protocol(_) => ConnectFault::UnexpectedServer,
        sqlx::Error::Tls(_) => ConnectFault::TlsRejected,
        // Every I/O failure at connect time — refused, no such host,
        // network unreachable, timed out — is one story to the reader:
        // nothing answered. `PoolTimedOut` is the same story told by
        // the pool rather than the socket.
        sqlx::Error::Io(_) | sqlx::Error::PoolTimedOut => ConnectFault::Unreachable,
        sqlx::Error::Database(db) => classify_db(db.as_ref()),
        _ => ConnectFault::Other,
    }
}

/// Map a backend's own error code.
///
/// Three vocabularies with one meaning each — and one trap. `code()` on
/// the `DatabaseError` trait is the `SQLSTATE`, which `MySQL` also
/// reports, but its `SQLSTATE`s are far coarser than its error numbers:
/// a missing database is `42000`, the same generic class as a syntax
/// error. So `MySQL` is classified on `number()`, reached by downcast,
/// with `SQLSTATE` only as a fallback.
fn classify_db(db: &dyn sqlx::error::DatabaseError) -> ConnectFault {
    #[cfg(feature = "mysql")]
    if let Some(my) = db.try_downcast_ref::<sqlx::mysql::MySqlDatabaseError>() {
        return match my.number() {
            // 1045 access denied for user
            1045 => ConnectFault::AuthFailed,
            // 1049 unknown database. 1044 "access denied to database" too:
            // MySQL sends it for a missing database the user has no global
            // rights to see, so the two cannot be told apart (#1678).
            1049 | 1044 => ConnectFault::NoSuchDatabase,
            // 1142 table command denied
            1142 => ConnectFault::PermissionDenied,
            _ => ConnectFault::Other,
        };
    }

    let Some(code) = db.code() else {
        return ConnectFault::Other;
    };
    // Two dialects' codes share a couple of meanings, so some arms have
    // identical bodies. Merging them is what clippy wants and would
    // make this unreadable: the grouping by dialect is the only thing
    // that makes a five-character SQLSTATE and a bare `8` legible side
    // by side, and the next person adding a code needs to see where it
    // goes.
    #[allow(clippy::match_same_arms)]
    match code.as_ref() {
        // ---- PostgreSQL (SQLSTATE) ----
        // 28P01 invalid_password, 28000 invalid_authorization_specification
        "28P01" | "28000" => ConnectFault::AuthFailed,
        // 3D000 invalid_catalog_name
        "3D000" => ConnectFault::NoSuchDatabase,
        // 42501 insufficient_privilege
        "42501" => ConnectFault::PermissionDenied,

        // ---- SQLite (extended result codes, as strings) ----
        // 14 SQLITE_CANTOPEN — the file (or its directory) is not there
        "14" => ConnectFault::Unreachable,
        // 8 SQLITE_READONLY, 3 SQLITE_PERM, 1032 READONLY_DBMOVED
        "8" | "3" | "1032" => ConnectFault::PermissionDenied,

        _ => ConnectFault::Other,
    }
}

/// A connection URL with its password replaced.
///
/// These strings reach terminals, log files and — once the operator
/// console lands — HTTP responses. A URL is the single most likely place
/// for a credential to escape into one of those, so redaction happens
/// here rather than at each call site, where it would eventually be
/// forgotten.
///
/// Everything else is kept: the host, the port and the database name are
/// exactly what the reader needs to see.
#[must_use]
pub fn redact(url: &str) -> String {
    let url = redact_userinfo(url);
    // sqlx also reads `password=` from the query. Every `?`/`&` chunk is
    // checked, so it is masked even if the userinfo pass ate the `?`.
    url.split_inclusive(['?', '&'])
        .map(|piece| {
            let chunk = piece.trim_end_matches(['?', '&']);
            let delim = &piece[chunk.len()..];
            match chunk.split_once('=') {
                Some((k, _)) if crate::url_codec::url_decode(k) == "password" => {
                    format!("{k}=***{delim}")
                }
                _ => piece.to_owned(),
            }
        })
        .collect()
}

/// `url` as scheme, userinfo and the rest after the `@`, when it has a userinfo.
pub(crate) fn split_userinfo(url: &str) -> Option<(&str, &str, &str)> {
    // `scheme://user:password@host:port/db?params`. Userinfo runs to the last
    // `@` before any `?`/`#`: a password may hold a raw `@` or `/` (#2109).
    // `sqlite:path` and friends carry no credentials.
    let (scheme, rest) = url.split_once("://")?;
    let authority = rest.split(['?', '#']).next().unwrap_or(rest);
    let at = authority.rfind('@')?;
    Some((scheme, &rest[..at], &rest[at + 1..]))
}

fn redact_userinfo(url: &str) -> String {
    let Some((scheme, userinfo, hostpart)) = split_userinfo(url) else {
        return url.to_owned();
    };
    let user = userinfo.split_once(':').map_or(userinfo, |(u, _)| u);
    if userinfo.contains(':') {
        format!("{scheme}://{user}:***@{hostpart}")
    } else {
        format!("{scheme}://{userinfo}@{hostpart}")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn redact_removes_the_password_and_keeps_everything_useful() {
        assert_eq!(
            redact("postgres://app:s3cret@db.internal:5432/acme"),
            "postgres://app:***@db.internal:5432/acme"
        );
    }

    #[test]
    fn redact_leaves_a_url_with_no_password_alone() {
        assert_eq!(
            redact("postgres://app@localhost:5432/acme"),
            "postgres://app@localhost:5432/acme"
        );
        assert_eq!(
            redact("postgres://localhost:5432/acme"),
            "postgres://localhost:5432/acme"
        );
    }

    #[test]
    fn redact_leaves_sqlite_paths_alone() {
        assert_eq!(
            redact("sqlite:./dev.db?mode=rwc"),
            "sqlite:./dev.db?mode=rwc"
        );
        assert_eq!(redact("sqlite://./dev.db"), "sqlite://./dev.db");
    }

    #[test]
    fn redact_masks_a_password_in_the_query() {
        assert_eq!(
            redact("postgres://db:5432/acme?sslmode=require&password=s3cret"),
            "postgres://db:5432/acme?sslmode=require&password=***"
        );
        let out = redact("postgres://app:pw@db/acme?pass%77ord=s3cret");
        assert!(!out.contains("s3cret") && !out.contains(":pw@"), "{out}");
    }

    /// An `@` inside the password must not be mistaken for the userinfo
    /// separator in a way that leaks the rest of it.
    #[test]
    fn redact_handles_an_at_sign_in_the_password() {
        assert_eq!(
            redact("mysql://root:p@ss@127.0.0.1:3306/app"),
            "mysql://root:***@127.0.0.1:3306/app"
        );
        assert_eq!(
            redact("postgres://u:a@b@c@db/acme?application_name=x@y"),
            "postgres://u:***@db/acme?application_name=x@y"
        );
    }

    /// An `@` after the path is not userinfo; it must not hide a `password=`.
    #[test]
    fn redact_ignores_an_at_sign_in_the_query() {
        assert_eq!(
            redact("postgres://db:5432/app?user=a@b&password=s3cret"),
            "postgres://db:5432/app?user=a@b&password=***"
        );
        let out = redact("postgres://db:5432?user=a@b&password=s3cret");
        assert!(!out.contains("s3cret"), "{out}");
    }

    #[test]
    fn redact_handles_a_slash_in_the_password() {
        assert_eq!(
            redact("postgres://u:pa/ss@host/db"),
            "postgres://u:***@host/db"
        );
        assert_eq!(
            redact("postgres://u:p@ss@host/db"),
            "postgres://u:***@host/db"
        );
    }

    #[test]
    fn a_protocol_error_reads_as_a_port_conflict_not_a_locale_problem() {
        // #1306: the exact class of error a shadowing local server
        // produces.
        let err = sqlx::Error::Protocol(
            "Postgres returned a non-UTF-8 string for its error message. This is most likely \
             due to an error that occurred during authentication and the default lc_messages \
             locale is not binary-compatible with UTF-8."
                .into(),
        );
        let d = ConnectDiagnosis::of(
            "postgres://rustango:rustango@localhost:5432/backend_dev",
            &err,
        );

        assert_eq!(d.fault, ConnectFault::UnexpectedServer);
        let rendered = d.to_string();
        assert!(
            rendered.contains("something other than the expected database answered"),
            "{rendered}"
        );
        assert!(rendered.contains("localhost:5432"), "{rendered}");
        assert!(
            !rendered.contains("rustango:rustango"),
            "credentials leaked: {rendered}"
        );
        // The driver's own text is still there for anyone who wants it —
        // just no longer the headline.
        assert!(rendered.contains("lc_messages"), "{rendered}");
    }

    #[test]
    fn a_refused_connection_reads_as_unreachable() {
        let err = sqlx::Error::Io(std::io::Error::new(
            std::io::ErrorKind::ConnectionRefused,
            "Connection refused (os error 61)",
        ));
        let d = ConnectDiagnosis::of("postgres://app:pw@127.0.0.1:5999/x", &err);
        assert_eq!(d.fault, ConnectFault::Unreachable);
        assert!(d.to_string().contains("nothing is listening"), "{d}");
    }

    #[test]
    fn advice_is_distinct_for_every_fault() {
        let all = [
            ConnectFault::Unreachable,
            ConnectFault::UnexpectedServer,
            ConnectFault::TlsRejected,
            ConnectFault::AuthFailed,
            ConnectFault::NoSuchDatabase,
            ConnectFault::PermissionDenied,
            ConnectFault::Other,
        ];
        let mut seen: Vec<&str> = all.iter().map(|f| f.advice()).collect();
        seen.sort_unstable();
        let before = seen.len();
        seen.dedup();
        assert_eq!(before, seen.len(), "two faults share advice text");
    }
}