Skip to main content

barbacane_wasm/
secrets.rs

1//! Secrets resolution for WASM plugins.
2//!
3//! Supports resolving secret references from configuration values:
4//! - `env://VAR_NAME` - Environment variable
5//! - `file:///path/to/secret` - File-based secret (trimmed)
6//!
7//! Future: `vault://`, `aws-sm://`, `k8s://` references.
8
9use std::collections::HashMap;
10use std::sync::Arc;
11
12use thiserror::Error;
13
14/// Errors during secret resolution.
15#[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/// Resolved secrets store.
34///
35/// Thread-safe cache of resolved secret values, keyed by reference.
36#[derive(Debug, Clone, Default)]
37pub struct SecretsStore {
38    secrets: Arc<HashMap<String, String>>,
39}
40
41impl SecretsStore {
42    /// Create an empty secrets store.
43    pub fn new() -> Self {
44        Self {
45            secrets: Arc::new(HashMap::new()),
46        }
47    }
48
49    /// Create a secrets store from a map of resolved secrets.
50    pub fn from_map(secrets: HashMap<String, String>) -> Self {
51        Self {
52            secrets: Arc::new(secrets),
53        }
54    }
55
56    /// Get a secret by its reference.
57    pub fn get(&self, reference: &str) -> Option<&String> {
58        self.secrets.get(reference)
59    }
60
61    /// Check if a reference exists in the store.
62    pub fn contains(&self, reference: &str) -> bool {
63        self.secrets.contains_key(reference)
64    }
65}
66
67/// Check if a string value is a secret reference.
68pub 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
76/// Resolve a single secret reference.
77///
78/// Currently supports:
79/// - `env://VAR_NAME` - Environment variable
80/// - `file:///path/to/secret` - File content (trimmed)
81pub 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
110/// Scan a JSON value for secret references and collect them.
111pub 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
136/// Replace secret references in a JSON value with resolved values.
137pub 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                    // Keep original if not resolved (shouldn't happen after validation)
148                    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
170/// Resolve all secrets from a list of configs.
171///
172/// Returns a SecretsStore with all resolved values, or an error if any secret
173/// cannot be resolved.
174pub fn resolve_all_secrets(
175    configs: &[&serde_json::Value],
176) -> Result<SecretsStore, Vec<SecretsError>> {
177    // Collect all unique references
178    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    // Resolve each reference
186    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}