secrets_core/backend/
file.rs1use 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#[derive(Debug, Clone)]
11pub struct FileBackend {
12 root: PathBuf,
13}
14
15impl FileBackend {
16 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 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}