1use std::fs::{self, File};
19use std::io::Write;
20use std::path::{Path, PathBuf};
21
22use crate::coordinate::Coordinate;
23use crate::crypto::{KEY_LEN, SealedRecord, open};
24use crate::error::CoreError;
25use crate::record::SecretRecord;
26
27pub const FRAME_MAGIC: &[u8; 4] = b"KOVR";
29pub const FRAME_VERSION: u32 = 1;
31pub const RECORD_EXT: &str = "sec";
33
34const HEADER_LEN: usize = 4 + 4; #[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Quarantined {
40 pub id: String,
42 pub reason: String,
44}
45
46#[derive(Debug, Default)]
48pub struct LoadOutcome {
49 pub records: Vec<(String, SecretRecord)>,
51 pub quarantined: Vec<Quarantined>,
53}
54
55pub fn record_path_for_id(dir: &Path, id: &str) -> PathBuf {
58 dir.join(format!("{id}.{RECORD_EXT}"))
59}
60
61pub fn record_path(dir: &Path, coord: &Coordinate) -> Result<PathBuf, CoreError> {
63 Ok(record_path_for_id(dir, &coord.storage_id()?))
64}
65
66fn frame(sealed: &SealedRecord) -> Result<Vec<u8>, CoreError> {
68 let payload =
69 serde_json::to_vec(sealed).map_err(|e| CoreError::Serialization(e.to_string()))?;
70 let mut out = Vec::with_capacity(HEADER_LEN + payload.len());
71 out.extend_from_slice(FRAME_MAGIC);
72 out.extend_from_slice(&FRAME_VERSION.to_le_bytes());
73 out.extend_from_slice(&payload);
74 Ok(out)
75}
76
77fn unframe(bytes: &[u8]) -> Result<SealedRecord, CoreError> {
79 if bytes.len() < HEADER_LEN || &bytes[..4] != FRAME_MAGIC {
80 return Err(CoreError::Serialization(
81 "not a kovra record frame".to_string(),
82 ));
83 }
84 let version = u32::from_le_bytes(bytes[4..8].try_into().expect("checked length"));
85 if version != FRAME_VERSION {
86 return Err(CoreError::Serialization(format!(
87 "unsupported record frame version {version}"
88 )));
89 }
90 serde_json::from_slice(&bytes[HEADER_LEN..])
91 .map_err(|e| CoreError::Serialization(e.to_string()))
92}
93
94#[cfg(unix)]
99pub(crate) fn restrict(path: &Path, mode: u32) -> Result<(), CoreError> {
100 use std::os::unix::fs::PermissionsExt;
101 fs::set_permissions(path, fs::Permissions::from_mode(mode))
102 .map_err(|e| CoreError::Io(format!("chmod {mode:o}: {e}")))
103}
104
105#[cfg(not(unix))]
106pub(crate) fn restrict(_path: &Path, _mode: u32) -> Result<(), CoreError> {
107 Ok(())
108}
109
110pub fn ensure_dir(dir: &Path) -> Result<(), CoreError> {
112 if !dir.exists() {
113 fs::create_dir_all(dir).map_err(|e| CoreError::Io(format!("create {dir:?}: {e}")))?;
114 restrict(dir, 0o700)?;
115 }
116 Ok(())
117}
118
119pub fn write_record(
122 dir: &Path,
123 coord: &Coordinate,
124 sealed: &SealedRecord,
125) -> Result<(), CoreError> {
126 ensure_dir(dir)?;
127 let path = record_path(dir, coord)?;
128 let tmp = path.with_extension(format!("{RECORD_EXT}.tmp"));
129 let bytes = frame(sealed)?;
130
131 {
132 let mut f = File::create(&tmp).map_err(|e| CoreError::Io(format!("create tmp: {e}")))?;
133 f.write_all(&bytes)
134 .map_err(|e| CoreError::Io(format!("write tmp: {e}")))?;
135 f.sync_all()
136 .map_err(|e| CoreError::Io(format!("fsync tmp: {e}")))?;
137 }
138 restrict(&tmp, 0o600)?;
139
140 if path.exists() {
141 let bak = path.with_extension(format!("{RECORD_EXT}.bak"));
142 fs::rename(&path, &bak).map_err(|e| CoreError::Io(format!("rotate .bak: {e}")))?;
143 }
144 fs::rename(&tmp, &path).map_err(|e| CoreError::Io(format!("rename into place: {e}")))?;
145 Ok(())
146}
147
148pub fn read_record(
152 dir: &Path,
153 coord: &Coordinate,
154 key: &[u8; KEY_LEN],
155) -> Result<Option<SecretRecord>, CoreError> {
156 let path = record_path(dir, coord)?;
157 if !path.exists() {
158 return Ok(None);
159 }
160 let bytes = fs::read(&path).map_err(|e| CoreError::Io(format!("read record: {e}")))?;
161 let sealed = unframe(&bytes)?;
162 Ok(Some(open(&sealed, key)?))
163}
164
165pub fn load_all(dir: &Path, key: &[u8; KEY_LEN]) -> Result<LoadOutcome, CoreError> {
169 let mut outcome = LoadOutcome::default();
170 if !dir.exists() {
171 return Ok(outcome);
172 }
173 let entries = fs::read_dir(dir).map_err(|e| CoreError::Io(format!("read_dir: {e}")))?;
174 for entry in entries {
175 let entry = entry.map_err(|e| CoreError::Io(format!("dir entry: {e}")))?;
176 let path = entry.path();
177 if path.extension().and_then(|e| e.to_str()) != Some(RECORD_EXT) {
178 continue; }
180 let id = path
181 .file_stem()
182 .and_then(|s| s.to_str())
183 .unwrap_or_default()
184 .to_string();
185
186 let opened = fs::read(&path)
187 .map_err(|e| CoreError::Io(format!("read: {e}")))
188 .and_then(|bytes| unframe(&bytes))
189 .and_then(|sealed| open(&sealed, key));
190
191 match opened {
192 Ok(record) => outcome.records.push((id, record)),
193 Err(e) => outcome.quarantined.push(Quarantined {
194 id,
195 reason: e.to_string(),
196 }),
197 }
198 }
199 Ok(outcome)
200}
201
202pub fn delete_record(dir: &Path, coord: &Coordinate) -> Result<(), CoreError> {
205 let path = record_path(dir, coord)?;
206 if path.exists() {
207 fs::remove_file(&path).map_err(|e| CoreError::Io(format!("remove record: {e}")))?;
208 }
209 Ok(())
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use crate::crypto::seal;
216 use crate::secret::SecretValue;
217 use crate::sensitivity::Sensitivity;
218
219 fn key() -> [u8; KEY_LEN] {
220 [0x11; KEY_LEN]
221 }
222
223 fn coord(s: &str) -> Coordinate {
224 s.parse().unwrap()
225 }
226
227 fn literal(value: &str) -> SecretRecord {
228 SecretRecord::Literal {
229 value: SecretValue::from(value),
230 sensitivity: Sensitivity::Medium,
231 revealable: false,
232 environment: "prod".to_string(),
233 component: "db".to_string(),
234 key: "password".to_string(),
235 description: None,
236 created: "2026-05-30T00:00:00Z".to_string(),
237 updated: "2026-05-30T00:00:00Z".to_string(),
238 }
239 }
240
241 #[test]
242 fn write_then_read_round_trips() {
243 let dir = tempfile::tempdir().unwrap();
244 let c = coord("secret:prod/db/password");
245 let sealed = seal(&literal("hunter2"), &key()).unwrap();
246 write_record(dir.path(), &c, &sealed).unwrap();
247
248 let got = read_record(dir.path(), &c, &key()).unwrap().unwrap();
249 match got {
250 SecretRecord::Literal { value, .. } => assert_eq!(value.expose(), b"hunter2"),
251 other => panic!("expected literal, got {other:?}"),
252 }
253 }
254
255 #[test]
256 fn read_missing_is_none() {
257 let dir = tempfile::tempdir().unwrap();
258 let c = coord("secret:prod/db/absent");
259 assert!(read_record(dir.path(), &c, &key()).unwrap().is_none());
260 }
261
262 #[test]
263 fn record_file_has_extension_and_hashed_name() {
264 let dir = tempfile::tempdir().unwrap();
265 let c = coord("secret:prod/db/password");
266 write_record(dir.path(), &c, &seal(&literal("x"), &key()).unwrap()).unwrap();
267 let path = record_path(dir.path(), &c).unwrap();
268 assert!(path.exists());
269 let name = path.file_name().unwrap().to_str().unwrap();
271 assert!(name.ends_with(".sec"));
272 assert!(!name.contains("password"));
273 }
274
275 #[cfg(unix)]
276 #[test]
277 fn written_record_is_0600() {
278 use std::os::unix::fs::PermissionsExt;
279 let dir = tempfile::tempdir().unwrap();
280 let c = coord("secret:prod/db/password");
281 write_record(dir.path(), &c, &seal(&literal("x"), &key()).unwrap()).unwrap();
282 let mode = fs::metadata(record_path(dir.path(), &c).unwrap())
283 .unwrap()
284 .permissions()
285 .mode();
286 assert_eq!(mode & 0o777, 0o600);
287 }
288
289 #[test]
290 fn overwrite_rotates_previous_to_bak() {
291 let dir = tempfile::tempdir().unwrap();
292 let c = coord("secret:prod/db/password");
293 write_record(dir.path(), &c, &seal(&literal("v1"), &key()).unwrap()).unwrap();
294 write_record(dir.path(), &c, &seal(&literal("v2"), &key()).unwrap()).unwrap();
295
296 let current = read_record(dir.path(), &c, &key()).unwrap().unwrap();
298 match current {
299 SecretRecord::Literal { value, .. } => assert_eq!(value.expose(), b"v2"),
300 other => panic!("expected literal, got {other:?}"),
301 }
302 let bak = record_path(dir.path(), &c)
304 .unwrap()
305 .with_extension(format!("{RECORD_EXT}.bak"));
306 assert!(bak.exists());
307 }
308
309 #[test]
310 fn load_all_quarantines_corrupt_and_loads_siblings() {
311 let dir = tempfile::tempdir().unwrap();
312 let good = coord("secret:prod/db/good");
313 let bad = coord("secret:prod/db/bad");
314 write_record(
315 dir.path(),
316 &good,
317 &seal(&literal("good-val"), &key()).unwrap(),
318 )
319 .unwrap();
320 write_record(
321 dir.path(),
322 &bad,
323 &seal(&literal("bad-val"), &key()).unwrap(),
324 )
325 .unwrap();
326
327 let bad_path = record_path(dir.path(), &bad).unwrap();
330 let mut bytes = fs::read(&bad_path).unwrap();
331 let last = bytes.len() - 1;
332 bytes[last] ^= 0xff;
333 fs::write(&bad_path, &bytes).unwrap();
334
335 let outcome = load_all(dir.path(), &key()).unwrap();
336 assert_eq!(outcome.records.len(), 1, "the good record still loads");
337 assert_eq!(
338 outcome.quarantined.len(),
339 1,
340 "the bad record is quarantined"
341 );
342 assert_eq!(outcome.quarantined[0].id, bad.storage_id().unwrap());
343 assert!(!outcome.quarantined[0].reason.contains("bad-val"));
345 }
346
347 #[test]
348 fn load_all_quarantines_garbage_file() {
349 let dir = tempfile::tempdir().unwrap();
350 ensure_dir(dir.path()).unwrap();
351 fs::write(dir.path().join("deadbeef.sec"), b"not a frame").unwrap();
352 let outcome = load_all(dir.path(), &key()).unwrap();
353 assert!(outcome.records.is_empty());
354 assert_eq!(outcome.quarantined.len(), 1);
355 }
356
357 #[test]
358 fn delete_removes_record() {
359 let dir = tempfile::tempdir().unwrap();
360 let c = coord("secret:prod/db/password");
361 write_record(dir.path(), &c, &seal(&literal("x"), &key()).unwrap()).unwrap();
362 delete_record(dir.path(), &c).unwrap();
363 assert!(read_record(dir.path(), &c, &key()).unwrap().is_none());
364 }
365
366 #[test]
367 fn placeholder_coordinate_is_rejected() {
368 let dir = tempfile::tempdir().unwrap();
369 let c = coord("secret:${ENV}/db/password");
370 assert!(matches!(
371 write_record(dir.path(), &c, &seal(&literal("x"), &key()).unwrap()),
372 Err(CoreError::NotStorable(_))
373 ));
374 }
375}