Skip to main content

void_core/store/
fs.rs

1//! Filesystem-based object store
2
3use std::fs;
4use std::path::PathBuf;
5
6use camino::Utf8PathBuf;
7
8use super::ObjectStore;
9use crate::cid::VoidCid;
10use crate::{Result, VoidError};
11
12/// Filesystem-backed object store.
13///
14/// Objects are stored in a two-level directory structure:
15/// `objects/<prefix>/<cid>`
16pub struct FsStore {
17    root: Utf8PathBuf,
18}
19
20impl FsStore {
21    /// Creates a new filesystem store at the given path.
22    ///
23    /// Creates the directory if it doesn't exist.
24    pub fn new(root: impl Into<Utf8PathBuf>) -> Result<Self> {
25        let root = root.into();
26        fs::create_dir_all(&root)?;
27        Ok(Self { root })
28    }
29
30    /// Returns the root directory path of this store.
31    pub fn root(&self) -> &Utf8PathBuf {
32        &self.root
33    }
34
35    /// Compute the filesystem path for a CID string within an objects directory.
36    ///
37    /// Layout: `<root>/<first-2-chars>/<full-cid-string>`
38    ///
39    /// This is the canonical path scheme for void object storage.
40    /// Used by FsStore, CliBlockStore, and the `daemon files` command.
41    pub fn object_path(root: &std::path::Path, cid_str: &str) -> PathBuf {
42        let prefix = &cid_str[..2.min(cid_str.len())];
43        root.join(prefix).join(cid_str)
44    }
45
46    /// Returns the path for a CID.
47    fn path_for(&self, cid: &VoidCid) -> Utf8PathBuf {
48        let path = Self::object_path(self.root.as_std_path(), &cid.to_string());
49        // Safe: FsStore root is always valid UTF-8 (Utf8PathBuf)
50        Utf8PathBuf::try_from(path).expect("FsStore path is valid UTF-8")
51    }
52}
53
54impl ObjectStore for FsStore {
55    fn write_raw(&self, data: &[u8]) -> Result<VoidCid> {
56        let cid = VoidCid::create(data);
57        let path = self.path_for(&cid);
58
59        // Create parent directory
60        if let Some(parent) = path.parent() {
61            fs::create_dir_all(parent)?;
62        }
63
64        // Write atomically via temp file
65        let temp_path = format!("{}.tmp", path);
66        fs::write(&temp_path, data)?;
67        fs::rename(&temp_path, &path)?;
68
69        Ok(cid)
70    }
71
72    fn read_raw(&self, cid: &VoidCid) -> Result<Vec<u8>> {
73        let path = self.path_for(cid);
74        fs::read(&path).map_err(|e| {
75            if e.kind() == std::io::ErrorKind::NotFound {
76                VoidError::NotFound(cid.to_string())
77            } else {
78                VoidError::Io(e)
79            }
80        })
81    }
82
83    fn has(&self, cid: &VoidCid) -> Result<bool> {
84        let path = self.path_for(cid);
85        Ok(PathBuf::from(path.as_str()).exists())
86    }
87
88    fn delete(&self, cid: &VoidCid) -> Result<()> {
89        let path = self.path_for(cid);
90        match fs::remove_file(&path) {
91            Ok(()) => Ok(()),
92            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
93            Err(e) => Err(VoidError::Io(e)),
94        }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::store::ObjectStoreExt;
102    use void_crypto::EncryptedCommit;
103    use tempfile::TempDir;
104
105    fn temp_store() -> (FsStore, TempDir) {
106        let dir = TempDir::new().unwrap();
107        let store = FsStore::new(Utf8PathBuf::try_from(dir.path().to_path_buf()).unwrap()).unwrap();
108        (store, dir)
109    }
110
111    #[test]
112    fn put_get_roundtrip() {
113        let (store, _dir) = temp_store();
114        let data = b"hello, void!";
115
116        let cid = store.put_blob(&EncryptedCommit::from_bytes(data.to_vec())).unwrap();
117        let retrieved: EncryptedCommit = store.get_blob(&cid).unwrap();
118
119        assert_eq!(retrieved.as_bytes(), data);
120    }
121
122    #[test]
123    fn has_exists() {
124        let (store, _dir) = temp_store();
125        let data = b"test";
126
127        let cid = store.put_blob(&EncryptedCommit::from_bytes(data.to_vec())).unwrap();
128        assert!(store.exists(&cid).unwrap());
129    }
130
131    #[test]
132    fn has_not_exists() {
133        let (store, _dir) = temp_store();
134        let cid = VoidCid::create(b"nonexistent");
135
136        assert!(!store.exists(&cid).unwrap());
137    }
138
139    #[test]
140    fn get_not_found() {
141        let (store, _dir) = temp_store();
142        let cid = VoidCid::create(b"nonexistent");
143
144        let result: Result<EncryptedCommit> = store.get_blob(&cid);
145        assert!(matches!(result, Err(VoidError::NotFound(_))));
146    }
147
148    #[test]
149    fn delete_exists() {
150        let (store, _dir) = temp_store();
151        let data = b"test";
152
153        let cid = store.put_blob(&EncryptedCommit::from_bytes(data.to_vec())).unwrap();
154        store.remove(&cid).unwrap();
155
156        assert!(!store.exists(&cid).unwrap());
157    }
158
159    #[test]
160    fn delete_not_exists() {
161        let (store, _dir) = temp_store();
162        let cid = VoidCid::create(b"nonexistent");
163
164        // Should not error
165        store.remove(&cid).unwrap();
166    }
167}