Skip to main content

sui_castore/storage/
local.rs

1//! Local filesystem storage backend.
2//!
3//! Layout:
4//! ```text
5//! <root>/
6//!   <hash>.narinfo          -- text narinfo metadata
7//!   nar/
8//!     <hash>.nar.xz         -- compressed NAR blobs
9//! ```
10
11use std::path::{Path, PathBuf};
12
13use async_trait::async_trait;
14use tokio::fs;
15
16use super::StorageBackend;
17use crate::StoreError;
18
19/// Filesystem-backed binary cache storage.
20#[derive(Debug, Clone)]
21pub struct LocalStorage {
22    /// Root directory for all cache data.
23    root: PathBuf,
24}
25
26impl LocalStorage {
27    /// Create a new local storage backend rooted at `path`.
28    ///
29    /// The directory structure is created lazily on first write.
30    pub fn new(path: impl Into<PathBuf>) -> Self {
31        Self { root: path.into() }
32    }
33
34    /// Return the root path.
35    #[must_use]
36    pub fn root(&self) -> &Path {
37        &self.root
38    }
39
40    /// Ensure a directory exists.
41    async fn ensure_dir(&self, path: &Path) -> Result<(), StoreError> {
42        if !path.exists() {
43            fs::create_dir_all(path).await.map_err(StoreError::Io)?;
44        }
45        Ok(())
46    }
47
48    /// Path to a narinfo file.
49    fn narinfo_path(&self, hash: &str) -> PathBuf {
50        self.root.join(format!("{hash}.narinfo"))
51    }
52
53    /// Path to a NAR blob. The `nar_path` is a relative path like
54    /// `nar/xyz.nar.xz`.
55    fn nar_blob_path(&self, nar_path: &str) -> PathBuf {
56        self.root.join(nar_path)
57    }
58}
59
60#[async_trait]
61impl StorageBackend for LocalStorage {
62    async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, StoreError> {
63        let path = self.narinfo_path(hash);
64        match fs::read_to_string(&path).await {
65            Ok(content) => Ok(Some(content)),
66            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
67            Err(e) => Err(StoreError::Io(e)),
68        }
69    }
70
71    async fn put_narinfo(&self, hash: &str, content: &str) -> Result<(), StoreError> {
72        self.ensure_dir(&self.root).await?;
73        let path = self.narinfo_path(hash);
74        fs::write(&path, content).await.map_err(StoreError::Io)
75    }
76
77    async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, StoreError> {
78        let full = self.nar_blob_path(path);
79        match fs::read(&full).await {
80            Ok(data) => Ok(Some(data)),
81            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
82            Err(e) => Err(StoreError::Io(e)),
83        }
84    }
85
86    async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), StoreError> {
87        let full = self.nar_blob_path(path);
88        if let Some(parent) = full.parent() {
89            self.ensure_dir(parent).await?;
90        }
91        fs::write(&full, data).await.map_err(StoreError::Io)
92    }
93
94    async fn delete(&self, hash: &str) -> Result<(), StoreError> {
95        // Read narinfo to find the NAR blob path, then delete both.
96        let narinfo_path = self.narinfo_path(hash);
97        if narinfo_path.exists() {
98            // Try to parse the narinfo to find the NAR URL.
99            if let Ok(content) = fs::read_to_string(&narinfo_path).await {
100                if let Ok(info) = sui_compat::narinfo::NarInfo::parse(&content) {
101                    let nar_path = self.nar_blob_path(&info.url);
102                    let _ = fs::remove_file(&nar_path).await;
103                }
104            }
105            fs::remove_file(&narinfo_path)
106                .await
107                .map_err(StoreError::Io)?;
108        }
109        Ok(())
110    }
111
112    async fn list_narinfos(&self) -> Result<Vec<String>, StoreError> {
113        let mut hashes = Vec::new();
114        if !self.root.exists() {
115            return Ok(hashes);
116        }
117        let mut entries = fs::read_dir(&self.root).await.map_err(StoreError::Io)?;
118        while let Some(entry) = entries.next_entry().await.map_err(StoreError::Io)? {
119            let name = entry.file_name();
120            let name = name.to_string_lossy();
121            if let Some(hash) = name.strip_suffix(".narinfo") {
122                hashes.push(hash.to_string());
123            }
124        }
125        Ok(hashes)
126    }
127
128    /// Complete L3 wipe: remove the entire cache directory (narinfos + the
129    /// `nar/` blob subtree), reclaiming NAR bytes a per-hash `delete` cannot
130    /// reach. The directory is re-created lazily on the next `put`. Returns
131    /// the narinfo count removed.
132    async fn wipe_all(&self) -> Result<usize, StoreError> {
133        let n = self.list_narinfos().await?.len();
134        if self.root.exists() {
135            fs::remove_dir_all(&self.root).await.map_err(StoreError::Io)?;
136        }
137        Ok(n)
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[tokio::test]
146    async fn get_missing_narinfo_returns_none() {
147        let dir = tempfile::tempdir().unwrap();
148        let storage = LocalStorage::new(dir.path());
149        let result = storage.get_narinfo("nonexistent").await.unwrap();
150        assert!(result.is_none());
151    }
152
153    #[tokio::test]
154    async fn put_and_get_narinfo() {
155        let dir = tempfile::tempdir().unwrap();
156        let storage = LocalStorage::new(dir.path());
157        let content = "StorePath: /nix/store/abc-hello\nURL: nar/abc.nar.xz\nCompression: xz\nFileHash: sha256:aaa\nFileSize: 100\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n";
158        storage.put_narinfo("abc", content).await.unwrap();
159        let retrieved = storage.get_narinfo("abc").await.unwrap().unwrap();
160        assert_eq!(retrieved, content);
161    }
162
163    #[tokio::test]
164    async fn get_missing_nar_returns_none() {
165        let dir = tempfile::tempdir().unwrap();
166        let storage = LocalStorage::new(dir.path());
167        let result = storage.get_nar("nar/missing.nar.xz").await.unwrap();
168        assert!(result.is_none());
169    }
170
171    #[tokio::test]
172    async fn put_and_get_nar() {
173        let dir = tempfile::tempdir().unwrap();
174        let storage = LocalStorage::new(dir.path());
175        let data = b"fake nar data";
176        storage.put_nar("nar/abc.nar.xz", data).await.unwrap();
177        let retrieved = storage.get_nar("nar/abc.nar.xz").await.unwrap().unwrap();
178        assert_eq!(retrieved, data);
179    }
180
181    #[tokio::test]
182    async fn list_narinfos_empty() {
183        let dir = tempfile::tempdir().unwrap();
184        let storage = LocalStorage::new(dir.path());
185        let hashes = storage.list_narinfos().await.unwrap();
186        assert!(hashes.is_empty());
187    }
188
189    #[tokio::test]
190    async fn list_narinfos_returns_hashes() {
191        let dir = tempfile::tempdir().unwrap();
192        let storage = LocalStorage::new(dir.path());
193        storage.put_narinfo("aaa", "content1").await.unwrap();
194        storage.put_narinfo("bbb", "content2").await.unwrap();
195        let mut hashes = storage.list_narinfos().await.unwrap();
196        hashes.sort();
197        assert_eq!(hashes, vec!["aaa", "bbb"]);
198    }
199
200    #[tokio::test]
201    async fn list_narinfos_ignores_non_narinfo_files() {
202        let dir = tempfile::tempdir().unwrap();
203        let storage = LocalStorage::new(dir.path());
204        storage.put_narinfo("abc", "content").await.unwrap();
205        // Write a non-narinfo file.
206        fs::write(dir.path().join("readme.txt"), "hello")
207            .await
208            .unwrap();
209        let hashes = storage.list_narinfos().await.unwrap();
210        assert_eq!(hashes, vec!["abc"]);
211    }
212
213    #[tokio::test]
214    async fn list_narinfos_on_nonexistent_dir() {
215        let storage = LocalStorage::new("/tmp/sui-castore-test-nonexistent-dir-12345");
216        let hashes = storage.list_narinfos().await.unwrap();
217        assert!(hashes.is_empty());
218    }
219
220    #[tokio::test]
221    async fn delete_removes_narinfo_and_nar() {
222        let dir = tempfile::tempdir().unwrap();
223        let storage = LocalStorage::new(dir.path());
224
225        let narinfo = "StorePath: /nix/store/xyz-hello\nURL: nar/xyz.nar.xz\nCompression: xz\nFileHash: sha256:aaa\nFileSize: 100\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n";
226        storage.put_narinfo("xyz", narinfo).await.unwrap();
227        storage.put_nar("nar/xyz.nar.xz", b"nar data").await.unwrap();
228
229        assert!(storage.get_narinfo("xyz").await.unwrap().is_some());
230        assert!(storage.get_nar("nar/xyz.nar.xz").await.unwrap().is_some());
231
232        storage.delete("xyz").await.unwrap();
233
234        assert!(storage.get_narinfo("xyz").await.unwrap().is_none());
235        assert!(storage.get_nar("nar/xyz.nar.xz").await.unwrap().is_none());
236    }
237
238    #[tokio::test]
239    async fn delete_nonexistent_is_noop() {
240        let dir = tempfile::tempdir().unwrap();
241        let storage = LocalStorage::new(dir.path());
242        storage.delete("nonexistent").await.unwrap();
243    }
244
245    #[tokio::test]
246    async fn root_accessor() {
247        let dir = tempfile::tempdir().unwrap();
248        let storage = LocalStorage::new(dir.path());
249        assert_eq!(storage.root(), dir.path());
250    }
251
252    #[tokio::test]
253    async fn put_narinfo_creates_parent_dir() {
254        let dir = tempfile::tempdir().unwrap();
255        let nested = dir.path().join("a").join("b").join("cache");
256        let storage = LocalStorage::new(&nested);
257        storage.put_narinfo("test", "content").await.unwrap();
258        assert!(nested.join("test.narinfo").exists());
259    }
260
261    #[tokio::test]
262    async fn put_nar_creates_parent_dirs() {
263        let dir = tempfile::tempdir().unwrap();
264        let storage = LocalStorage::new(dir.path());
265        storage.put_nar("nar/deep/path.nar.xz", b"data").await.unwrap();
266        assert!(dir.path().join("nar/deep/path.nar.xz").exists());
267    }
268
269    #[tokio::test]
270    async fn overwrite_narinfo() {
271        let dir = tempfile::tempdir().unwrap();
272        let storage = LocalStorage::new(dir.path());
273        storage.put_narinfo("hash", "version1").await.unwrap();
274        storage.put_narinfo("hash", "version2").await.unwrap();
275        let content = storage.get_narinfo("hash").await.unwrap().unwrap();
276        assert_eq!(content, "version2");
277    }
278
279    #[tokio::test]
280    async fn overwrite_nar() {
281        let dir = tempfile::tempdir().unwrap();
282        let storage = LocalStorage::new(dir.path());
283        storage.put_nar("nar/x.nar.xz", b"old").await.unwrap();
284        storage.put_nar("nar/x.nar.xz", b"new").await.unwrap();
285        let data = storage.get_nar("nar/x.nar.xz").await.unwrap().unwrap();
286        assert_eq!(data, b"new");
287    }
288}