secrets_core/backend/
file.rs

1use crate::backend::{SecretVersion, SecretsBackend, VersionedSecret};
2use crate::errors::{Error as CoreError, Result as CoreResult};
3use crate::types::{Scope, SecretListItem, SecretRecord};
4use crate::uri::SecretUri;
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8
9/// Filesystem-backed secrets storage using JSON-serialised records.
10#[derive(Debug, Clone)]
11pub struct FileBackend {
12    root: PathBuf,
13}
14
15impl FileBackend {
16    /// Construct a new file backend rooted at `root`.
17    pub fn new(root: impl Into<PathBuf>) -> Self {
18        Self { root: root.into() }
19    }
20
21    fn path_for_uri(&self, uri: &SecretUri) -> PathBuf {
22        self.root
23            .join(normalise_segment(uri.scope().env()))
24            .join(normalise_segment(uri.scope().tenant()))
25            .join(
26                uri.scope()
27                    .team()
28                    .map(normalise_segment)
29                    .unwrap_or_else(|| "_".into()),
30            )
31            .join(normalise_segment(uri.category()))
32            .join(normalise_segment(uri.name()))
33    }
34
35    fn read_record(&self, uri: &SecretUri) -> CoreResult<Option<SecretRecord>> {
36        let path = self.path_for_uri(uri);
37        match fs::read(&path) {
38            Ok(bytes) => {
39                let record: SecretRecord = serde_json::from_slice(&bytes)
40                    .map_err(|err| CoreError::Storage(err.to_string()))?;
41                Ok(Some(record))
42            }
43            Err(err) => {
44                if err.kind() == std::io::ErrorKind::NotFound {
45                    Ok(None)
46                } else {
47                    Err(CoreError::Storage(err.to_string()))
48                }
49            }
50        }
51    }
52
53    fn write_record(&self, record: &SecretRecord) -> CoreResult<()> {
54        let path = self.path_for_uri(&record.meta.uri);
55        if let Some(parent) = path.parent() {
56            fs::create_dir_all(parent).map_err(|err| CoreError::Storage(err.to_string()))?;
57        }
58        let data = serde_json::to_vec(record).map_err(|err| CoreError::Storage(err.to_string()))?;
59        let mut file =
60            fs::File::create(&path).map_err(|err| CoreError::Storage(err.to_string()))?;
61        file.write_all(&data)
62            .and_then(|_| file.sync_all())
63            .map_err(|err| CoreError::Storage(err.to_string()))
64    }
65
66    fn delete_record(&self, uri: &SecretUri) -> CoreResult<()> {
67        let path = self.path_for_uri(uri);
68        match fs::remove_file(&path) {
69            Ok(_) => Ok(()),
70            Err(err) => {
71                if err.kind() == std::io::ErrorKind::NotFound {
72                    Err(CoreError::NotFound {
73                        entity: uri.to_string(),
74                    })
75                } else {
76                    Err(CoreError::Storage(err.to_string()))
77                }
78            }
79        }
80    }
81
82    fn base_dir(&self, scope: &Scope) -> PathBuf {
83        self.root
84            .join(normalise_segment(scope.env()))
85            .join(normalise_segment(scope.tenant()))
86            .join(
87                scope
88                    .team()
89                    .map(normalise_segment)
90                    .unwrap_or_else(|| "_".into()),
91            )
92    }
93}
94
95impl SecretsBackend for FileBackend {
96    fn put(&self, record: SecretRecord) -> CoreResult<SecretVersion> {
97        self.write_record(&record)?;
98        Ok(SecretVersion {
99            version: 1,
100            deleted: false,
101        })
102    }
103
104    fn get(&self, uri: &SecretUri, version: Option<u64>) -> CoreResult<Option<VersionedSecret>> {
105        if version.is_some() {
106            // File backend stores only the latest version.
107            return Ok(None);
108        }
109        match self.read_record(uri)? {
110            Some(record) => Ok(Some(VersionedSecret {
111                version: 1,
112                deleted: false,
113                record: Some(record),
114            })),
115            None => Ok(None),
116        }
117    }
118
119    fn list(
120        &self,
121        scope: &Scope,
122        category_prefix: Option<&str>,
123        name_prefix: Option<&str>,
124    ) -> CoreResult<Vec<SecretListItem>> {
125        let base = self.base_dir(scope);
126        if !base.exists() {
127            return Ok(vec![]);
128        }
129
130        let mut items = Vec::new();
131        for category_entry in read_dir_filtered(&base)? {
132            let category_name = category_entry.0;
133            let category_path = category_entry.1;
134            if let Some(prefix) = category_prefix {
135                if !category_name.starts_with(prefix) {
136                    continue;
137                }
138            }
139
140            for secret_entry in read_dir_filtered(&category_path)? {
141                let secret_name = secret_entry.0;
142                let secret_path = secret_entry.1;
143                if let Some(prefix) = name_prefix {
144                    if !secret_name.starts_with(prefix) {
145                        continue;
146                    }
147                }
148
149                let contents =
150                    fs::read(&secret_path).map_err(|err| CoreError::Storage(err.to_string()))?;
151                let record: SecretRecord = serde_json::from_slice(&contents)
152                    .map_err(|err| CoreError::Storage(err.to_string()))?;
153                items.push(SecretListItem::from_meta(
154                    &record.meta,
155                    Some("1".to_string()),
156                ));
157            }
158        }
159
160        Ok(items)
161    }
162
163    fn delete(&self, uri: &SecretUri) -> CoreResult<SecretVersion> {
164        self.delete_record(uri)?;
165        Ok(SecretVersion {
166            version: 1,
167            deleted: true,
168        })
169    }
170
171    fn versions(&self, _uri: &SecretUri) -> CoreResult<Vec<SecretVersion>> {
172        Ok(vec![SecretVersion {
173            version: 1,
174            deleted: false,
175        }])
176    }
177
178    fn exists(&self, uri: &SecretUri) -> CoreResult<bool> {
179        Ok(self.path_for_uri(uri).exists())
180    }
181}
182
183fn normalise_segment(input: &str) -> String {
184    input
185        .chars()
186        .map(|c| match c {
187            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
188            _ => '_',
189        })
190        .collect()
191}
192
193fn read_dir_filtered(path: &Path) -> CoreResult<Vec<(String, PathBuf)>> {
194    let mut entries = Vec::new();
195    for entry in fs::read_dir(path).map_err(|err| CoreError::Storage(err.to_string()))? {
196        let entry = entry.map_err(|err| CoreError::Storage(err.to_string()))?;
197        let file_type = entry
198            .file_type()
199            .map_err(|err| CoreError::Storage(err.to_string()))?;
200        if file_type.is_dir() || file_type.is_file() {
201            let name = entry.file_name().to_string_lossy().into_owned();
202            entries.push((name, entry.path()));
203        }
204    }
205    Ok(entries)
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::types::{ContentType, Envelope, SecretMeta, Visibility};
212    use tempfile::tempdir;
213
214    fn sample_record(uri: SecretUri) -> SecretRecord {
215        let mut meta = SecretMeta::new(uri, Visibility::Team, ContentType::Json);
216        meta.description = Some("file backend".into());
217        let envelope = Envelope {
218            algorithm: crate::types::EncryptionAlgorithm::Aes256Gcm,
219            nonce: vec![1, 2, 3],
220            hkdf_salt: vec![4, 5, 6],
221            wrapped_dek: vec![7, 8, 9],
222        };
223        SecretRecord::new(meta, br#"{"token":"value"}"#.to_vec(), envelope)
224    }
225
226    #[test]
227    fn file_backend_get_and_list() {
228        let dir = tempdir().unwrap();
229        let backend = FileBackend::new(dir.path());
230        let scope = Scope::new("dev", "tenant", Some("team".into())).unwrap();
231        let uri = SecretUri::new(scope.clone(), "configs", "service").unwrap();
232        let record = sample_record(uri.clone());
233
234        backend.write_record(&record).unwrap();
235
236        let fetched = backend.get(&uri, None).unwrap().unwrap();
237        assert_eq!(fetched.record.unwrap().meta.uri, record.meta.uri);
238
239        let items = backend
240            .list(&scope, Some("configs"), Some("service"))
241            .unwrap();
242        assert_eq!(items.len(), 1);
243        assert_eq!(items[0].uri, record.meta.uri);
244    }
245
246    #[test]
247    fn file_backend_missing_returns_none() {
248        let dir = tempdir().unwrap();
249        let backend = FileBackend::new(dir.path());
250        let scope = Scope::new("dev", "tenant", None).unwrap();
251        let uri = SecretUri::new(scope, "configs", "missing").unwrap();
252        assert!(backend.get(&uri, None).unwrap().is_none());
253    }
254}