sui_cache/storage/
local.rs1use std::path::{Path, PathBuf};
12
13use async_trait::async_trait;
14use tokio::fs;
15
16use super::StorageBackend;
17use crate::CacheError;
18
19#[derive(Debug, Clone)]
21pub struct LocalStorage {
22 root: PathBuf,
24}
25
26impl LocalStorage {
27 pub fn new(path: impl Into<PathBuf>) -> Self {
31 Self { root: path.into() }
32 }
33
34 #[must_use]
36 pub fn root(&self) -> &Path {
37 &self.root
38 }
39
40 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 fn narinfo_path(&self, hash: &str) -> PathBuf {
50 self.root.join(format!("{hash}.narinfo"))
51 }
52
53 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 let narinfo_path = self.narinfo_path(hash);
96 if narinfo_path.exists() {
97 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 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 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 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();
234
235 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 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}