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}