A secret comes from a file or from an env var, never both: two sources is an error rather than a precedence rule, which would silently use the wrong credential (CLAUDE.md Cost and safety, "The API key never appears in SQL or logs"). Errors name the sources, never the value.

7use std::fmt;
8use std::path::{Path, PathBuf};

Where one secret may come from.

11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Sources {

What the secret is, for errors ("the target password").

14    pub what: String,

The file named in the config, if any.

16    pub file: Option<PathBuf>,

The env var that may hold it.

18    pub env: String,
19}
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 {}

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}
58impl Sources {

Checks the sources without reading them: the refusal to start 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    }

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    }

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}
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}