1use std::io;
7use std::path::{Path, PathBuf};
8
9#[async_trait::async_trait]
14pub trait Storage: Send + Sync {
15 async fn store(&self, key: &str, value: &[u8]) -> io::Result<()>;
17
18 async fn load(&self, key: &str) -> io::Result<Option<Vec<u8>>>;
20
21 async fn delete(&self, key: &str) -> io::Result<()>;
23
24 async fn exists(&self, key: &str) -> io::Result<bool>;
26
27 async fn list(&self, prefix: &str) -> io::Result<Vec<String>>;
29}
30
31pub struct FileStorage {
36 root: PathBuf,
37}
38
39impl FileStorage {
40 pub fn new(root: impl Into<PathBuf>) -> Self {
42 Self { root: root.into() }
43 }
44
45 fn key_path(&self, key: &str) -> PathBuf {
46 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
102async 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 assert!(path.ends_with("escape/test") || path.ends_with("escape\\test"));
161 }
162}