barbacane_wasm/
secrets.rs1use std::collections::HashMap;
10use std::sync::Arc;
11
12use thiserror::Error;
13
14#[derive(Debug, Error)]
16pub enum SecretsError {
17 #[error("environment variable not found: {0}")]
18 EnvNotFound(String),
19
20 #[error("file not found: {0}")]
21 FileNotFound(String),
22
23 #[error("failed to read file: {0}")]
24 FileReadError(String),
25
26 #[error("unsupported secret scheme: {0}")]
27 UnsupportedScheme(String),
28
29 #[error("invalid secret reference: {0}")]
30 InvalidReference(String),
31}
32
33#[derive(Debug, Clone, Default)]
37pub struct SecretsStore {
38 secrets: Arc<HashMap<String, String>>,
39}
40
41impl SecretsStore {
42 pub fn new() -> Self {
44 Self {
45 secrets: Arc::new(HashMap::new()),
46 }
47 }
48
49 pub fn from_map(secrets: HashMap<String, String>) -> Self {
51 Self {
52 secrets: Arc::new(secrets),
53 }
54 }
55
56 pub fn get(&self, reference: &str) -> Option<&String> {
58 self.secrets.get(reference)
59 }
60
61 pub fn contains(&self, reference: &str) -> bool {
63 self.secrets.contains_key(reference)
64 }
65}
66
67pub fn is_secret_reference(value: &str) -> bool {
69 value.starts_with("env://")
70 || value.starts_with("file://")
71 || value.starts_with("vault://")
72 || value.starts_with("aws-sm://")
73 || value.starts_with("k8s://")
74}
75
76pub fn resolve_secret(reference: &str) -> Result<String, SecretsError> {
82 if let Some(var_name) = reference.strip_prefix("env://") {
83 std::env::var(var_name).map_err(|_| SecretsError::EnvNotFound(var_name.to_string()))
84 } else if let Some(path) = reference.strip_prefix("file://") {
85 std::fs::read_to_string(path)
86 .map(|s| s.trim().to_string())
87 .map_err(|e| {
88 if e.kind() == std::io::ErrorKind::NotFound {
89 SecretsError::FileNotFound(path.to_string())
90 } else {
91 SecretsError::FileReadError(format!("{}: {}", path, e))
92 }
93 })
94 } else if reference.starts_with("vault://")
95 || reference.starts_with("aws-sm://")
96 || reference.starts_with("k8s://")
97 {
98 Err(SecretsError::UnsupportedScheme(
99 reference
100 .split("://")
101 .next()
102 .unwrap_or("unknown")
103 .to_string(),
104 ))
105 } else {
106 Err(SecretsError::InvalidReference(reference.to_string()))
107 }
108}
109
110pub fn collect_secret_references(value: &serde_json::Value) -> Vec<String> {
112 let mut refs = Vec::new();
113 collect_refs_recursive(value, &mut refs);
114 refs
115}
116
117fn collect_refs_recursive(value: &serde_json::Value, refs: &mut Vec<String>) {
118 match value {
119 serde_json::Value::String(s) if is_secret_reference(s) => {
120 refs.push(s.clone());
121 }
122 serde_json::Value::Array(arr) => {
123 for item in arr {
124 collect_refs_recursive(item, refs);
125 }
126 }
127 serde_json::Value::Object(obj) => {
128 for v in obj.values() {
129 collect_refs_recursive(v, refs);
130 }
131 }
132 _ => {}
133 }
134}
135
136pub fn resolve_config_secrets(
138 value: &serde_json::Value,
139 store: &SecretsStore,
140) -> serde_json::Value {
141 match value {
142 serde_json::Value::String(s) => {
143 if is_secret_reference(s) {
144 if let Some(resolved) = store.get(s) {
145 serde_json::Value::String(resolved.clone())
146 } else {
147 value.clone()
149 }
150 } else {
151 value.clone()
152 }
153 }
154 serde_json::Value::Array(arr) => serde_json::Value::Array(
155 arr.iter()
156 .map(|v| resolve_config_secrets(v, store))
157 .collect(),
158 ),
159 serde_json::Value::Object(obj) => {
160 let resolved: serde_json::Map<String, serde_json::Value> = obj
161 .iter()
162 .map(|(k, v)| (k.clone(), resolve_config_secrets(v, store)))
163 .collect();
164 serde_json::Value::Object(resolved)
165 }
166 _ => value.clone(),
167 }
168}
169
170pub fn resolve_all_secrets(
175 configs: &[&serde_json::Value],
176) -> Result<SecretsStore, Vec<SecretsError>> {
177 let mut all_refs: Vec<String> = configs
179 .iter()
180 .flat_map(|c| collect_secret_references(c))
181 .collect();
182 all_refs.sort();
183 all_refs.dedup();
184
185 let mut resolved = HashMap::new();
187 let mut errors = Vec::new();
188
189 for reference in all_refs {
190 match resolve_secret(&reference) {
191 Ok(value) => {
192 resolved.insert(reference, value);
193 }
194 Err(e) => {
195 errors.push(e);
196 }
197 }
198 }
199
200 if errors.is_empty() {
201 Ok(SecretsStore::from_map(resolved))
202 } else {
203 Err(errors)
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn test_is_secret_reference() {
213 assert!(is_secret_reference("env://MY_VAR"));
214 assert!(is_secret_reference("file:///etc/secret"));
215 assert!(is_secret_reference("vault://secrets/key"));
216 assert!(is_secret_reference("aws-sm://prod/key"));
217 assert!(is_secret_reference("k8s://ns/secret/key"));
218
219 assert!(!is_secret_reference("plain-value"));
220 assert!(!is_secret_reference("https://example.com"));
221 assert!(!is_secret_reference(""));
222 }
223
224 #[test]
225 fn test_resolve_env_secret() {
226 std::env::set_var("TEST_SECRET_VAR", "secret-value");
227 let result = resolve_secret("env://TEST_SECRET_VAR");
228 assert_eq!(result.unwrap(), "secret-value");
229 std::env::remove_var("TEST_SECRET_VAR");
230 }
231
232 #[test]
233 fn test_resolve_env_not_found() {
234 let result = resolve_secret("env://NONEXISTENT_VAR_12345");
235 assert!(matches!(result, Err(SecretsError::EnvNotFound(_))));
236 }
237
238 #[test]
239 fn test_resolve_file_secret() {
240 use std::io::Write;
241 let dir = tempfile::tempdir().unwrap();
242 let path = dir.path().join("secret.txt");
243 let mut file = std::fs::File::create(&path).unwrap();
244 writeln!(file, "file-secret-value").unwrap();
245
246 let result = resolve_secret(&format!("file://{}", path.display()));
247 assert_eq!(result.unwrap(), "file-secret-value");
248 }
249
250 #[test]
251 fn test_resolve_file_not_found() {
252 let result = resolve_secret("file:///nonexistent/path/to/secret");
253 assert!(matches!(result, Err(SecretsError::FileNotFound(_))));
254 }
255
256 #[test]
257 fn test_unsupported_scheme() {
258 let result = resolve_secret("vault://secrets/key");
259 assert!(matches!(result, Err(SecretsError::UnsupportedScheme(_))));
260 }
261
262 #[test]
263 fn test_collect_secret_references() {
264 let config = serde_json::json!({
265 "client_id": "my-client",
266 "client_secret": "env://OAUTH_SECRET",
267 "nested": {
268 "key": "file:///etc/key"
269 },
270 "list": ["plain", "env://LIST_VAR"]
271 });
272
273 let refs = collect_secret_references(&config);
274 assert_eq!(refs.len(), 3);
275 assert!(refs.contains(&"env://OAUTH_SECRET".to_string()));
276 assert!(refs.contains(&"file:///etc/key".to_string()));
277 assert!(refs.contains(&"env://LIST_VAR".to_string()));
278 }
279
280 #[test]
281 fn test_resolve_config_secrets() {
282 let config = serde_json::json!({
283 "client_id": "my-client",
284 "client_secret": "env://SECRET"
285 });
286
287 let mut secrets = HashMap::new();
288 secrets.insert("env://SECRET".to_string(), "resolved-secret".to_string());
289 let store = SecretsStore::from_map(secrets);
290
291 let resolved = resolve_config_secrets(&config, &store);
292 assert_eq!(resolved["client_id"], "my-client");
293 assert_eq!(resolved["client_secret"], "resolved-secret");
294 }
295}