1use std::ffi::c_void;
30use std::fs;
31use std::path::{Path, PathBuf};
32
33use snafu::ResultExt;
34
35use crate::backend::{Compartment, Options, StoreBackend, StoreInstance};
36use crate::error::{InvalidPathSnafu, IoSnafu, Result, ValueNotFoundSnafu};
37use crate::register_backend;
38
39pub const OPT_ROOT: &str = "root";
41
42const DEFAULT_ROOT: &str = "~/.local/share/confium/store/";
44
45const SIG_EXT: &str = "sig";
49
50const fn forbidden_char(c: char) -> bool {
52 matches!(c, '/' | '\\' | '\0')
53}
54
55#[cfg(target_os = "windows")]
61fn sanitize_for_filename(s: &str) -> String {
62 s.chars()
63 .map(|c| match c {
64 ':' | '<' | '>' | '"' | '|' | '?' | '*' | '/' | '\\' => '_',
65 other => other,
66 })
67 .collect()
68}
69
70#[cfg(not(target_os = "windows"))]
71fn sanitize_for_filename(s: &str) -> String {
72 s.chars()
73 .map(|c| match c {
74 '/' | '\\' | '\0' => '_',
75 other => other,
76 })
77 .collect()
78}
79
80fn validate_component(component: &str) -> Result<()> {
88 if component.is_empty()
89 || component == "."
90 || component == ".."
91 || component.contains(forbidden_char)
92 {
93 return Err(InvalidPathSnafu {
94 component: component.to_string(),
95 }
96 .build());
97 }
98 Ok(())
99}
100
101fn join_path(root: &Path, module: &str, app: &str, sub: &str, leaf: &str) -> Result<PathBuf> {
104 validate_component(module)?;
105 validate_component(app)?;
106 validate_component(sub)?;
107 validate_component(leaf)?;
108 Ok(root.join(module).join(app).join(sub).join(leaf))
109}
110
111unsafe fn key_bytes(key: *mut c_void) -> Result<&'static [u8]> {
124 if key.is_null() {
125 return Ok(&[]);
126 }
127 let boxed: &Vec<u8> = unsafe { &*(key as *mut Box<Vec<u8>>) };
129 Ok(boxed.as_slice())
130}
131
132fn encode_key(bytes: Vec<u8>) -> *mut c_void {
136 Box::into_raw(Box::new(Box::new(bytes))) as *mut c_void
137}
138
139#[cfg(test)]
142unsafe fn reclaim_key(key: *mut c_void) {
143 if key.is_null() {
144 return;
145 }
146 unsafe {
148 drop(Box::from_raw(key as *mut Box<Vec<u8>>));
149 }
150}
151
152fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
159 if let Some(parent) = path.parent() {
160 fs::create_dir_all(parent).context(IoSnafu {})?;
161 }
162 let dir = path.parent().unwrap_or_else(|| Path::new("."));
163 let tmp = dir.join(format!(
164 ".{}.tmp",
165 path.file_name()
166 .and_then(|s| s.to_str())
167 .unwrap_or("confium")
168 ));
169 fs::write(&tmp, bytes).context(IoSnafu {})?;
170 fs::rename(&tmp, path).context(IoSnafu)?;
171 Ok(())
172}
173
174pub struct FilesystemBackend;
179
180impl StoreBackend for FilesystemBackend {
181 fn name(&self) -> &'static str {
182 "filesystem"
183 }
184
185 fn open(&self, opts: &Options) -> Result<Box<dyn StoreInstance>> {
186 let raw = opts
187 .get(OPT_ROOT)
188 .map(String::as_str)
189 .unwrap_or(DEFAULT_ROOT);
190 let root = expand_tilde(raw);
191 fs::create_dir_all(&root).context(IoSnafu {})?;
192 Ok(Box::new(FilesystemInstance { root }))
193 }
194}
195
196register_backend!(FilesystemBackend);
197
198fn expand_tilde(raw: &str) -> PathBuf {
202 if let Some(rest) = raw.strip_prefix("~/") {
203 if let Some(home) = std::env::var_os("HOME") {
204 return PathBuf::from(home).join(rest);
205 }
206 } else if raw == "~" {
207 if let Some(home) = std::env::var_os("HOME") {
208 return PathBuf::from(home);
209 }
210 }
211 PathBuf::from(raw)
212}
213
214pub struct FilesystemInstance {
215 root: PathBuf,
216}
217
218impl FilesystemInstance {
219 fn private_path(&self, module: &str, app: &str, key_id: &str) -> Result<PathBuf> {
220 join_path(&self.root, module, app, "private", key_id)
221 }
222
223 fn public_key_path(&self, module: &str, app: &str, identity: &str) -> Result<PathBuf> {
224 validate_component(module)?;
225 validate_component(app)?;
226 let identity = sanitize_for_filename(identity);
227 validate_component(&identity)?;
228 Ok(self
229 .root
230 .join(module)
231 .join(app)
232 .join("public")
233 .join(identity))
234 }
235
236 fn public_sig_path(&self, module: &str, app: &str, identity: &str) -> Result<PathBuf> {
237 validate_component(module)?;
246 validate_component(app)?;
247 let identity = sanitize_for_filename(identity);
248 let leaf = format!("{identity}.{SIG_EXT}");
249 validate_component(&leaf)?;
250 Ok(self.root.join(module).join(app).join("public").join(leaf))
251 }
252
253 fn compartment_dir(
257 &self,
258 module: &str,
259 app: &str,
260 compartment: Compartment,
261 ) -> Result<PathBuf> {
262 let sub = match compartment {
263 Compartment::Private => "private",
264 Compartment::Public => "public",
265 };
266 validate_component(module)?;
267 validate_component(app)?;
268 Ok(self.root.join(module).join(app).join(sub))
269 }
270}
271
272impl StoreInstance for FilesystemInstance {
273 fn put_secret(
274 &mut self,
275 module: &str,
276 app: &str,
277 key_id: &str,
278 key: *mut c_void,
279 ) -> Result<()> {
280 let path = self.private_path(module, app, key_id)?;
281 let bytes = unsafe { key_bytes(key) }?;
284 atomic_write(&path, bytes)
285 }
286
287 fn get_secret(&self, module: &str, app: &str, key_id: &str) -> Result<*mut c_void> {
288 read_or_not_found(&self.private_path(module, app, key_id)?).map(encode_key)
289 }
290
291 fn put_public(
292 &mut self,
293 module: &str,
294 app: &str,
295 identity: &str,
296 key: *mut c_void,
297 sig: &[u8],
298 ) -> Result<()> {
299 let key_path = self.public_key_path(module, app, identity)?;
300 let sig_path = self.public_sig_path(module, app, identity)?;
301 let bytes = unsafe { key_bytes(key) }?;
303 atomic_write(&key_path, bytes)?;
304 atomic_write(&sig_path, sig)
305 }
306
307 fn get_public(
308 &self,
309 module: &str,
310 app: &str,
311 identity: &str,
312 ) -> Result<(*mut c_void, Vec<u8>)> {
313 let key_path = self.public_key_path(module, app, identity)?;
314 let sig_path = self.public_sig_path(module, app, identity)?;
315 let key_bytes = read_or_not_found(&key_path)?;
316 let sig = read_or_not_found(&sig_path)?;
317 Ok((encode_key(key_bytes), sig))
318 }
319
320 fn enumerate(
321 &self,
322 module: &str,
323 app: &str,
324 compartment: Compartment,
325 ) -> Result<Vec<(*mut c_void, String)>> {
326 let dir = self.compartment_dir(module, app, compartment)?;
327 let read = match fs::read_dir(&dir) {
328 Ok(rd) => rd,
329 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
330 return Ok(Vec::new());
331 }
332 Err(e) => return Err(e).context(IoSnafu {}),
333 };
334
335 let mut entries: Vec<(PathBuf, String)> = Vec::new();
336 for entry in read {
337 let entry = entry.context(IoSnafu {})?;
338 let path = entry.path();
339 let Some(name) = path
340 .file_name()
341 .and_then(|s| s.to_str())
342 .map(str::to_string)
343 else {
344 continue;
345 };
346 match compartment {
347 Compartment::Private => {
348 entries.push((path, name));
349 }
350 Compartment::Public => {
351 if path.extension().and_then(|s| s.to_str()) == Some(SIG_EXT) {
356 continue;
357 }
358 entries.push((path, name));
359 }
360 }
361 }
362
363 entries.sort_by(|a, b| a.1.cmp(&b.1));
364
365 let mut out = Vec::with_capacity(entries.len());
366 for (path, index) in entries {
367 let bytes = fs::read(&path).context(IoSnafu {})?;
368 out.push((encode_key(bytes), index));
369 }
370 Ok(out)
371 }
372}
373
374fn read_or_not_found(path: &Path) -> Result<Vec<u8>> {
380 match fs::read(path) {
381 Ok(bytes) => Ok(bytes),
382 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(ValueNotFoundSnafu.build()),
383 Err(e) => Err(e).context(IoSnafu {}),
384 }
385}
386
387unsafe impl Send for FilesystemInstance {}
392unsafe impl Sync for FilesystemInstance {}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::backend::{Options, StoreBackend, StoreInstance};
398 use std::collections::HashMap;
399 use tempfile::TempDir;
400
401 fn open() -> (TempDir, Box<dyn StoreInstance>) {
403 let dir = TempDir::new().expect("tempdir");
404 let mut opts: Options = HashMap::new();
405 opts.insert(
406 OPT_ROOT.to_string(),
407 dir.path().to_str().expect("utf8 tmpdir").to_string(),
408 );
409 let ks = FilesystemBackend
410 .open(&opts)
411 .expect("filesystem backend opens");
412 (dir, ks)
413 }
414
415 fn key_handle(bytes: &[u8]) -> *mut c_void {
417 encode_key(bytes.to_vec())
418 }
419
420 #[test]
421 fn put_get_secret_round_trip() {
422 let (_dir, mut ks) = open();
423 let secret = b"\x01\x02\x03\x04 secret key bytes";
424 let handle = key_handle(secret);
425 ks.put_secret("mod", "app", "key-1", handle)
426 .expect("put_secret");
427 unsafe { reclaim_key(handle) };
428
429 let got = ks.get_secret("mod", "app", "key-1").expect("get_secret");
430 let bytes = unsafe { key_bytes(got) }.expect("decode");
431 assert_eq!(bytes, secret);
432 unsafe { reclaim_key(got) };
433 }
434
435 #[test]
436 fn put_get_public_round_trip() {
437 let (_dir, mut ks) = open();
438 let pubkey = b"PUBKEY-BYTES";
439 let sig = vec![0xDE, 0xAD, 0xBE, 0xEF];
440 let handle = key_handle(pubkey);
441 ks.put_public("mod", "app", "email:alice@example.com", handle, &sig)
442 .expect("put_public");
443 unsafe { reclaim_key(handle) };
444
445 let (got_key, got_sig) = ks
446 .get_public("mod", "app", "email:alice@example.com")
447 .expect("get_public");
448 let bytes = unsafe { key_bytes(got_key) }.expect("decode");
449 assert_eq!(bytes, pubkey);
450 assert_eq!(got_sig, sig);
451 unsafe { reclaim_key(got_key) };
452 }
453
454 #[test]
455 fn get_secret_missing_returns_value_not_found() {
456 let (_dir, ks) = open();
457 let err = ks.get_secret("mod", "app", "missing").unwrap_err();
458 assert!(matches!(err, crate::error::Error::ValueNotFound));
459 }
460
461 #[test]
462 fn get_public_missing_returns_value_not_found() {
463 let (_dir, ks) = open();
464 let err = ks
465 .get_public("mod", "app", "email:nobody@example.com")
466 .unwrap_err();
467 assert!(matches!(err, crate::error::Error::ValueNotFound));
468 }
469
470 #[test]
471 fn public_files_distinct_for_dotted_identity() {
472 let (dir, mut ks) = open();
477 let identity = "email:alice@example.com";
478 let h = key_handle(b"pk-bytes");
479 ks.put_public("mod", "app", identity, h, b"sig-bytes")
480 .expect("put_public");
481 unsafe { reclaim_key(h) };
482
483 let leaf = sanitize_for_filename(identity);
487 let key_path = dir
488 .path()
489 .join("mod")
490 .join("app")
491 .join("public")
492 .join(&leaf);
493 let sig_path = dir
494 .path()
495 .join("mod")
496 .join("app")
497 .join("public")
498 .join(format!("{leaf}.sig"));
499 assert!(key_path.exists(), "key file at leaf identity");
500 assert!(sig_path.exists(), "sig file at leaf identity + .sig");
501 assert_ne!(key_path, sig_path, "key and sig paths must differ");
502 assert_eq!(std::fs::read(&key_path).expect("read key"), b"pk-bytes");
503 assert_eq!(std::fs::read(&sig_path).expect("read sig"), b"sig-bytes");
504
505 let (got_key, got_sig) = ks.get_public("mod", "app", identity).expect("get_public");
506 let bytes = unsafe { key_bytes(got_key) }.expect("decode");
507 assert_eq!(bytes, b"pk-bytes");
508 assert_eq!(got_sig, b"sig-bytes");
509 unsafe { reclaim_key(got_key) };
510 }
511
512 #[test]
513 fn enumerate_private_lists_key_ids() {
514 let (_dir, mut ks) = open();
515 for (kid, bytes) in [
516 ("key-a", b"a".as_slice()),
517 ("key-b", b"bb".as_slice()),
518 ("key-c", b"ccc".as_slice()),
519 ] {
520 let h = key_handle(bytes);
521 ks.put_secret("mod", "app", kid, h).expect("put_secret");
522 unsafe { reclaim_key(h) };
523 }
524
525 let entries = ks
526 .enumerate("mod", "app", Compartment::Private)
527 .expect("enumerate private");
528 let ids: Vec<String> = entries.iter().map(|(_, id)| id.clone()).collect();
529 assert_eq!(ids, vec!["key-a", "key-b", "key-c"]);
530 for (key, _) in &entries {
531 unsafe { reclaim_key(*key) };
532 }
533 }
534
535 #[test]
536 fn enumerate_public_lists_identities_not_sigs() {
537 let (_dir, mut ks) = open();
538 for (id, bytes) in [
539 ("email:alice@example.com", b"pk-a"),
540 ("email:bob@example.com", b"pk-b"),
541 ] {
542 let h = key_handle(bytes);
543 ks.put_public("mod", "app", id, h, &[0u8])
544 .expect("put_public");
545 unsafe { reclaim_key(h) };
546 }
547
548 let entries = ks
549 .enumerate("mod", "app", Compartment::Public)
550 .expect("enumerate public");
551 let ids: Vec<String> = entries.iter().map(|(_, id)| id.clone()).collect();
552 let expected: Vec<String> = ["email:alice@example.com", "email:bob@example.com"]
556 .iter()
557 .map(|s| sanitize_for_filename(s))
558 .collect();
559 assert_eq!(ids, expected);
560 for (key, _) in &entries {
561 unsafe { reclaim_key(*key) };
562 }
563 }
564
565 #[test]
566 fn enumerate_missing_scope_returns_empty() {
567 let (_dir, ks) = open();
568 let entries = ks
569 .enumerate("nope", "nope", Compartment::Private)
570 .expect("enumerate should not error on absent scope");
571 assert!(entries.is_empty());
572 }
573
574 #[test]
575 fn put_secret_overwrites() {
576 let (_dir, mut ks) = open();
577 let h1 = key_handle(b"old");
578 ks.put_secret("mod", "app", "key-1", h1).expect("put old");
579 unsafe { reclaim_key(h1) };
580 let h2 = key_handle(b"new");
581 ks.put_secret("mod", "app", "key-1", h2).expect("put new");
582 unsafe { reclaim_key(h2) };
583
584 let got = ks.get_secret("mod", "app", "key-1").expect("get");
585 let bytes = unsafe { key_bytes(got) }.expect("decode");
586 assert_eq!(bytes, b"new");
587 unsafe { reclaim_key(got) };
588 }
589
590 #[test]
591 fn path_traversal_module_rejected() {
592 let (_dir, mut ks) = open();
593 let h = key_handle(b"x");
594 let err = ks.put_secret("..", "app", "key", h).unwrap_err();
595 assert!(matches!(err, crate::error::Error::InvalidPath { .. }));
596 unsafe { reclaim_key(h) };
597 }
598
599 #[test]
600 fn path_traversal_app_rejected() {
601 let (_dir, mut ks) = open();
602 let h = key_handle(b"x");
603 let err = ks.put_secret("mod", "../../etc", "key", h).unwrap_err();
604 assert!(matches!(err, crate::error::Error::InvalidPath { .. }));
605 unsafe { reclaim_key(h) };
606 }
607
608 #[test]
609 fn path_traversal_key_id_rejected() {
610 let (_dir, mut ks) = open();
611 let h = key_handle(b"x");
612 let err = ks.put_secret("mod", "app", "../escape", h).unwrap_err();
613 assert!(matches!(err, crate::error::Error::InvalidPath { .. }));
614 unsafe { reclaim_key(h) };
615 }
616
617 #[test]
618 fn path_traversal_absolute_rejected() {
619 let (_dir, mut ks) = open();
620 let h = key_handle(b"x");
621 let err = ks.put_secret("/etc", "app", "key", h).unwrap_err();
623 assert!(matches!(err, crate::error::Error::InvalidPath { .. }));
624 unsafe { reclaim_key(h) };
625 }
626
627 #[test]
628 fn nul_in_component_rejected() {
629 let (_dir, mut ks) = open();
630 let h = key_handle(b"x");
631 let err = ks.put_secret("mo\0d", "app", "key", h).unwrap_err();
632 assert!(matches!(err, crate::error::Error::InvalidPath { .. }));
633 unsafe { reclaim_key(h) };
634 }
635
636 #[test]
637 fn backend_is_registered() {
638 let backend = crate::backend::find("filesystem").expect("filesystem backend registered");
639 assert_eq!(backend.name(), "filesystem");
640 }
641
642 #[test]
643 fn open_creates_root_if_missing() {
644 let dir = TempDir::new().expect("tempdir");
645 let nested = dir.path().join("a/b/c/store");
646 let mut opts: Options = HashMap::new();
647 opts.insert(
648 OPT_ROOT.to_string(),
649 nested.to_str().expect("utf8").to_string(),
650 );
651 let _ks = FilesystemBackend.open(&opts).expect("open");
652 assert!(nested.exists(), "open should create the root directory");
653 }
654
655 #[test]
656 fn on_disk_layout_matches_spec() {
657 let (dir, mut ks) = open();
658 let h = key_handle(b"secret");
659 ks.put_secret("mod", "app", "key-1", h).expect("put_secret");
660 unsafe { reclaim_key(h) };
661
662 let expected = dir
664 .path()
665 .join("mod")
666 .join("app")
667 .join("private")
668 .join("key-1");
669 assert!(expected.exists(), "private key file at spec path");
670 let on_disk = std::fs::read(&expected).expect("read");
671 assert_eq!(on_disk, b"secret");
672 }
673}