Skip to main content

sui_cache/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::CacheError;
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<(), CacheError> {
42        if !path.exists() {
43            fs::create_dir_all(path).await.map_err(CacheError::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 `nar/xyz.nar.xz`.
54    fn nar_blob_path(&self, nar_path: &str) -> PathBuf {
55        self.root.join(nar_path)
56    }
57}
58
59#[async_trait]
60impl StorageBackend for LocalStorage {
61    async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, CacheError> {
62        let path = self.narinfo_path(hash);
63        match fs::read_to_string(&path).await {
64            Ok(content) => Ok(Some(content)),
65            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
66            Err(e) => Err(CacheError::Io(e)),
67        }
68    }
69
70    async fn put_narinfo(&self, hash: &str, content: &str) -> Result<(), CacheError> {
71        self.ensure_dir(&self.root).await?;
72        let path = self.narinfo_path(hash);
73        fs::write(&path, content).await.map_err(CacheError::Io)
74    }
75
76    async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, CacheError> {
77        let full = self.nar_blob_path(path);
78        match fs::read(&full).await {
79            Ok(data) => Ok(Some(data)),
80            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
81            Err(e) => Err(CacheError::Io(e)),
82        }
83    }
84
85    async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), CacheError> {
86        let full = self.nar_blob_path(path);
87        if let Some(parent) = full.parent() {
88            self.ensure_dir(parent).await?;
89        }
90        fs::write(&full, data).await.map_err(CacheError::Io)
91    }
92
93    async fn delete(&self, hash: &str) -> Result<(), CacheError> {
94        // Read narinfo to find the NAR blob path, then delete both.
95        let narinfo_path = self.narinfo_path(hash);
96        if narinfo_path.exists() {
97            // Try to parse the narinfo to find the NAR URL.
98            if let Ok(content) = fs::read_to_string(&narinfo_path).await {
99                if let Ok(info) = sui_compat::narinfo::NarInfo::parse(&content) {
100                    let nar_path = self.nar_blob_path(&info.url);
101                    let _ = fs::remove_file(&nar_path).await;
102                }
103            }
104            fs::remove_file(&narinfo_path)
105                .await
106                .map_err(CacheError::Io)?;
107        }
108        Ok(())
109    }
110
111    async fn list_narinfos(&self) -> Result<Vec<String>, CacheError> {
112        let mut hashes = Vec::new();
113        if !self.root.exists() {
114            return Ok(hashes);
115        }
116        let mut entries = fs::read_dir(&self.root).await.map_err(CacheError::Io)?;
117        while let Some(entry) = entries.next_entry().await.map_err(CacheError::Io)? {
118            let name = entry.file_name();
119            let name = name.to_string_lossy();
120            if let Some(hash) = name.strip_suffix(".narinfo") {
121                hashes.push(hash.to_string());
122            }
123        }
124        Ok(hashes)
125    }
126
127    /// Complete L3 wipe: remove the entire cache directory (narinfos + the `nar/`
128    /// blob subtree), reclaiming NAR bytes a per-hash `delete` cannot reach. The
129    /// directory is re-created lazily on the next `put`. Returns the narinfo
130    /// count removed.
131    async fn wipe_all(&self) -> Result<usize, CacheError> {
132        let n = self.list_narinfos().await?.len();
133        if self.root.exists() {
134            fs::remove_dir_all(&self.root).await.map_err(CacheError::Io)?;
135        }
136        Ok(n)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[tokio::test]
145    async fn get_missing_narinfo_returns_none() {
146        let dir = tempfile::tempdir().unwrap();
147        let storage = LocalStorage::new(dir.path());
148        let result = storage.get_narinfo("nonexistent").await.unwrap();
149        assert!(result.is_none());
150    }
151
152    #[tokio::test]
153    async fn put_and_get_narinfo() {
154        let dir = tempfile::tempdir().unwrap();
155        let storage = LocalStorage::new(dir.path());
156        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";
157        storage.put_narinfo("abc", content).await.unwrap();
158        let retrieved = storage.get_narinfo("abc").await.unwrap().unwrap();
159        assert_eq!(retrieved, content);
160    }
161
162    #[tokio::test]
163    async fn get_missing_nar_returns_none() {
164        let dir = tempfile::tempdir().unwrap();
165        let storage = LocalStorage::new(dir.path());
166        let result = storage.get_nar("nar/missing.nar.xz").await.unwrap();
167        assert!(result.is_none());
168    }
169
170    #[tokio::test]
171    async fn put_and_get_nar() {
172        let dir = tempfile::tempdir().unwrap();
173        let storage = LocalStorage::new(dir.path());
174        let data = b"fake nar data";
175        storage.put_nar("nar/abc.nar.xz", data).await.unwrap();
176        let retrieved = storage.get_nar("nar/abc.nar.xz").await.unwrap().unwrap();
177        assert_eq!(retrieved, data);
178    }
179
180    #[tokio::test]
181    async fn list_narinfos_empty() {
182        let dir = tempfile::tempdir().unwrap();
183        let storage = LocalStorage::new(dir.path());
184        let hashes = storage.list_narinfos().await.unwrap();
185        assert!(hashes.is_empty());
186    }
187
188    #[tokio::test]
189    async fn list_narinfos_returns_hashes() {
190        let dir = tempfile::tempdir().unwrap();
191        let storage = LocalStorage::new(dir.path());
192        storage.put_narinfo("aaa", "content1").await.unwrap();
193        storage.put_narinfo("bbb", "content2").await.unwrap();
194        let mut hashes = storage.list_narinfos().await.unwrap();
195        hashes.sort();
196        assert_eq!(hashes, vec!["aaa", "bbb"]);
197    }
198
199    #[tokio::test]
200    async fn list_narinfos_ignores_non_narinfo_files() {
201        let dir = tempfile::tempdir().unwrap();
202        let storage = LocalStorage::new(dir.path());
203        storage.put_narinfo("abc", "content").await.unwrap();
204        // Write a non-narinfo file.
205        fs::write(dir.path().join("readme.txt"), "hello")
206            .await
207            .unwrap();
208        let hashes = storage.list_narinfos().await.unwrap();
209        assert_eq!(hashes, vec!["abc"]);
210    }
211
212    #[tokio::test]
213    async fn list_narinfos_on_nonexistent_dir() {
214        let storage = LocalStorage::new("/tmp/sui-cache-test-nonexistent-dir-12345");
215        let hashes = storage.list_narinfos().await.unwrap();
216        assert!(hashes.is_empty());
217    }
218
219    #[tokio::test]
220    async fn delete_removes_narinfo_and_nar() {
221        let dir = tempfile::tempdir().unwrap();
222        let storage = LocalStorage::new(dir.path());
223
224        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";
225        storage.put_narinfo("xyz", narinfo).await.unwrap();
226        storage.put_nar("nar/xyz.nar.xz", b"nar data").await.unwrap();
227
228        // Verify both exist.
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        // Delete.
233        storage.delete("xyz").await.unwrap();
234
235        // Both should be gone.
236        assert!(storage.get_narinfo("xyz").await.unwrap().is_none());
237        assert!(storage.get_nar("nar/xyz.nar.xz").await.unwrap().is_none());
238    }
239
240    #[tokio::test]
241    async fn delete_nonexistent_is_noop() {
242        let dir = tempfile::tempdir().unwrap();
243        let storage = LocalStorage::new(dir.path());
244        // Should not error.
245        storage.delete("nonexistent").await.unwrap();
246    }
247
248    #[tokio::test]
249    async fn root_accessor() {
250        let dir = tempfile::tempdir().unwrap();
251        let storage = LocalStorage::new(dir.path());
252        assert_eq!(storage.root(), dir.path());
253    }
254
255    #[tokio::test]
256    async fn put_narinfo_creates_parent_dir() {
257        let dir = tempfile::tempdir().unwrap();
258        let nested = dir.path().join("a").join("b").join("cache");
259        let storage = LocalStorage::new(&nested);
260        storage.put_narinfo("test", "content").await.unwrap();
261        assert!(nested.join("test.narinfo").exists());
262    }
263
264    #[tokio::test]
265    async fn put_nar_creates_parent_dirs() {
266        let dir = tempfile::tempdir().unwrap();
267        let storage = LocalStorage::new(dir.path());
268        storage.put_nar("nar/deep/path.nar.xz", b"data").await.unwrap();
269        assert!(dir.path().join("nar/deep/path.nar.xz").exists());
270    }
271
272    #[tokio::test]
273    async fn overwrite_narinfo() {
274        let dir = tempfile::tempdir().unwrap();
275        let storage = LocalStorage::new(dir.path());
276        storage.put_narinfo("hash", "version1").await.unwrap();
277        storage.put_narinfo("hash", "version2").await.unwrap();
278        let content = storage.get_narinfo("hash").await.unwrap().unwrap();
279        assert_eq!(content, "version2");
280    }
281
282    #[tokio::test]
283    async fn overwrite_nar() {
284        let dir = tempfile::tempdir().unwrap();
285        let storage = LocalStorage::new(dir.path());
286        storage.put_nar("nar/x.nar.xz", b"old").await.unwrap();
287        storage.put_nar("nar/x.nar.xz", b"new").await.unwrap();
288        let data = storage.get_nar("nar/x.nar.xz").await.unwrap().unwrap();
289        assert_eq!(data, b"new");
290    }
291}