Skip to main content

edgestore_repl/
filesystem_remote_store.rs

1//! FilesystemRemoteStore — local-filesystem implementation of `RemoteStore`.
2//!
3//! Files are content-addressed: named `{hash_hex}.seg` where `hash_hex` is the
4//! 64-character lowercase hex encoding of the 32-byte BLAKE3 hash. Listing the
5//! directory is equivalent to listing stored segments.
6//!
7//! This is the Phase 4 implementation (Plan 04-04, D04). Real S3 (`S3RemoteStore`)
8//! is a future phase deliverable.
9
10use std::path::PathBuf;
11
12use edgestore::error::EdgestoreError;
13use edgestore::RemoteStore;
14
15/// Local-filesystem implementation of `RemoteStore`.
16///
17/// All operations are idempotent and atomic where applicable.
18/// `upload` uses a `.tmp` write + rename to prevent torn writes (T-04-10).
19pub struct FilesystemRemoteStore {
20    base_dir: PathBuf,
21}
22
23impl FilesystemRemoteStore {
24    /// Create a new `FilesystemRemoteStore` rooted at `base_dir`.
25    ///
26    /// Creates `base_dir` (and all parent directories) if it does not exist.
27    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    /// Encode a 32-byte hash as a 64-character lowercase hex string.
34    fn hash_hex(hash: &[u8; 32]) -> String {
35        hash.iter()
36            .map(|b| format!("{:02x}", b))
37            .collect::<String>()
38    }
39
40    /// Return the path `{base_dir}/{hash_hex}.seg` for the given hash.
41    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    /// Store `data` under `hash`. Idempotent: if the file already exists, returns `Ok(())`.
60    ///
61    /// Writes to a `.tmp` file first, then renames atomically (T-04-10).
62    fn upload(&self, hash: &[u8; 32], data: &[u8]) -> Result<(), EdgestoreError> {
63        let dest = self.seg_path(hash);
64
65        // Content-addressed: if it already exists, nothing to do.
66        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    /// Download the segment bytes for `hash`.
81    ///
82    /// Returns `EdgestoreError::ReplicationError` if the segment is not present.
83    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    /// List all stored segment hashes by scanning `{base_dir}/*.seg`.
98    ///
99    /// Filenames that are not exactly 64 lowercase hex characters followed by `.seg`
100    /// are silently skipped (T-04-12).
101    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            // Must end with ".seg"
115            if !name.ends_with(".seg") {
116                continue;
117            }
118
119            // Stem must be exactly 64 characters (32 bytes * 2 hex digits).
120            let stem = &name[..name.len() - 4]; // strip ".seg"
121            if stem.len() != 64 {
122                continue;
123            }
124
125            // Parse 64 hex chars → [u8; 32]
126            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    /// Remove the segment for `hash`. No-op if the segment does not exist (idempotent).
140    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        // Second upload with same hash must succeed without error.
208        store
209            .upload(&hash, b"different")
210            .expect("second upload (idempotent)");
211
212        // File should still contain the original data (idempotent — skipped overwrite).
213        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        // Download must now fail.
248        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        // Content-addressed: first upload wins.
297        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}