1//! A secret comes from a file or from an env var, never both: two
2//! sources is an error rather than a precedence rule, which would
3//! silently use the wrong credential (CLAUDE.md *Cost and safety*, "The
4//! API key never appears in SQL or logs"). Errors name the sources,
5//! never the value.
6
7use std::fmt;
8use std::path::{Path, PathBuf};
9
10/// Where one secret may come from.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Sources {
13    /// What the secret is, for errors ("the target password").
14    pub what: String,
15    /// The file named in the config, if any.
16    pub file: Option<PathBuf>,
17    /// The env var that may hold it.
18    pub env: String,
19}
20
21#[derive(Debug, PartialEq, Eq)]
22pub enum SecretError {
23    Both { what: String, file: PathBuf, env: String },
24    Neither { what: String, env: String },
25    Unreadable { what: String, file: PathBuf, reason: String },
26    Empty { what: String, source: String },
27}
28
29impl fmt::Display for SecretError {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            SecretError::Both { what, file, env } => write!(
33                f,
34                "{what} is set twice, in the file {} and in ${env}; set exactly one",
35                file.display()
36            ),
37            SecretError::Neither { what, env } => write!(
38                f,
39                "{what} is not set: name a file in the config or set ${env}"
40            ),
41            SecretError::Unreadable { what, file, reason } => {
42                write!(f, "{what}: cannot read {}: {reason}", file.display())
43            }
44            SecretError::Empty { what, source } => write!(f, "{what} in {source} is empty"),
45        }
46    }
47}
48
49impl std::error::Error for SecretError {}
50
51/// Which source the value came from; the value itself is never kept here.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum Source {
54    File(PathBuf),
55    Env(String),
56}
57
58impl Sources {
59    /// Checks the sources without reading them: the refusal to start
60    /// happens before anything connects. `env` is the env var's value.
61    pub fn choose(&self, env: Option<&str>) -> Result<Option<Source>, SecretError> {
62        let env = env.filter(|v| !v.is_empty());
63        match (&self.file, env) {
64            (Some(file), Some(_)) => Err(SecretError::Both {
65                what: self.what.clone(),
66                file: file.clone(),
67                env: self.env.clone(),
68            }),
69            (Some(file), None) => Ok(Some(Source::File(file.clone()))),
70            (None, Some(_)) => Ok(Some(Source::Env(self.env.clone()))),
71            (None, None) => Ok(None),
72        }
73    }
74
75    /// As [`Sources::choose`], for a secret that must be set.
76    pub fn require(&self, env: Option<&str>) -> Result<Source, SecretError> {
77        self.choose(env)?.ok_or_else(|| SecretError::Neither {
78            what: self.what.clone(),
79            env: self.env.clone(),
80        })
81    }
82
83    /// Reads the secret from the process environment and the filesystem.
84    pub fn resolve(&self, required: bool) -> Result<Option<String>, SecretError> {
85        let env = std::env::var(&self.env).ok();
86        let source = if required {
87            Some(self.require(env.as_deref())?)
88        } else {
89            self.choose(env.as_deref())?
90        };
91        let Some(source) = source else {
92            return Ok(None);
93        };
94        let value = match &source {
95            Source::Env(_) => env.unwrap_or_default(),
96            Source::File(file) => read(&self.what, file)?,
97        };
98        let value = value.trim_end_matches(['\r', '\n']).to_owned();
99        if value.is_empty() {
100            let source = match source {
101                Source::File(f) => f.display().to_string(),
102                Source::Env(e) => format!("${e}"),
103            };
104            return Err(SecretError::Empty { what: self.what.clone(), source });
105        }
106        Ok(Some(value))
107    }
108}
109
110fn read(what: &str, file: &Path) -> Result<String, SecretError> {
111    std::fs::read_to_string(file).map_err(|e| SecretError::Unreadable {
112        what: what.to_owned(),
113        file: file.to_owned(),
114        reason: e.to_string(),
115    })
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn sources(file: Option<&str>) -> Sources {
123        Sources {
124            what: "the target password".into(),
125            file: file.map(PathBuf::from),
126            env: "PGPASSWORD_TARGET".into(),
127        }
128    }
129
130    #[test]
131    fn both_set_is_refused_naming_the_sources_not_the_value() {
132        let err = sources(Some("/run/secrets/pw"))
133            .choose(Some("hunter2"))
134            .unwrap_err()
135            .to_string();
136        assert!(err.contains("/run/secrets/pw"), "{err}");
137        assert!(err.contains("$PGPASSWORD_TARGET"), "{err}");
138        assert!(!err.contains("hunter2"), "{err}");
139    }
140
141    #[test]
142    fn neither_set_is_refused_when_required() {
143        let err = sources(None).require(None).unwrap_err().to_string();
144        assert!(err.contains("$PGPASSWORD_TARGET"), "{err}");
145        assert_eq!(sources(None).choose(None), Ok(None));
146    }
147
148    #[test]
149    fn one_source_is_chosen() {
150        assert_eq!(
151            sources(Some("/f")).require(None),
152            Ok(Source::File("/f".into()))
153        );
154        assert_eq!(
155            sources(None).require(Some("x")),
156            Ok(Source::Env("PGPASSWORD_TARGET".into()))
157        );
158        // An empty env var is unset, as libpq treats PGPASSWORD.
159        assert_eq!(sources(Some("/f")).choose(Some("")), Ok(Some(Source::File("/f".into()))));
160    }
161}