1use async_trait::async_trait;
4
5#[async_trait]
7pub trait ObjectStore: Send + Sync + 'static {
8 async fn get(&self, key: &str) -> anyhow::Result<Vec<u8>>;
9 async fn put(&self, key: &str, data: Vec<u8>) -> anyhow::Result<()>;
10 async fn delete(&self, key: &str) -> anyhow::Result<()>;
11 async fn list(&self, prefix: &str) -> anyhow::Result<Vec<String>>;
12 async fn exists(&self, key: &str) -> anyhow::Result<bool>;
13}
14
15pub struct LocalStore {
17 root: std::path::PathBuf,
18}
19
20impl LocalStore {
21 pub fn new(root: std::path::PathBuf) -> Self {
22 Self { root }
23 }
24}
25
26fn validate_key(key: &str) -> anyhow::Result<()> {
30 if key.is_empty() {
31 anyhow::bail!("storage key cannot be empty");
32 }
33 if key.starts_with('/') || key.starts_with('\\') {
34 anyhow::bail!("storage key must be relative");
35 }
36 if key.contains('\0') {
37 anyhow::bail!("storage key contains null byte");
38 }
39 for component in key.split(&['/', '\\'] as &[char]) {
40 if component == ".." {
41 anyhow::bail!("storage key contains '..' traversal");
42 }
43 }
44 Ok(())
45}
46
47#[async_trait]
48impl ObjectStore for LocalStore {
49 async fn get(&self, key: &str) -> anyhow::Result<Vec<u8>> {
50 validate_key(key)?;
51 let path = self.root.join(key);
52 Ok(tokio::fs::read(path).await?)
53 }
54
55 async fn put(&self, key: &str, data: Vec<u8>) -> anyhow::Result<()> {
56 validate_key(key)?;
57 let path = self.root.join(key);
58 if let Some(parent) = path.parent() {
59 tokio::fs::create_dir_all(parent).await?;
60 }
61 Ok(tokio::fs::write(path, data).await?)
62 }
63
64 async fn delete(&self, key: &str) -> anyhow::Result<()> {
65 validate_key(key)?;
66 let path = self.root.join(key);
67 match tokio::fs::remove_file(path).await {
68 Ok(()) => Ok(()),
69 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
70 Err(e) => Err(e.into()),
71 }
72 }
73
74 async fn list(&self, prefix: &str) -> anyhow::Result<Vec<String>> {
75 if !prefix.is_empty() {
78 validate_key(prefix)?;
79 }
80 let dir = self.root.join(prefix);
81 let mut entries = Vec::new();
82 if dir.exists() {
83 let mut read_dir = tokio::fs::read_dir(&dir).await?;
84 while let Some(entry) = read_dir.next_entry().await? {
85 if let Some(name) = entry.file_name().to_str() {
86 let key = if prefix.is_empty() {
87 name.to_string()
88 } else {
89 format!("{prefix}/{name}")
90 };
91 entries.push(key);
92 }
93 }
94 }
95 Ok(entries)
96 }
97
98 async fn exists(&self, key: &str) -> anyhow::Result<bool> {
99 validate_key(key)?;
100 let path = self.root.join(key);
101 Ok(path.exists())
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn validate_key_rejects_traversal() {
111 assert!(validate_key("../etc/passwd").is_err());
112 assert!(validate_key("foo/../../bar").is_err());
113 }
114
115 #[test]
116 fn validate_key_rejects_absolute() {
117 assert!(validate_key("/etc/passwd").is_err());
118 }
119
120 #[test]
121 fn validate_key_rejects_null() {
122 assert!(validate_key("foo\0bar").is_err());
123 }
124
125 #[test]
126 fn validate_key_accepts_valid() {
127 assert!(validate_key("repos/abc/data.json").is_ok());
128 assert!(validate_key("sessions/123").is_ok());
129 assert!(validate_key("file.txt").is_ok());
130 }
131
132 #[tokio::test]
133 async fn local_store_roundtrip() {
134 let dir = tempfile::tempdir().unwrap();
135 let store = LocalStore::new(dir.path().to_path_buf());
136
137 store.put("test/file.txt", b"hello".to_vec()).await.unwrap();
138 assert!(store.exists("test/file.txt").await.unwrap());
139
140 let data = store.get("test/file.txt").await.unwrap();
141 assert_eq!(data, b"hello");
142
143 let keys = store.list("test").await.unwrap();
144 assert_eq!(keys, vec!["test/file.txt"]);
145
146 store.delete("test/file.txt").await.unwrap();
147 assert!(!store.exists("test/file.txt").await.unwrap());
148 }
149
150 #[tokio::test]
151 async fn local_store_get_not_found() {
152 let dir = tempfile::tempdir().unwrap();
153 let store = LocalStore::new(dir.path().to_path_buf());
154 let result = store.get("nonexistent").await;
155 assert!(result.is_err());
156 }
157
158 #[tokio::test]
159 async fn local_store_delete_idempotent() {
160 let dir = tempfile::tempdir().unwrap();
161 let store = LocalStore::new(dir.path().to_path_buf());
162 store.delete("nonexistent").await.unwrap();
163 }
164}