stasis/application/config/
secrets.rs1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5pub trait SecretsSource: Send + Sync {
7 fn lookup(&self, key: &str) -> Option<String>;
8}
9
10#[derive(Debug, Default, Clone, Copy)]
12pub struct OsEnvSource;
13
14impl SecretsSource for OsEnvSource {
15 fn lookup(&self, key: &str) -> Option<String> {
16 std::env::var(key)
17 .ok()
18 .map(|value| value.trim().to_string())
19 .filter(|value| !value.is_empty())
20 }
21}
22
23#[derive(Debug, Clone, Default)]
25pub struct FileSecretsSource {
26 secrets: HashMap<String, String>,
27}
28
29impl FileSecretsSource {
30 pub fn from_dir(dir: impl AsRef<Path>) -> Self {
31 Self {
32 secrets: load_secret_files(dir.as_ref()),
33 }
34 }
35
36 pub fn len(&self) -> usize {
37 self.secrets.len()
38 }
39
40 pub fn is_empty(&self) -> bool {
41 self.secrets.is_empty()
42 }
43}
44
45impl SecretsSource for FileSecretsSource {
46 fn lookup(&self, key: &str) -> Option<String> {
47 self.secrets.get(key).cloned()
48 }
49}
50
51pub struct ChainedSecretsSource {
53 sources: Vec<Box<dyn SecretsSource>>,
54}
55
56impl Default for ChainedSecretsSource {
57 fn default() -> Self {
58 Self::new()
59 }
60}
61
62impl ChainedSecretsSource {
63 pub fn new() -> Self {
64 Self {
65 sources: Vec::new(),
66 }
67 }
68
69 pub fn with_source(mut self, source: impl SecretsSource + 'static) -> Self {
70 self.sources.push(Box::new(source));
71 return self;
72 }
73}
74
75impl SecretsSource for ChainedSecretsSource {
76 fn lookup(&self, key: &str) -> Option<String> {
77 self.sources
78 .iter()
79 .find_map(|source| source.lookup(key))
80 }
81}
82
83static SECRETS_RESOLVER: OnceLock<ChainedSecretsSource> = OnceLock::new();
84
85pub(crate) fn install_resolver(resolver: ChainedSecretsSource) {
86 let _ = SECRETS_RESOLVER.set(resolver);
87}
88
89pub(crate) fn resolve(key: &str) -> Option<String> {
90 SECRETS_RESOLVER
91 .get()
92 .and_then(|resolver| resolver.lookup(key))
93 .or_else(|| OsEnvSource.lookup(key))
94}
95
96fn load_secret_files(dir: &Path) -> HashMap<String, String> {
97 let entries = match std::fs::read_dir(dir) {
98 Ok(entries) => entries,
99 Err(_) => return HashMap::new(),
100 };
101
102 let mut secrets = HashMap::new();
103 for entry in entries.flatten() {
104 let path = entry.path();
105 if !path.is_file() {
106 continue;
107 }
108
109 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
110 continue;
111 };
112 if file_name.starts_with('.') {
113 continue;
114 }
115
116 let Ok(raw) = std::fs::read_to_string(&path) else {
117 continue;
118 };
119 let value = raw.trim().to_string();
120 if value.is_empty() {
121 continue;
122 }
123
124 secrets.insert(file_name.to_string(), value);
125 }
126
127 secrets
128}
129
130pub fn default_secrets_dir() -> Option<PathBuf> {
131 OsEnvSource
132 .lookup("STASIS_SECRETS_DIR")
133 .map(PathBuf::from)
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use std::fs;
140 use std::time::{SystemTime, UNIX_EPOCH};
141
142 fn temp_secrets_dir() -> PathBuf {
143 let nanos = SystemTime::now()
144 .duration_since(UNIX_EPOCH)
145 .expect("clock should be after epoch")
146 .as_nanos();
147 std::env::temp_dir().join(format!("stasis-secrets-{nanos}"))
148 }
149
150 #[test]
151 fn file_secrets_source_reads_trimmed_files() {
152 let dir = temp_secrets_dir();
153 fs::create_dir_all(&dir).expect("temp secrets dir should be created");
154 fs::write(dir.join("STASIS_TEST_SECRET"), " secret-value \n")
155 .expect("secret file should be written");
156
157 let source = FileSecretsSource::from_dir(&dir);
158 assert_eq!(
159 source.lookup("STASIS_TEST_SECRET"),
160 Some("secret-value".to_string())
161 );
162
163 let _ = fs::remove_dir_all(&dir);
164 }
165
166 #[test]
167 fn chained_source_uses_first_match() {
168 let dir = temp_secrets_dir();
169 fs::create_dir_all(&dir).expect("temp secrets dir should be created");
170 fs::write(dir.join("STASIS_CHAINED"), "from-file")
171 .expect("secret file should be written");
172
173 let chain = ChainedSecretsSource::new()
174 .with_source(OsEnvSource)
175 .with_source(FileSecretsSource::from_dir(&dir));
176
177 unsafe {
178 std::env::set_var("STASIS_CHAINED", "from-env");
179 }
180 assert_eq!(chain.lookup("STASIS_CHAINED"), Some("from-env".to_string()));
181 unsafe {
182 std::env::remove_var("STASIS_CHAINED");
183 }
184
185 assert_eq!(chain.lookup("STASIS_CHAINED"), Some("from-file".to_string()));
186
187 let _ = fs::remove_dir_all(&dir);
188 }
189}