Skip to main content

persistant_relational/
secret.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use crate::RelError;
3use std::fmt;
4use std::path::PathBuf;
5
6/// A connection string that never appears in a `Debug` print, a log line or an error.
7///
8/// A DSN usually carries a password. Held as a plain `String` in a `#[derive(Debug)]` config it is
9/// one `{:?}` away from a log file. This type is where a connection string lives instead: a
10/// *reference* to where it is (an environment variable, a file) or, where a program must, the
11/// literal, which prints as `<redacted>`. The reference variants print their *name*, which is not
12/// a secret.
13///
14/// Resolution happens at open ([`ConnectionSecret::resolve`]); the resolved value is a
15/// [`Redacted`] that prints as `<redacted>` and gives up its text only through
16/// [`Redacted::expose`], a call a reviewer can grep for.
17#[derive(Clone, PartialEq, Eq)]
18pub enum ConnectionSecret {
19    /// Read the connection string from this environment variable when opening.
20    Env(String),
21    /// Read it from this file (trailing whitespace trimmed), as a mounted secret.
22    File(PathBuf),
23    /// The literal. Prefer a reference; this exists for tests and for programs that already
24    /// hold the string.
25    Literal(Redacted),
26}
27
28impl ConnectionSecret {
29    /// A literal connection string.
30    pub fn literal(s: impl Into<String>) -> Self {
31        ConnectionSecret::Literal(Redacted::new(s))
32    }
33
34    /// Resolve to the connection string. Errors name the variable or file, never a value.
35    pub fn resolve(&self) -> Result<Redacted, RelError> {
36        match self {
37            ConnectionSecret::Env(name) => std::env::var(name)
38                .map(Redacted::new)
39                .map_err(|_| RelError::Backend(format!("environment variable {name} is not set"))),
40            ConnectionSecret::File(path) => std::fs::read_to_string(path)
41                .map(|s| Redacted::new(s.trim_end().to_string()))
42                .map_err(|e| {
43                    RelError::Backend(format!(
44                        "connection secret file {} could not be read: {}",
45                        path.display(),
46                        e.kind()
47                    ))
48                }),
49            ConnectionSecret::Literal(r) => Ok(r.clone()),
50        }
51    }
52}
53
54impl fmt::Debug for ConnectionSecret {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            ConnectionSecret::Env(n) => write!(f, "ConnectionSecret::Env({n:?})"),
58            ConnectionSecret::File(p) => write!(f, "ConnectionSecret::File({p:?})"),
59            ConnectionSecret::Literal(_) => f.write_str("ConnectionSecret::Literal(<redacted>)"),
60        }
61    }
62}
63
64/// A string that prints as `<redacted>`; its text comes out only through [`Redacted::expose`].
65#[derive(Clone, PartialEq, Eq)]
66pub struct Redacted(String);
67
68impl Redacted {
69    /// Wrap a secret string.
70    pub fn new(s: impl Into<String>) -> Self {
71        Redacted(s.into())
72    }
73
74    /// The text. The one call that reveals it.
75    pub fn expose(&self) -> &str {
76        &self.0
77    }
78}
79
80impl fmt::Debug for Redacted {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        f.write_str("<redacted>")
83    }
84}
85
86impl fmt::Display for Redacted {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        f.write_str("<redacted>")
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    const SECRET: &str = "postgres://u:hunter2@db/x";
97
98    #[test]
99    fn nothing_prints_the_literal() {
100        let c = ConnectionSecret::literal(SECRET);
101        assert!(!format!("{c:?}").contains("hunter2"));
102        let r = c.resolve().unwrap();
103        assert!(!format!("{r:?} {r}").contains("hunter2"));
104        assert_eq!(
105            r.expose(),
106            SECRET,
107            "control: the text is still reachable on purpose"
108        );
109    }
110
111    #[test]
112    fn a_reference_prints_its_name_and_resolution_errors_never_print_a_value() {
113        let e = ConnectionSecret::Env("PERSISTANT_TEST_SURELY_UNSET_DSN".to_string());
114        assert!(format!("{e:?}").contains("PERSISTANT_TEST_SURELY_UNSET_DSN"));
115        let err = e.resolve().unwrap_err().to_string();
116        assert!(err.contains("PERSISTANT_TEST_SURELY_UNSET_DSN"), "{err}");
117    }
118
119    #[test]
120    fn a_file_secret_is_trimmed_and_unreadable_files_name_the_path_only() {
121        let dir = std::env::temp_dir().join(format!("persistant-secret-{}", std::process::id()));
122        std::fs::create_dir_all(&dir).unwrap();
123        let p = dir.join("dsn");
124        std::fs::write(&p, format!("{SECRET}\n")).unwrap();
125        let r = ConnectionSecret::File(p.clone()).resolve().unwrap();
126        assert_eq!(r.expose(), SECRET);
127        std::fs::remove_dir_all(&dir).unwrap();
128        let err = ConnectionSecret::File(p).resolve().unwrap_err().to_string();
129        assert!(!err.contains("hunter2"), "{err}");
130    }
131}