Skip to main content

gatel_core/
storage.rs

1//! Pluggable storage abstraction for certificate and persistent data.
2//!
3//! The [`Storage`] trait defines a simple key-value interface that backends
4//! must implement. The default [`FileStorage`] backend stores data on disk.
5
6use std::io;
7use std::path::{Path, PathBuf};
8
9/// Trait for persistent key-value storage backends.
10///
11/// Keys are slash-separated paths (e.g. `"certs/example.com/cert.pem"`).
12/// Values are raw bytes.
13#[async_trait::async_trait]
14pub trait Storage: Send + Sync {
15    /// Store a value under the given key.
16    async fn store(&self, key: &str, value: &[u8]) -> io::Result<()>;
17
18    /// Load a value by key. Returns `None` if the key does not exist.
19    async fn load(&self, key: &str) -> io::Result<Option<Vec<u8>>>;
20
21    /// Delete a value by key.
22    async fn delete(&self, key: &str) -> io::Result<()>;
23
24    /// Check whether a key exists.
25    async fn exists(&self, key: &str) -> io::Result<bool>;
26
27    /// List all keys under a given prefix.
28    async fn list(&self, prefix: &str) -> io::Result<Vec<String>>;
29}
30
31/// Filesystem-based storage backend.
32///
33/// Stores data in a directory tree under `root`. Keys are mapped to file
34/// paths relative to the root.
35pub struct FileStorage {
36    root: PathBuf,
37}
38
39impl FileStorage {
40    /// Create a new file storage rooted at the given directory.
41    pub fn new(root: impl Into<PathBuf>) -> Self {
42        Self { root: root.into() }
43    }
44
45    fn key_path(&self, key: &str) -> PathBuf {
46        // Sanitize: prevent path traversal by resolving each component.
47        let mut resolved = self.root.clone();
48        for component in key.split(['/', '\\']) {
49            match component {
50                "" | "." | ".." => continue,
51                c => resolved.push(c),
52            }
53        }
54        resolved
55    }
56}
57
58#[async_trait::async_trait]
59impl Storage for FileStorage {
60    async fn store(&self, key: &str, value: &[u8]) -> io::Result<()> {
61        let path = self.key_path(key);
62        if let Some(parent) = path.parent() {
63            tokio::fs::create_dir_all(parent).await?;
64        }
65        tokio::fs::write(&path, value).await
66    }
67
68    async fn load(&self, key: &str) -> io::Result<Option<Vec<u8>>> {
69        let path = self.key_path(key);
70        match tokio::fs::read(&path).await {
71            Ok(data) => Ok(Some(data)),
72            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
73            Err(e) => Err(e),
74        }
75    }
76
77    async fn delete(&self, key: &str) -> io::Result<()> {
78        let path = self.key_path(key);
79        match tokio::fs::remove_file(&path).await {
80            Ok(()) => Ok(()),
81            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
82            Err(e) => Err(e),
83        }
84    }
85
86    async fn exists(&self, key: &str) -> io::Result<bool> {
87        let path = self.key_path(key);
88        Ok(path.exists())
89    }
90
91    async fn list(&self, prefix: &str) -> io::Result<Vec<String>> {
92        let dir = self.key_path(prefix);
93        let mut keys = Vec::new();
94        if !dir.is_dir() {
95            return Ok(keys);
96        }
97        collect_keys(&dir, &self.root, &mut keys).await?;
98        Ok(keys)
99    }
100}
101
102/// Recursively collect file paths relative to `root`.
103async fn collect_keys(dir: &Path, root: &Path, keys: &mut Vec<String>) -> io::Result<()> {
104    let mut entries = tokio::fs::read_dir(dir).await?;
105    while let Some(entry) = entries.next_entry().await? {
106        let path = entry.path();
107        if path.is_dir() {
108            Box::pin(collect_keys(&path, root, keys)).await?;
109        } else if let Ok(rel) = path.strip_prefix(root) {
110            keys.push(rel.to_string_lossy().replace('\\', "/"));
111        }
112    }
113    Ok(())
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[tokio::test]
121    async fn roundtrip() {
122        let dir = tempfile::tempdir().unwrap();
123        let storage = FileStorage::new(dir.path());
124
125        storage.store("test/key.txt", b"hello").await.unwrap();
126        assert!(storage.exists("test/key.txt").await.unwrap());
127
128        let data = storage.load("test/key.txt").await.unwrap();
129        assert_eq!(data.as_deref(), Some(b"hello".as_slice()));
130
131        let keys = storage.list("test").await.unwrap();
132        assert_eq!(keys.len(), 1);
133        assert!(keys[0].contains("key.txt"));
134
135        storage.delete("test/key.txt").await.unwrap();
136        assert!(!storage.exists("test/key.txt").await.unwrap());
137    }
138
139    #[tokio::test]
140    async fn load_missing() {
141        let dir = tempfile::tempdir().unwrap();
142        let storage = FileStorage::new(dir.path());
143        assert_eq!(storage.load("nonexistent").await.unwrap(), None);
144    }
145
146    #[test]
147    fn path_traversal_sanitized() {
148        let root = std::env::temp_dir().join("gatel-test-root");
149        let storage = FileStorage::new(&root);
150        let path = storage.key_path("../../escape/test");
151        assert!(
152            path.starts_with(&root),
153            "path traversal not prevented: {path:?}"
154        );
155        assert!(
156            !path.to_string_lossy().contains(".."),
157            "path contains '..': {path:?}"
158        );
159        // Should resolve to root/escape/test
160        assert!(path.ends_with("escape/test") || path.ends_with("escape\\test"));
161    }
162}