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
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 #[tokio::test]
133 async fn get_missing_narinfo_returns_none() {
134 let dir = tempfile::tempdir().unwrap();
135 let storage = LocalStorage::new(dir.path());
136 let result = storage.get_narinfo("nonexistent").await.unwrap();
137 assert!(result.is_none());
138 }
139
140 #[tokio::test]
141 async fn put_and_get_narinfo() {
142 let dir = tempfile::tempdir().unwrap();
143 let storage = LocalStorage::new(dir.path());
144 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";
145 storage.put_narinfo("abc", content).await.unwrap();
146 let retrieved = storage.get_narinfo("abc").await.unwrap().unwrap();
147 assert_eq!(retrieved, content);
148 }
149
150 #[tokio::test]
151 async fn get_missing_nar_returns_none() {
152 let dir = tempfile::tempdir().unwrap();
153 let storage = LocalStorage::new(dir.path());
154 let result = storage.get_nar("nar/missing.nar.xz").await.unwrap();
155 assert!(result.is_none());
156 }
157
158 #[tokio::test]
159 async fn put_and_get_nar() {
160 let dir = tempfile::tempdir().unwrap();
161 let storage = LocalStorage::new(dir.path());
162 let data = b"fake nar data";
163 storage.put_nar("nar/abc.nar.xz", data).await.unwrap();
164 let retrieved = storage.get_nar("nar/abc.nar.xz").await.unwrap().unwrap();
165 assert_eq!(retrieved, data);
166 }
167
168 #[tokio::test]
169 async fn list_narinfos_empty() {
170 let dir = tempfile::tempdir().unwrap();
171 let storage = LocalStorage::new(dir.path());
172 let hashes = storage.list_narinfos().await.unwrap();
173 assert!(hashes.is_empty());
174 }
175
176 #[tokio::test]
177 async fn list_narinfos_returns_hashes() {
178 let dir = tempfile::tempdir().unwrap();
179 let storage = LocalStorage::new(dir.path());
180 storage.put_narinfo("aaa", "content1").await.unwrap();
181 storage.put_narinfo("bbb", "content2").await.unwrap();
182 let mut hashes = storage.list_narinfos().await.unwrap();
183 hashes.sort();
184 assert_eq!(hashes, vec!["aaa", "bbb"]);
185 }
186
187 #[tokio::test]
188 async fn list_narinfos_ignores_non_narinfo_files() {
189 let dir = tempfile::tempdir().unwrap();
190 let storage = LocalStorage::new(dir.path());
191 storage.put_narinfo("abc", "content").await.unwrap();
192 fs::write(dir.path().join("readme.txt"), "hello")
194 .await
195 .unwrap();
196 let hashes = storage.list_narinfos().await.unwrap();
197 assert_eq!(hashes, vec!["abc"]);
198 }
199
200 #[tokio::test]
201 async fn list_narinfos_on_nonexistent_dir() {
202 let storage = LocalStorage::new("/tmp/sui-cache-test-nonexistent-dir-12345");
203 let hashes = storage.list_narinfos().await.unwrap();
204 assert!(hashes.is_empty());
205 }
206
207 #[tokio::test]
208 async fn delete_removes_narinfo_and_nar() {
209 let dir = tempfile::tempdir().unwrap();
210 let storage = LocalStorage::new(dir.path());
211
212 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";
213 storage.put_narinfo("xyz", narinfo).await.unwrap();
214 storage.put_nar("nar/xyz.nar.xz", b"nar data").await.unwrap();
215
216 assert!(storage.get_narinfo("xyz").await.unwrap().is_some());
218 assert!(storage.get_nar("nar/xyz.nar.xz").await.unwrap().is_some());
219
220 storage.delete("xyz").await.unwrap();
222
223 assert!(storage.get_narinfo("xyz").await.unwrap().is_none());
225 assert!(storage.get_nar("nar/xyz.nar.xz").await.unwrap().is_none());
226 }
227
228 #[tokio::test]
229 async fn delete_nonexistent_is_noop() {
230 let dir = tempfile::tempdir().unwrap();
231 let storage = LocalStorage::new(dir.path());
232 storage.delete("nonexistent").await.unwrap();
234 }
235
236 #[tokio::test]
237 async fn root_accessor() {
238 let dir = tempfile::tempdir().unwrap();
239 let storage = LocalStorage::new(dir.path());
240 assert_eq!(storage.root(), dir.path());
241 }
242
243 #[tokio::test]
244 async fn put_narinfo_creates_parent_dir() {
245 let dir = tempfile::tempdir().unwrap();
246 let nested = dir.path().join("a").join("b").join("cache");
247 let storage = LocalStorage::new(&nested);
248 storage.put_narinfo("test", "content").await.unwrap();
249 assert!(nested.join("test.narinfo").exists());
250 }
251
252 #[tokio::test]
253 async fn put_nar_creates_parent_dirs() {
254 let dir = tempfile::tempdir().unwrap();
255 let storage = LocalStorage::new(dir.path());
256 storage.put_nar("nar/deep/path.nar.xz", b"data").await.unwrap();
257 assert!(dir.path().join("nar/deep/path.nar.xz").exists());
258 }
259
260 #[tokio::test]
261 async fn overwrite_narinfo() {
262 let dir = tempfile::tempdir().unwrap();
263 let storage = LocalStorage::new(dir.path());
264 storage.put_narinfo("hash", "version1").await.unwrap();
265 storage.put_narinfo("hash", "version2").await.unwrap();
266 let content = storage.get_narinfo("hash").await.unwrap().unwrap();
267 assert_eq!(content, "version2");
268 }
269
270 #[tokio::test]
271 async fn overwrite_nar() {
272 let dir = tempfile::tempdir().unwrap();
273 let storage = LocalStorage::new(dir.path());
274 storage.put_nar("nar/x.nar.xz", b"old").await.unwrap();
275 storage.put_nar("nar/x.nar.xz", b"new").await.unwrap();
276 let data = storage.get_nar("nar/x.nar.xz").await.unwrap().unwrap();
277 assert_eq!(data, b"new");
278 }
279}