sui_castore/storage/
local.rs1use std::path::{Path, PathBuf};
12
13use async_trait::async_trait;
14use tokio::fs;
15
16use super::StorageBackend;
17use crate::StoreError;
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<(), StoreError> {
42 if !path.exists() {
43 fs::create_dir_all(path).await.map_err(StoreError::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 {
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 let narinfo_path = self.narinfo_path(hash);
97 if narinfo_path.exists() {
98 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 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 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}