skill_context/providers/
file.rs

1//! File-based secret provider.
2//!
3//! This provider reads secrets from files in various formats:
4//! - `.env` format (KEY=value)
5//! - JSON format ({"key": "value"})
6//! - YAML format (key: value)
7//! - Raw format (single secret per file)
8
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::sync::RwLock;
13
14use async_trait::async_trait;
15use zeroize::Zeroizing;
16
17use super::{SecretProvider, SecretValue};
18use crate::secrets::SecretFileFormat;
19use crate::ContextError;
20
21/// Secret provider that reads from files.
22pub struct FileProvider {
23    /// Base path for secret files.
24    base_path: PathBuf,
25    /// File format.
26    format: SecretFileFormat,
27    /// Cached secrets (key -> value).
28    cache: RwLock<HashMap<String, HashMap<String, String>>>,
29    /// Whether to allow writes.
30    allow_writes: bool,
31}
32
33impl FileProvider {
34    /// Create a new file provider.
35    ///
36    /// # Arguments
37    ///
38    /// * `path` - Path to the secrets file or directory
39    /// * `format` - Format of the secrets file(s)
40    pub fn new(path: impl AsRef<Path>, format: SecretFileFormat) -> Result<Self, ContextError> {
41        let base_path = path.as_ref().to_path_buf();
42
43        Ok(Self {
44            base_path,
45            format,
46            cache: RwLock::new(HashMap::new()),
47            allow_writes: false,
48        })
49    }
50
51    /// Allow writes to the secrets file.
52    ///
53    /// By default, the file provider is read-only for safety.
54    pub fn with_writes(mut self) -> Self {
55        self.allow_writes = true;
56        self
57    }
58
59    /// Get the file path for a context's secrets.
60    fn context_file(&self, context_id: &str) -> PathBuf {
61        if self.base_path.is_file() {
62            self.base_path.clone()
63        } else {
64            let ext = self.format.extension();
65            self.base_path.join(format!("{}.{}", context_id, ext))
66        }
67    }
68
69    /// Load secrets from a file.
70    fn load_file(&self, path: &Path) -> Result<HashMap<String, String>, ContextError> {
71        if !path.exists() {
72            return Ok(HashMap::new());
73        }
74
75        // Check permissions (Unix only)
76        #[cfg(unix)]
77        {
78            use std::os::unix::fs::PermissionsExt;
79            let metadata = fs::metadata(path)?;
80            let mode = metadata.permissions().mode();
81
82            // Warn if file is world-readable
83            if mode & 0o004 != 0 {
84                tracing::warn!(
85                    path = %path.display(),
86                    mode = format!("{:o}", mode),
87                    "Secrets file is world-readable, consider restricting permissions"
88                );
89            }
90        }
91
92        let content = fs::read_to_string(path)?;
93
94        match self.format {
95            SecretFileFormat::Env => self.parse_env(&content),
96            SecretFileFormat::Json => self.parse_json(&content),
97            SecretFileFormat::Yaml => self.parse_yaml(&content),
98            SecretFileFormat::Raw => {
99                // For raw format, the filename (without extension) is the key
100                let key = path
101                    .file_stem()
102                    .and_then(|s| s.to_str())
103                    .unwrap_or("secret")
104                    .to_string();
105                let mut map = HashMap::new();
106                map.insert(key, content.trim().to_string());
107                Ok(map)
108            }
109        }
110    }
111
112    /// Parse .env format.
113    fn parse_env(&self, content: &str) -> Result<HashMap<String, String>, ContextError> {
114        let mut secrets = HashMap::new();
115
116        for line in content.lines() {
117            let line = line.trim();
118
119            // Skip empty lines and comments
120            if line.is_empty() || line.starts_with('#') {
121                continue;
122            }
123
124            // Parse KEY=value
125            if let Some((key, value)) = line.split_once('=') {
126                let key = key.trim().to_string();
127                let mut value = value.trim().to_string();
128
129                // Remove surrounding quotes if present
130                if (value.starts_with('"') && value.ends_with('"'))
131                    || (value.starts_with('\'') && value.ends_with('\''))
132                {
133                    value = value[1..value.len() - 1].to_string();
134                }
135
136                secrets.insert(key, value);
137            }
138        }
139
140        Ok(secrets)
141    }
142
143    /// Parse JSON format.
144    fn parse_json(&self, content: &str) -> Result<HashMap<String, String>, ContextError> {
145        let value: serde_json::Value = serde_json::from_str(content)?;
146
147        let mut secrets = HashMap::new();
148
149        if let serde_json::Value::Object(map) = value {
150            for (k, v) in map {
151                let string_value = match v {
152                    serde_json::Value::String(s) => s,
153                    other => other.to_string(),
154                };
155                secrets.insert(k, string_value);
156            }
157        }
158
159        Ok(secrets)
160    }
161
162    /// Parse YAML format.
163    fn parse_yaml(&self, content: &str) -> Result<HashMap<String, String>, ContextError> {
164        // Use serde_json as an intermediate format since we have it as a dependency
165        // A proper implementation would use serde_yaml
166        let value: serde_json::Value = serde_yaml::from_str(content)
167            .map_err(|e| ContextError::Serialization(e.to_string()))?;
168
169        let mut secrets = HashMap::new();
170
171        if let serde_json::Value::Object(map) = value {
172            for (k, v) in map {
173                let string_value = match v {
174                    serde_json::Value::String(s) => s,
175                    other => other.to_string(),
176                };
177                secrets.insert(k, string_value);
178            }
179        }
180
181        Ok(secrets)
182    }
183
184    /// Save secrets to a file.
185    fn save_file(&self, path: &Path, secrets: &HashMap<String, String>) -> Result<(), ContextError> {
186        let content = match self.format {
187            SecretFileFormat::Env => {
188                secrets
189                    .iter()
190                    .map(|(k, v)| format!("{}={}", k, v))
191                    .collect::<Vec<_>>()
192                    .join("\n")
193            }
194            SecretFileFormat::Json => serde_json::to_string_pretty(secrets)?,
195            SecretFileFormat::Yaml => {
196                serde_yaml::to_string(secrets)
197                    .map_err(|e| ContextError::Serialization(e.to_string()))?
198            }
199            SecretFileFormat::Raw => {
200                // Raw format can only store one secret
201                secrets
202                    .values()
203                    .next()
204                    .cloned()
205                    .unwrap_or_default()
206            }
207        };
208
209        // Create parent directory if needed
210        if let Some(parent) = path.parent() {
211            fs::create_dir_all(parent)?;
212        }
213
214        fs::write(path, content)?;
215
216        // Set restrictive permissions (Unix only)
217        #[cfg(unix)]
218        {
219            use std::os::unix::fs::PermissionsExt;
220            let perms = fs::Permissions::from_mode(0o600);
221            fs::set_permissions(path, perms)?;
222        }
223
224        Ok(())
225    }
226
227    /// Get cached secrets for a context, loading from file if needed.
228    fn get_cached(&self, context_id: &str) -> Result<HashMap<String, String>, ContextError> {
229        // Check cache first
230        {
231            let cache = self.cache.read().unwrap();
232            if let Some(secrets) = cache.get(context_id) {
233                return Ok(secrets.clone());
234            }
235        }
236
237        // Load from file
238        let file = self.context_file(context_id);
239        let secrets = self.load_file(&file)?;
240
241        // Update cache
242        {
243            let mut cache = self.cache.write().unwrap();
244            cache.insert(context_id.to_string(), secrets.clone());
245        }
246
247        Ok(secrets)
248    }
249
250    /// Invalidate the cache for a context.
251    fn invalidate_cache(&self, context_id: &str) {
252        let mut cache = self.cache.write().unwrap();
253        cache.remove(context_id);
254    }
255}
256
257#[async_trait]
258impl SecretProvider for FileProvider {
259    async fn get_secret(
260        &self,
261        context_id: &str,
262        key: &str,
263    ) -> Result<Option<SecretValue>, ContextError> {
264        let secrets = self.get_cached(context_id)?;
265
266        Ok(secrets.get(key).map(|v| Zeroizing::new(v.clone())))
267    }
268
269    async fn set_secret(
270        &self,
271        context_id: &str,
272        key: &str,
273        value: &str,
274    ) -> Result<(), ContextError> {
275        if !self.allow_writes {
276            return Err(ContextError::SecretProvider(
277                "File provider is configured as read-only".to_string(),
278            ));
279        }
280
281        let file = self.context_file(context_id);
282        let mut secrets = self.get_cached(context_id)?;
283
284        secrets.insert(key.to_string(), value.to_string());
285
286        self.save_file(&file, &secrets)?;
287        self.invalidate_cache(context_id);
288
289        tracing::info!(
290            context_id = context_id,
291            key = key,
292            file = %file.display(),
293            "Stored secret in file"
294        );
295
296        Ok(())
297    }
298
299    async fn delete_secret(&self, context_id: &str, key: &str) -> Result<(), ContextError> {
300        if !self.allow_writes {
301            return Err(ContextError::SecretProvider(
302                "File provider is configured as read-only".to_string(),
303            ));
304        }
305
306        let file = self.context_file(context_id);
307        let mut secrets = self.get_cached(context_id)?;
308
309        if secrets.remove(key).is_some() {
310            self.save_file(&file, &secrets)?;
311            self.invalidate_cache(context_id);
312
313            tracing::info!(
314                context_id = context_id,
315                key = key,
316                file = %file.display(),
317                "Deleted secret from file"
318            );
319        }
320
321        Ok(())
322    }
323
324    async fn list_keys(&self, context_id: &str) -> Result<Vec<String>, ContextError> {
325        let secrets = self.get_cached(context_id)?;
326        Ok(secrets.keys().cloned().collect())
327    }
328
329    fn name(&self) -> &'static str {
330        "file"
331    }
332
333    fn is_read_only(&self) -> bool {
334        !self.allow_writes
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use tempfile::TempDir;
342
343    #[tokio::test]
344    async fn test_env_format() {
345        let temp_dir = TempDir::new().unwrap();
346        let secrets_file = temp_dir.path().join("test.env");
347
348        fs::write(
349            &secrets_file,
350            r#"
351# Comment line
352API_KEY=secret123
353DB_PASSWORD="quoted value"
354EMPTY=
355"#,
356        )
357        .unwrap();
358
359        let provider = FileProvider::new(&secrets_file, SecretFileFormat::Env).unwrap();
360
361        let api_key = provider.get_secret("test", "API_KEY").await.unwrap();
362        assert_eq!(&*api_key.unwrap(), "secret123");
363
364        let db_pass = provider.get_secret("test", "DB_PASSWORD").await.unwrap();
365        assert_eq!(&*db_pass.unwrap(), "quoted value");
366
367        let empty = provider.get_secret("test", "EMPTY").await.unwrap();
368        assert_eq!(&*empty.unwrap(), "");
369
370        let missing = provider.get_secret("test", "MISSING").await.unwrap();
371        assert!(missing.is_none());
372    }
373
374    #[tokio::test]
375    async fn test_json_format() {
376        let temp_dir = TempDir::new().unwrap();
377        let secrets_file = temp_dir.path().join("secrets.json");
378
379        fs::write(
380            &secrets_file,
381            r#"{"api_key": "secret123", "number": 42, "nested": {"key": "value"}}"#,
382        )
383        .unwrap();
384
385        let provider = FileProvider::new(&secrets_file, SecretFileFormat::Json).unwrap();
386
387        let api_key = provider.get_secret("secrets", "api_key").await.unwrap();
388        assert_eq!(&*api_key.unwrap(), "secret123");
389
390        let number = provider.get_secret("secrets", "number").await.unwrap();
391        assert_eq!(&*number.unwrap(), "42");
392    }
393
394    #[tokio::test]
395    async fn test_yaml_format() {
396        let temp_dir = TempDir::new().unwrap();
397        let secrets_file = temp_dir.path().join("secrets.yaml");
398
399        fs::write(
400            &secrets_file,
401            r#"
402api_key: secret123
403db_password: "quoted value"
404"#,
405        )
406        .unwrap();
407
408        let provider = FileProvider::new(&secrets_file, SecretFileFormat::Yaml).unwrap();
409
410        let api_key = provider.get_secret("secrets", "api_key").await.unwrap();
411        assert_eq!(&*api_key.unwrap(), "secret123");
412    }
413
414    #[tokio::test]
415    async fn test_directory_mode() {
416        let temp_dir = TempDir::new().unwrap();
417
418        let provider = FileProvider::new(temp_dir.path(), SecretFileFormat::Env).unwrap();
419
420        // Create a context file
421        let ctx_file = temp_dir.path().join("my-context.env");
422        fs::write(&ctx_file, "SECRET_KEY=value123").unwrap();
423
424        let secret = provider.get_secret("my-context", "SECRET_KEY").await.unwrap();
425        assert_eq!(&*secret.unwrap(), "value123");
426    }
427
428    #[tokio::test]
429    async fn test_write_secrets() {
430        let temp_dir = TempDir::new().unwrap();
431        let secrets_file = temp_dir.path().join("writable.env");
432
433        let provider = FileProvider::new(&secrets_file, SecretFileFormat::Env)
434            .unwrap()
435            .with_writes();
436
437        // Set a secret
438        provider.set_secret("writable", "NEW_KEY", "new_value").await.unwrap();
439
440        // Read it back
441        let secret = provider.get_secret("writable", "NEW_KEY").await.unwrap();
442        assert_eq!(&*secret.unwrap(), "new_value");
443
444        // Delete it
445        provider.delete_secret("writable", "NEW_KEY").await.unwrap();
446
447        // Verify deleted
448        let secret = provider.get_secret("writable", "NEW_KEY").await.unwrap();
449        assert!(secret.is_none());
450    }
451
452    #[tokio::test]
453    async fn test_read_only_mode() {
454        let temp_dir = TempDir::new().unwrap();
455        let secrets_file = temp_dir.path().join("readonly.env");
456        fs::write(&secrets_file, "").unwrap();
457
458        let provider = FileProvider::new(&secrets_file, SecretFileFormat::Env).unwrap();
459
460        // Should fail to write
461        let result = provider.set_secret("readonly", "KEY", "value").await;
462        assert!(result.is_err());
463
464        assert!(provider.is_read_only());
465    }
466
467    #[tokio::test]
468    async fn test_list_keys() {
469        let temp_dir = TempDir::new().unwrap();
470        let secrets_file = temp_dir.path().join("list.env");
471
472        fs::write(&secrets_file, "KEY1=v1\nKEY2=v2\nKEY3=v3").unwrap();
473
474        let provider = FileProvider::new(&secrets_file, SecretFileFormat::Env).unwrap();
475
476        let keys = provider.list_keys("list").await.unwrap();
477        assert_eq!(keys.len(), 3);
478        assert!(keys.contains(&"KEY1".to_string()));
479        assert!(keys.contains(&"KEY2".to_string()));
480        assert!(keys.contains(&"KEY3".to_string()));
481    }
482
483    #[tokio::test]
484    async fn test_nonexistent_file() {
485        let temp_dir = TempDir::new().unwrap();
486        let nonexistent = temp_dir.path().join("nonexistent.env");
487
488        let provider = FileProvider::new(&nonexistent, SecretFileFormat::Env).unwrap();
489
490        // Should return None, not error
491        let result = provider.get_secret("nonexistent", "KEY").await.unwrap();
492        assert!(result.is_none());
493
494        // List should return empty
495        let keys = provider.list_keys("nonexistent").await.unwrap();
496        assert!(keys.is_empty());
497    }
498}