Skip to main content

fraiseql_server/secrets_manager/backends/
file.rs

1// Phase 12.1 Cycle 1: File-based Secrets Backend
2//! Backend for reading secrets from local files
3
4use std::path::PathBuf;
5
6use chrono::{Duration, Utc};
7
8use super::super::{SecretsBackend, SecretsError};
9
10/// Secrets backend that reads from local files
11///
12/// Useful for local development and testing
13/// Not recommended for production
14///
15/// # File Format
16/// Each secret stored in separate file as plain text
17/// Filename is the secret name, content is the value
18///
19/// # Example
20/// ```ignore
21/// // Create file ~/.secrets/db_password
22/// let backend = FileBackend::new("~/.secrets");
23/// let secret = backend.get_secret("db_password").await?;
24/// // Reads from ~/.secrets/db_password
25/// ```
26#[derive(Clone, Debug)]
27pub struct FileBackend {
28    base_path: PathBuf,
29}
30
31#[async_trait::async_trait]
32impl SecretsBackend for FileBackend {
33    async fn get_secret(&self, name: &str) -> Result<String, SecretsError> {
34        let path = self.base_path.join(name);
35
36        let content = tokio::fs::read_to_string(&path).await.map_err(|e| {
37            SecretsError::BackendError(format!(
38                "Failed to read secret from {}: {}",
39                path.display(),
40                e
41            ))
42        })?;
43
44        Ok(content.trim().to_string())
45    }
46
47    async fn get_secret_with_expiry(
48        &self,
49        name: &str,
50    ) -> Result<(String, chrono::DateTime<Utc>), SecretsError> {
51        let secret = self.get_secret(name).await?;
52        // File-based secrets don't expire; use 1-year TTL
53        let expiry = Utc::now() + Duration::days(365);
54        Ok((secret, expiry))
55    }
56
57    async fn rotate_secret(&self, name: &str) -> Result<String, SecretsError> {
58        // File-based secrets can't be rotated programmatically
59        Err(SecretsError::RotationError(format!(
60            "Rotation not supported for file-based secret {}",
61            name
62        )))
63    }
64}
65
66impl FileBackend {
67    /// Create new FileBackend with base path
68    pub fn new<P: Into<PathBuf>>(base_path: P) -> Self {
69        FileBackend {
70            base_path: base_path.into(),
71        }
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    /// Test FileBackend reads from file
80    #[tokio::test]
81    async fn test_file_backend_read_secret() {
82        use tempfile::tempdir;
83
84        let dir = tempdir().unwrap();
85        let secret_file = dir.path().join("test_secret");
86        tokio::fs::write(&secret_file, "secret_content_123").await.unwrap();
87
88        let backend = FileBackend::new(dir.path());
89        let secret = backend.get_secret("test_secret").await.unwrap();
90
91        assert_eq!(secret, "secret_content_123");
92    }
93
94    /// Test FileBackend returns error for missing file
95    #[tokio::test]
96    async fn test_file_backend_not_found() {
97        use tempfile::tempdir;
98
99        let dir = tempdir().unwrap();
100        let backend = FileBackend::new(dir.path());
101        let result = backend.get_secret("nonexistent.txt").await;
102
103        assert!(result.is_err());
104    }
105
106    /// Test FileBackend trims whitespace
107    #[tokio::test]
108    async fn test_file_backend_trims_whitespace() {
109        use tempfile::tempdir;
110
111        let dir = tempdir().unwrap();
112        let secret_file = dir.path().join("whitespace_secret");
113        tokio::fs::write(&secret_file, "  secret_value  \n").await.unwrap();
114
115        let backend = FileBackend::new(dir.path());
116        let secret = backend.get_secret("whitespace_secret").await.unwrap();
117
118        assert_eq!(secret, "secret_value");
119    }
120
121    /// Test FileBackend with_expiry returns future date
122    #[tokio::test]
123    async fn test_file_backend_with_expiry() {
124        use tempfile::tempdir;
125
126        let dir = tempdir().unwrap();
127        let secret_file = dir.path().join("expiry_test");
128        tokio::fs::write(&secret_file, "value").await.unwrap();
129
130        let backend = FileBackend::new(dir.path());
131        let (secret, expiry) = backend.get_secret_with_expiry("expiry_test").await.unwrap();
132
133        assert_eq!(secret, "value");
134        assert!(expiry > Utc::now());
135    }
136
137    /// Test FileBackend rotate returns error
138    #[tokio::test]
139    async fn test_file_backend_rotate_not_supported() {
140        use tempfile::tempdir;
141
142        let dir = tempdir().unwrap();
143        let backend = FileBackend::new(dir.path());
144        let result = backend.rotate_secret("any_file").await;
145
146        assert!(result.is_err());
147    }
148
149    /// Test FileBackend with multiple files
150    #[tokio::test]
151    async fn test_file_backend_multiple_secrets() {
152        use tempfile::tempdir;
153
154        let dir = tempdir().unwrap();
155        tokio::fs::write(dir.path().join("secret1"), "value1").await.unwrap();
156        tokio::fs::write(dir.path().join("secret2"), "value2").await.unwrap();
157
158        let backend = FileBackend::new(dir.path());
159
160        let s1 = backend.get_secret("secret1").await.unwrap();
161        let s2 = backend.get_secret("secret2").await.unwrap();
162
163        assert_eq!(s1, "value1");
164        assert_eq!(s2, "value2");
165    }
166
167    /// Test FileBackend handles empty files
168    #[tokio::test]
169    async fn test_file_backend_empty_file() {
170        use tempfile::tempdir;
171
172        let dir = tempdir().unwrap();
173        tokio::fs::write(dir.path().join("empty"), "").await.unwrap();
174
175        let backend = FileBackend::new(dir.path());
176        let secret = backend.get_secret("empty").await.unwrap();
177
178        assert_eq!(secret, "");
179    }
180}