1use std::path::PathBuf;
11
12use edgestore::error::EdgestoreError;
13use edgestore::RemoteStore;
14
15pub struct FilesystemRemoteStore {
20 base_dir: PathBuf,
21}
22
23impl FilesystemRemoteStore {
24 pub fn new(base_dir: PathBuf) -> Result<Self, EdgestoreError> {
28 std::fs::create_dir_all(&base_dir)
29 .map_err(|e| EdgestoreError::ReplicationError(e.to_string()))?;
30 Ok(Self { base_dir })
31 }
32
33 fn hash_hex(hash: &[u8; 32]) -> String {
35 hash.iter()
36 .map(|b| format!("{:02x}", b))
37 .collect::<String>()
38 }
39
40 fn seg_path(&self, hash: &[u8; 32]) -> PathBuf {
42 self.base_dir.join(format!("{}.seg", Self::hash_hex(hash)))
43 }
44
45 fn aux_path(&self, hash: &[u8; 32], ext: &str) -> Result<PathBuf, EdgestoreError> {
46 if ext.is_empty() || !ext.bytes().all(|b| b.is_ascii_lowercase()) {
47 return Err(EdgestoreError::InvalidOperation(format!(
48 "invalid sidecar ext {:?}: only lowercase ASCII letters allowed",
49 ext
50 )));
51 }
52 Ok(self
53 .base_dir
54 .join(format!("{}.{}", Self::hash_hex(hash), ext)))
55 }
56}
57
58impl RemoteStore for FilesystemRemoteStore {
59 fn upload(&self, hash: &[u8; 32], data: &[u8]) -> Result<(), EdgestoreError> {
63 let dest = self.seg_path(hash);
64
65 if dest.exists() {
67 return Ok(());
68 }
69
70 let tmp = self.base_dir.join(format!("{}.tmp", Self::hash_hex(hash)));
71
72 std::fs::write(&tmp, data).map_err(|e| EdgestoreError::ReplicationError(e.to_string()))?;
73
74 std::fs::rename(&tmp, &dest)
75 .map_err(|e| EdgestoreError::ReplicationError(e.to_string()))?;
76
77 Ok(())
78 }
79
80 fn download(&self, hash: &[u8; 32]) -> Result<Vec<u8>, EdgestoreError> {
84 let path = self.seg_path(hash);
85 std::fs::read(&path).map_err(|e| {
86 if e.kind() == std::io::ErrorKind::NotFound {
87 EdgestoreError::ReplicationError(format!(
88 "segment not found: {}",
89 Self::hash_hex(hash)
90 ))
91 } else {
92 EdgestoreError::ReplicationError(e.to_string())
93 }
94 })
95 }
96
97 fn list(&self) -> Result<Vec<[u8; 32]>, EdgestoreError> {
102 let entries = std::fs::read_dir(&self.base_dir)
103 .map_err(|e| EdgestoreError::ReplicationError(e.to_string()))?;
104
105 let mut hashes = Vec::new();
106
107 for entry in entries.flatten() {
108 let file_name = entry.file_name();
109 let name = match file_name.to_str() {
110 Some(n) => n.to_owned(),
111 None => continue,
112 };
113
114 if !name.ends_with(".seg") {
116 continue;
117 }
118
119 let stem = &name[..name.len() - 4]; if stem.len() != 64 {
122 continue;
123 }
124
125 let parsed: Option<[u8; 32]> = (0..32)
127 .map(|i| u8::from_str_radix(&stem[i * 2..i * 2 + 2], 16).ok())
128 .collect::<Option<Vec<u8>>>()
129 .and_then(|v| v.try_into().ok());
130
131 if let Some(hash) = parsed {
132 hashes.push(hash);
133 }
134 }
135
136 Ok(hashes)
137 }
138
139 fn delete(&self, hash: &[u8; 32]) -> Result<(), EdgestoreError> {
141 let path = self.seg_path(hash);
142 match std::fs::remove_file(&path) {
143 Ok(()) => Ok(()),
144 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
145 Err(e) => Err(EdgestoreError::ReplicationError(e.to_string())),
146 }
147 }
148
149 fn upload_aux(&self, hash: &[u8; 32], ext: &str, data: &[u8]) -> Result<(), EdgestoreError> {
150 let dest = self.aux_path(hash, ext)?;
151 if dest.exists() {
152 return Ok(());
153 }
154 let tmp = self
155 .base_dir
156 .join(format!("{}.{}.tmp", Self::hash_hex(hash), ext));
157 std::fs::write(&tmp, data).map_err(|e| EdgestoreError::ReplicationError(e.to_string()))?;
158 std::fs::rename(&tmp, &dest).map_err(|e| EdgestoreError::ReplicationError(e.to_string()))
159 }
160
161 fn download_aux(&self, hash: &[u8; 32], ext: &str) -> Result<Vec<u8>, EdgestoreError> {
162 let path = self.aux_path(hash, ext)?;
163 std::fs::read(&path).map_err(|e| {
164 if e.kind() == std::io::ErrorKind::NotFound {
165 EdgestoreError::ReplicationError(format!(
166 "sidecar not found: {}.{}",
167 Self::hash_hex(hash),
168 ext
169 ))
170 } else {
171 EdgestoreError::ReplicationError(e.to_string())
172 }
173 })
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use tempfile::TempDir;
181
182 fn make_store() -> (TempDir, FilesystemRemoteStore) {
183 let dir = TempDir::new().expect("tempdir");
184 let store = FilesystemRemoteStore::new(dir.path().to_path_buf())
185 .expect("FilesystemRemoteStore::new");
186 (dir, store)
187 }
188
189 #[test]
190 fn test_upload_download_roundtrip() {
191 let (_dir, store) = make_store();
192 let hash = [0x42u8; 32];
193 let data = b"hello edgestore";
194
195 store.upload(&hash, data).expect("upload");
196 let got = store.download(&hash).expect("download");
197 assert_eq!(got, data);
198 }
199
200 #[test]
201 fn test_upload_idempotent() {
202 let (_dir, store) = make_store();
203 let hash = [0x42u8; 32];
204 let data = b"original";
205
206 store.upload(&hash, data).expect("first upload");
207 store
209 .upload(&hash, b"different")
210 .expect("second upload (idempotent)");
211
212 let got = store
214 .download(&hash)
215 .expect("download after idempotent upload");
216 assert_eq!(got, data);
217 }
218
219 #[test]
220 fn test_list_returns_uploaded_hashes() {
221 let (_dir, store) = make_store();
222 let hash1 = [0x01u8; 32];
223 let hash2 = [0x02u8; 32];
224 let hash3 = [0x03u8; 32];
225
226 store.upload(&hash1, b"a").expect("upload 1");
227 store.upload(&hash2, b"b").expect("upload 2");
228 store.upload(&hash3, b"c").expect("upload 3");
229
230 let mut listed = store.list().expect("list");
231 listed.sort();
232
233 let mut expected = vec![hash1, hash2, hash3];
234 expected.sort();
235
236 assert_eq!(listed, expected);
237 }
238
239 #[test]
240 fn test_delete_removes_file() {
241 let (_dir, store) = make_store();
242 let hash = [0x42u8; 32];
243
244 store.upload(&hash, b"segment data").expect("upload");
245 store.delete(&hash).expect("delete");
246
247 let result = store.download(&hash);
249 assert!(result.is_err(), "download after delete should return Err");
250 }
251
252 #[test]
253 fn test_download_not_found() {
254 let (_dir, store) = make_store();
255 let hash = [0xFFu8; 32];
256
257 let result = store.download(&hash);
258 assert!(
259 result.is_err(),
260 "download of non-existent hash should return Err"
261 );
262 }
263
264 #[test]
265 fn test_upload_aux_download_aux_roundtrip() {
266 let (_dir, store) = make_store();
267 let hash = [0x77u8; 32];
268
269 store
270 .upload_aux(&hash, "idx", b"index-data")
271 .expect("upload_aux idx");
272 store
273 .upload_aux(&hash, "xf", b"filter-data")
274 .expect("upload_aux xf");
275 store
276 .upload_aux(&hash, "meta", b"meta-data")
277 .expect("upload_aux meta");
278
279 assert_eq!(store.download_aux(&hash, "idx").unwrap(), b"index-data");
280 assert_eq!(store.download_aux(&hash, "xf").unwrap(), b"filter-data");
281 assert_eq!(store.download_aux(&hash, "meta").unwrap(), b"meta-data");
282 }
283
284 #[test]
285 fn test_upload_aux_idempotent() {
286 let (_dir, store) = make_store();
287 let hash = [0x88u8; 32];
288
289 store
290 .upload_aux(&hash, "idx", b"v1")
291 .expect("first upload_aux");
292 store
293 .upload_aux(&hash, "idx", b"v2")
294 .expect("second upload_aux idempotent");
295
296 assert_eq!(store.download_aux(&hash, "idx").unwrap(), b"v1");
298 }
299
300 #[test]
301 fn test_download_aux_not_found() {
302 let (_dir, store) = make_store();
303 let hash = [0x99u8; 32];
304
305 let result = store.download_aux(&hash, "idx");
306 assert!(
307 result.is_err(),
308 "download_aux of non-existent sidecar should Err"
309 );
310 }
311
312 #[test]
313 fn test_upload_aux_rejects_path_traversal_ext() {
314 let (_dir, store) = make_store();
315 let hash = [0xAAu8; 32];
316
317 for bad_ext in &[
318 "../etc/passwd",
319 "idx/../../shadow",
320 "IDX",
321 "idx.bak",
322 "",
323 "idx\0evil",
324 ] {
325 let result = store.upload_aux(&hash, bad_ext, b"data");
326 assert!(result.is_err(), "upload_aux must reject ext {:?}", bad_ext);
327 }
328 }
329}