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