Skip to main content

stasis/application/config/
secrets.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5/// Lookup surface for configuration and secret values.
6pub trait SecretsSource: Send + Sync {
7    fn lookup(&self, key: &str) -> Option<String>;
8}
9
10/// Reads trimmed non-empty values from the process environment.
11#[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/// Reads one secret file per key from a directory (Vault Agent / ESO file sinks).
24#[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
51/// Resolves a key against multiple sources in order.
52pub 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}