Skip to main content

cratefield_import_supabase/
secret.rs

1//! The inputs that are secrets, and the one view of them a report may hold.
2//!
3//! ADR 0026, Decision 2: the database URL and the Management API token live
4//! in process memory only, are cleared on drop, never reach a log line, and
5//! the report names the database by host, port and database alone.
6
7use std::fmt;
8use zeroize::Zeroizing;
9
10/// A secret string: cleared on drop, and `Debug` prints `[redacted]`, so a
11/// stray `{:?}` cannot disclose it.
12#[derive(Clone)]
13pub struct Secret(Zeroizing<String>);
14
15impl Secret {
16    /// Wraps `value`.
17    #[must_use]
18    pub fn new(value: impl Into<String>) -> Self {
19        Self(Zeroizing::new(value.into()))
20    }
21
22    /// The value. Named `expose` so a reader has to notice.
23    #[must_use]
24    pub fn expose(&self) -> &str {
25        &self.0
26    }
27}
28
29impl fmt::Debug for Secret {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        f.write_str("[redacted]")
32    }
33}
34
35/// The part of a connection string a report may show.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SourceName {
38    /// The host.
39    pub host: String,
40    /// The port (5432 when the URL names none).
41    pub port: u16,
42    /// The database (`postgres` when the URL names none).
43    pub database: String,
44}
45
46/// Parses `url` as a `postgres://` or `postgresql://` connection string and
47/// returns its host, port and database, and nothing else.
48///
49/// # Errors
50///
51/// A message that never quotes the URL: it may hold a password.
52pub fn source_name(url: &Secret) -> Result<SourceName, String> {
53    let parsed = url::Url::parse(url.expose()).map_err(|_| {
54        "the database URL is not a valid URL (postgres://user:password@host:5432/postgres)"
55            .to_owned()
56    })?;
57    if !matches!(parsed.scheme(), "postgres" | "postgresql") {
58        return Err(format!(
59            "the database URL must start with postgres:// or postgresql://, not {}://",
60            parsed.scheme()
61        ));
62    }
63    let host = parsed
64        .host_str()
65        .filter(|host| !host.is_empty())
66        .ok_or_else(|| "the database URL names no host".to_owned())?
67        .to_owned();
68    let database = parsed.path().trim_start_matches('/');
69    Ok(SourceName {
70        host,
71        port: parsed.port().unwrap_or(5432),
72        database: if database.is_empty() {
73            "postgres".to_owned()
74        } else {
75            database.to_owned()
76        },
77    })
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn debug_never_prints_the_value() {
86        let secret = Secret::new("postgres://u:hunter2@db.example.test/postgres");
87        assert_eq!(format!("{secret:?}"), "[redacted]");
88    }
89
90    #[test]
91    fn the_source_name_keeps_host_port_and_database_only() {
92        let name = source_name(&Secret::new(
93            "postgresql://inspect.ref:hunter2@db.abc.supabase.co:6543/postgres?sslmode=require",
94        ))
95        .expect("parses");
96        assert_eq!(
97            name,
98            SourceName {
99                host: "db.abc.supabase.co".to_owned(),
100                port: 6543,
101                database: "postgres".to_owned(),
102            }
103        );
104    }
105
106    #[test]
107    fn a_bad_url_is_refused_without_quoting_it() {
108        let error = source_name(&Secret::new("hunter2 is not a url")).unwrap_err();
109        assert!(!error.contains("hunter2"), "{error}");
110        let error = source_name(&Secret::new("mysql://u:hunter2@h/db")).unwrap_err();
111        assert!(!error.contains("hunter2"), "{error}");
112    }
113}