1use std::path::{Path, PathBuf};
21
22use async_trait::async_trait;
23use futures::StreamExt;
24use tokio::fs;
25use tokio::io::AsyncWriteExt;
26
27use super::nar_refs::{NarRefIndex, NarRefScan};
28use super::nar_stream::{self, NarSource, NarStream};
29use super::{NarResidency, StorageBackend};
30use crate::StoreError;
31
32#[derive(Debug, Clone)]
34pub struct LocalStorage {
35 root: PathBuf,
37}
38
39impl LocalStorage {
40 pub fn new(path: impl Into<PathBuf>) -> Self {
44 Self { root: path.into() }
45 }
46
47 #[must_use]
49 pub fn root(&self) -> &Path {
50 &self.root
51 }
52
53 async fn ensure_dir(&self, path: &Path) -> Result<(), StoreError> {
55 if !path.exists() {
56 fs::create_dir_all(path).await.map_err(StoreError::Io)?;
57 }
58 Ok(())
59 }
60
61 fn narinfo_path(&self, hash: &str) -> PathBuf {
63 self.root.join(format!("{hash}.narinfo"))
64 }
65
66 fn nar_blob_path(&self, nar_path: &str) -> PathBuf {
69 self.root.join(nar_path)
70 }
71
72 fn nar_ref_dir(&self, nar_path: &str) -> PathBuf {
74 self.root.join(NarRefScan { nar_path }.to_string())
75 }
76
77 fn temp_sibling(final_path: &Path) -> PathBuf {
87 use std::sync::atomic::{AtomicU64, Ordering};
88 static SEQ: AtomicU64 = AtomicU64::new(0);
89 let n = SEQ.fetch_add(1, Ordering::Relaxed);
90 let pid = std::process::id();
91 let mut name = final_path.file_name().unwrap_or_default().to_os_string();
92 name.push(format!(".{pid}.{n}.tmp"));
93 final_path.with_file_name(name)
94 }
95}
96
97#[async_trait]
98impl StorageBackend for LocalStorage {
99 async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, StoreError> {
100 let path = self.narinfo_path(hash);
101 match fs::read_to_string(&path).await {
102 Ok(content) => Ok(Some(content)),
103 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
104 Err(e) => Err(StoreError::Io(e)),
105 }
106 }
107
108 async fn put_narinfo_record(&self, hash: &str, content: &str) -> Result<(), StoreError> {
109 self.ensure_dir(&self.root).await?;
110 let path = self.narinfo_path(hash);
111 fs::write(&path, content).await.map_err(StoreError::Io)
112 }
113
114 async fn delete_narinfo_record(&self, hash: &str) -> Result<(), StoreError> {
115 match fs::remove_file(self.narinfo_path(hash)).await {
116 Ok(()) => Ok(()),
117 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
118 Err(e) => Err(StoreError::Io(e)),
119 }
120 }
121
122 async fn delete_nar_record(&self, nar_path: &str) -> Result<(), StoreError> {
123 match fs::remove_file(self.nar_blob_path(nar_path)).await {
124 Ok(()) => Ok(()),
125 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
126 Err(e) => Err(StoreError::Io(e)),
127 }
128 }
129
130 fn nar_ref_index(&self) -> &dyn NarRefIndex {
131 self
132 }
133
134 async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, StoreError> {
135 match self.get_nar_stream(path).await? {
138 Some(s) => Ok(Some(nar_stream::collect_nar(s, None).await?)),
139 None => Ok(None),
140 }
141 }
142
143 async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), StoreError> {
144 self.put_nar_stream(path, &nar_stream::BytesNarSource::from(data)).await
145 }
146
147 fn nar_residency(&self) -> NarResidency {
150 NarResidency::Streaming
151 }
152
153 async fn get_nar_stream(&self, path: &str) -> Result<Option<NarStream>, StoreError> {
154 let full = self.nar_blob_path(path);
155 match fs::File::open(&full).await {
156 Ok(f) => Ok(Some(nar_stream::file_stream(f))),
157 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
158 Err(e) => Err(StoreError::Io(e)),
159 }
160 }
161
162 async fn put_nar_stream(&self, path: &str, src: &dyn NarSource) -> Result<(), StoreError> {
172 let full = self.nar_blob_path(path);
173 if let Some(parent) = full.parent() {
174 self.ensure_dir(parent).await?;
175 }
176 let tmp = Self::temp_sibling(&full);
177
178 let write = async {
181 let mut f = fs::File::create(&tmp).await.map_err(StoreError::Io)?;
182 let mut stream = src.open().await?;
183 while let Some(chunk) = stream.next().await {
184 let chunk = chunk?;
185 f.write_all(&chunk).await.map_err(StoreError::Io)?;
186 }
187 f.flush().await.map_err(StoreError::Io)?;
188 drop(f);
189 fs::rename(&tmp, &full).await.map_err(StoreError::Io)
190 }
191 .await;
192
193 if write.is_err() {
194 let _ = fs::remove_file(&tmp).await;
195 }
196 write
197 }
198
199 async fn list_narinfos(&self) -> Result<Vec<String>, StoreError> {
200 let mut hashes = Vec::new();
201 if !self.root.exists() {
202 return Ok(hashes);
203 }
204 let mut entries = fs::read_dir(&self.root).await.map_err(StoreError::Io)?;
205 while let Some(entry) = entries.next_entry().await.map_err(StoreError::Io)? {
206 let name = entry.file_name();
207 let name = name.to_string_lossy();
208 if let Some(hash) = name.strip_suffix(".narinfo") {
209 hashes.push(hash.to_string());
210 }
211 }
212 Ok(hashes)
213 }
214
215 async fn wipe_all(&self) -> Result<usize, StoreError> {
220 let n = self.list_narinfos().await?.len();
221 if self.root.exists() {
222 fs::remove_dir_all(&self.root).await.map_err(StoreError::Io)?;
223 }
224 Ok(n)
225 }
226}
227
228#[async_trait]
235impl NarRefIndex for LocalStorage {
236 async fn record(&self, nar_path: &str, hash: &str) -> Result<(), StoreError> {
237 let dir = self.nar_ref_dir(nar_path);
238 self.ensure_dir(&dir).await?;
239 fs::write(dir.join(hash), b"").await.map_err(StoreError::Io)
240 }
241
242 async fn forget(&self, nar_path: &str, hash: &str) -> Result<(), StoreError> {
243 let dir = self.nar_ref_dir(nar_path);
244 match fs::remove_file(dir.join(hash)).await {
245 Ok(()) => {}
246 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
247 Err(e) => return Err(StoreError::Io(e)),
248 }
249 let _ = fs::remove_dir(&dir).await;
253 Ok(())
254 }
255
256 async fn referrers(&self, nar_path: &str) -> Result<Vec<String>, StoreError> {
257 let dir = self.nar_ref_dir(nar_path);
258 let mut entries = match fs::read_dir(&dir).await {
259 Ok(e) => e,
260 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
261 Err(e) => return Err(StoreError::Io(e)),
262 };
263 let mut hashes = Vec::new();
264 while let Some(entry) = entries.next_entry().await.map_err(StoreError::Io)? {
265 hashes.push(entry.file_name().to_string_lossy().into_owned());
266 }
267 hashes.sort();
268 Ok(hashes)
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[tokio::test]
277 async fn get_missing_narinfo_returns_none() {
278 let dir = tempfile::tempdir().unwrap();
279 let storage = LocalStorage::new(dir.path());
280 let result = storage.get_narinfo("nonexistent").await.unwrap();
281 assert!(result.is_none());
282 }
283
284 #[tokio::test]
285 async fn put_and_get_narinfo() {
286 let dir = tempfile::tempdir().unwrap();
287 let storage = LocalStorage::new(dir.path());
288 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";
289 storage.put_narinfo("abc", content).await.unwrap();
290 let retrieved = storage.get_narinfo("abc").await.unwrap().unwrap();
291 assert_eq!(retrieved, content);
292 }
293
294 #[tokio::test]
295 async fn get_missing_nar_returns_none() {
296 let dir = tempfile::tempdir().unwrap();
297 let storage = LocalStorage::new(dir.path());
298 let result = storage.get_nar("nar/missing.nar.xz").await.unwrap();
299 assert!(result.is_none());
300 }
301
302 #[tokio::test]
303 async fn put_and_get_nar() {
304 let dir = tempfile::tempdir().unwrap();
305 let storage = LocalStorage::new(dir.path());
306 let data = b"fake nar data";
307 storage.put_nar("nar/abc.nar.xz", data).await.unwrap();
308 let retrieved = storage.get_nar("nar/abc.nar.xz").await.unwrap().unwrap();
309 assert_eq!(retrieved, data);
310 }
311
312 #[tokio::test]
313 async fn list_narinfos_empty() {
314 let dir = tempfile::tempdir().unwrap();
315 let storage = LocalStorage::new(dir.path());
316 let hashes = storage.list_narinfos().await.unwrap();
317 assert!(hashes.is_empty());
318 }
319
320 #[tokio::test]
321 async fn list_narinfos_returns_hashes() {
322 let dir = tempfile::tempdir().unwrap();
323 let storage = LocalStorage::new(dir.path());
324 storage.put_narinfo("aaa", "content1").await.unwrap();
325 storage.put_narinfo("bbb", "content2").await.unwrap();
326 let mut hashes = storage.list_narinfos().await.unwrap();
327 hashes.sort();
328 assert_eq!(hashes, vec!["aaa", "bbb"]);
329 }
330
331 #[tokio::test]
332 async fn list_narinfos_ignores_non_narinfo_files() {
333 let dir = tempfile::tempdir().unwrap();
334 let storage = LocalStorage::new(dir.path());
335 storage.put_narinfo("abc", "content").await.unwrap();
336 fs::write(dir.path().join("readme.txt"), "hello")
338 .await
339 .unwrap();
340 let hashes = storage.list_narinfos().await.unwrap();
341 assert_eq!(hashes, vec!["abc"]);
342 }
343
344 #[tokio::test]
345 async fn list_narinfos_on_nonexistent_dir() {
346 let storage = LocalStorage::new("/tmp/sui-castore-test-nonexistent-dir-12345");
347 let hashes = storage.list_narinfos().await.unwrap();
348 assert!(hashes.is_empty());
349 }
350
351 #[tokio::test]
352 async fn delete_removes_narinfo_and_nar() {
353 let dir = tempfile::tempdir().unwrap();
354 let storage = LocalStorage::new(dir.path());
355
356 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";
357 storage.put_narinfo("xyz", narinfo).await.unwrap();
358 storage.put_nar("nar/xyz.nar.xz", b"nar data").await.unwrap();
359
360 assert!(storage.get_narinfo("xyz").await.unwrap().is_some());
361 assert!(storage.get_nar("nar/xyz.nar.xz").await.unwrap().is_some());
362
363 storage.delete("xyz").await.unwrap();
364
365 assert!(storage.get_narinfo("xyz").await.unwrap().is_none());
366 assert!(storage.get_nar("nar/xyz.nar.xz").await.unwrap().is_none());
367 }
368
369 #[tokio::test]
370 async fn delete_nonexistent_is_noop() {
371 let dir = tempfile::tempdir().unwrap();
372 let storage = LocalStorage::new(dir.path());
373 storage.delete("nonexistent").await.unwrap();
374 }
375
376 fn narinfo_for(url: &str) -> String {
380 format!(
381 "StorePath: /nix/store/pkg\nURL: {url}\nCompression: xz\nFileHash: sha256:aaa\n\
382 FileSize: 100\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n"
383 )
384 }
385
386 #[tokio::test]
389 async fn the_index_is_a_directory_of_edge_files() {
390 let dir = tempfile::tempdir().unwrap();
391 let storage = LocalStorage::new(dir.path());
392 storage.put_narinfo("pathA", &narinfo_for("nar/shared.nar.xz")).await.unwrap();
393 storage.put_narinfo("pathB", &narinfo_for("nar/shared.nar.xz")).await.unwrap();
394
395 assert!(dir.path().join("nar-refs/nar/shared.nar.xz/pathA").exists());
396 assert!(dir.path().join("nar-refs/nar/shared.nar.xz/pathB").exists());
397 assert_eq!(
398 storage.nar_ref_index().referrers("nar/shared.nar.xz").await.unwrap(),
399 vec!["pathA".to_string(), "pathB".to_string()],
400 );
401
402 storage.put_narinfo("pathA", &narinfo_for("nar/shared.nar.xz")).await.unwrap();
404 assert_eq!(
405 storage.nar_ref_index().referrers("nar/shared.nar.xz").await.unwrap().len(),
406 2,
407 );
408 }
409
410 #[tokio::test]
412 async fn edge_files_do_not_pollute_the_narinfo_listing() {
413 let dir = tempfile::tempdir().unwrap();
414 let storage = LocalStorage::new(dir.path());
415 storage.put_narinfo("pathA", &narinfo_for("nar/shared.nar.xz")).await.unwrap();
416 assert_eq!(storage.list_narinfos().await.unwrap(), vec!["pathA".to_string()]);
417 }
418
419 #[tokio::test]
422 async fn the_last_edge_takes_its_directory_with_it() {
423 let dir = tempfile::tempdir().unwrap();
424 let storage = LocalStorage::new(dir.path());
425 storage.put_narinfo("pathA", &narinfo_for("nar/x.nar.xz")).await.unwrap();
426 assert!(dir.path().join("nar-refs/nar/x.nar.xz").exists());
427 storage.delete("pathA").await.unwrap();
428 assert!(!dir.path().join("nar-refs/nar/x.nar.xz").exists());
429 }
430
431 #[tokio::test]
435 async fn a_traversal_url_is_refused_rather_than_stored() {
436 let dir = tempfile::tempdir().unwrap();
437 let storage = LocalStorage::new(dir.path());
438 let evil = narinfo_for("../../escape.nar");
439 let err = storage.put_narinfo("evil", &evil).await.unwrap_err();
440 assert!(
441 matches!(err, StoreError::NarInfo(ref m) if m.contains("unaddressable")),
442 "expected a typed refusal, got {err:?}",
443 );
444 assert!(
445 storage.get_narinfo("evil").await.unwrap().is_none(),
446 "a refused narinfo must not be stored either",
447 );
448 }
449
450 #[tokio::test]
453 async fn reindex_rebuilds_edges_for_a_pre_index_store() {
454 let dir = tempfile::tempdir().unwrap();
455 let storage = LocalStorage::new(dir.path());
456 storage.put_narinfo_record("pathA", &narinfo_for("nar/shared.nar.xz")).await.unwrap();
459 storage.put_narinfo_record("pathB", &narinfo_for("nar/shared.nar.xz")).await.unwrap();
460 assert!(
461 storage.nar_ref_index().referrers("nar/shared.nar.xz").await.unwrap().is_empty(),
462 "the fixture must actually start unindexed",
463 );
464
465 assert_eq!(storage.reindex_nar_refs().await.unwrap(), 2);
466 assert_eq!(
467 storage.nar_ref_index().referrers("nar/shared.nar.xz").await.unwrap(),
468 vec!["pathA".to_string(), "pathB".to_string()],
469 );
470
471 assert_eq!(storage.reindex_nar_refs().await.unwrap(), 2);
473 assert_eq!(
474 storage.nar_ref_index().referrers("nar/shared.nar.xz").await.unwrap().len(),
475 2,
476 );
477 }
478
479 #[tokio::test]
480 async fn root_accessor() {
481 let dir = tempfile::tempdir().unwrap();
482 let storage = LocalStorage::new(dir.path());
483 assert_eq!(storage.root(), dir.path());
484 }
485
486 #[tokio::test]
487 async fn put_narinfo_creates_parent_dir() {
488 let dir = tempfile::tempdir().unwrap();
489 let nested = dir.path().join("a").join("b").join("cache");
490 let storage = LocalStorage::new(&nested);
491 storage.put_narinfo("test", "content").await.unwrap();
492 assert!(nested.join("test.narinfo").exists());
493 }
494
495 #[tokio::test]
496 async fn put_nar_creates_parent_dirs() {
497 let dir = tempfile::tempdir().unwrap();
498 let storage = LocalStorage::new(dir.path());
499 storage.put_nar("nar/deep/path.nar.xz", b"data").await.unwrap();
500 assert!(dir.path().join("nar/deep/path.nar.xz").exists());
501 }
502
503 #[tokio::test]
504 async fn overwrite_narinfo() {
505 let dir = tempfile::tempdir().unwrap();
506 let storage = LocalStorage::new(dir.path());
507 storage.put_narinfo("hash", "version1").await.unwrap();
508 storage.put_narinfo("hash", "version2").await.unwrap();
509 let content = storage.get_narinfo("hash").await.unwrap().unwrap();
510 assert_eq!(content, "version2");
511 }
512
513 #[tokio::test]
514 async fn overwrite_nar() {
515 let dir = tempfile::tempdir().unwrap();
516 let storage = LocalStorage::new(dir.path());
517 storage.put_nar("nar/x.nar.xz", b"old").await.unwrap();
518 storage.put_nar("nar/x.nar.xz", b"new").await.unwrap();
519 let data = storage.get_nar("nar/x.nar.xz").await.unwrap().unwrap();
520 assert_eq!(data, b"new");
521 }
522
523 use super::nar_stream::{collect_nar, BytesNarSource, NarStream, NAR_CHUNK_BYTES};
526
527 fn multi_chunk() -> Vec<u8> {
528 (0..NAR_CHUNK_BYTES * 2 + 33).map(|i| (i % 251) as u8).collect()
529 }
530
531 #[tokio::test]
532 async fn residency_is_streaming() {
533 let dir = tempfile::tempdir().unwrap();
534 assert_eq!(LocalStorage::new(dir.path()).nar_residency(), NarResidency::Streaming);
535 }
536
537 #[tokio::test]
538 async fn a_multi_chunk_nar_round_trips_and_every_chunk_is_bounded() {
539 let dir = tempfile::tempdir().unwrap();
540 let storage = LocalStorage::new(dir.path());
541 let nar = multi_chunk();
542 storage
543 .put_nar_stream("nar/big.nar.xz", &BytesNarSource::new(nar.clone()))
544 .await
545 .unwrap();
546
547 let mut s = storage.get_nar_stream("nar/big.nar.xz").await.unwrap().unwrap();
548 let mut seen = Vec::new();
549 while let Some(c) = s.next().await {
550 let c = c.unwrap();
551 assert!(c.len() <= NAR_CHUNK_BYTES, "the read path handed out an unbounded chunk");
552 seen.extend_from_slice(&c);
553 }
554 assert_eq!(seen, nar);
555 }
556
557 #[tokio::test]
558 async fn a_streamed_write_leaves_no_scratch_file_behind() {
559 let dir = tempfile::tempdir().unwrap();
560 let storage = LocalStorage::new(dir.path());
561 storage.put_nar("nar/x.nar.xz", b"bytes").await.unwrap();
562 let mut entries = fs::read_dir(dir.path().join("nar")).await.unwrap();
563 let mut names = Vec::new();
564 while let Some(e) = entries.next_entry().await.unwrap() {
565 names.push(e.file_name().to_string_lossy().into_owned());
566 }
567 assert_eq!(names, vec!["x.nar.xz".to_string()], "a .tmp survived the rename");
568 }
569
570 struct FailingSource {
573 good_bytes: usize,
574 }
575
576 #[async_trait]
577 impl super::nar_stream::NarSource for FailingSource {
578 async fn open(&self) -> Result<NarStream, StoreError> {
579 let n = self.good_bytes;
580 Ok(futures::stream::iter(vec![
581 Ok(bytes::Bytes::from(vec![7u8; n])),
582 Err(StoreError::Io(std::io::Error::other("upload died mid-stream"))),
583 ])
584 .boxed())
585 }
586 }
587
588 #[tokio::test]
589 async fn a_write_that_dies_mid_stream_publishes_nothing_at_all() {
590 let dir = tempfile::tempdir().unwrap();
596 let storage = LocalStorage::new(dir.path());
597 let err = storage
598 .put_nar_stream("nar/doomed.nar.xz", &FailingSource { good_bytes: 4096 })
599 .await
600 .unwrap_err();
601 assert!(matches!(err, StoreError::Io(_)));
602
603 assert!(
604 storage.get_nar("nar/doomed.nar.xz").await.unwrap().is_none(),
605 "a half-written NAR must never be readable",
606 );
607 let mut entries = fs::read_dir(dir.path().join("nar")).await.unwrap();
608 assert!(
609 entries.next_entry().await.unwrap().is_none(),
610 "the scratch file must be cleaned up on failure",
611 );
612 }
613
614 #[tokio::test]
615 async fn a_failed_rewrite_does_not_destroy_the_previous_value() {
616 let dir = tempfile::tempdir().unwrap();
619 let storage = LocalStorage::new(dir.path());
620 storage.put_nar("nar/x.nar.xz", b"the good bytes").await.unwrap();
621 let _ = storage
622 .put_nar_stream("nar/x.nar.xz", &FailingSource { good_bytes: 8 })
623 .await;
624 assert_eq!(
625 storage.get_nar("nar/x.nar.xz").await.unwrap().unwrap(),
626 b"the good bytes",
627 );
628 }
629
630 #[tokio::test]
631 async fn concurrent_writes_of_the_same_key_do_not_splice() {
632 let dir = tempfile::tempdir().unwrap();
637 let storage = std::sync::Arc::new(LocalStorage::new(dir.path()));
638 let nar = multi_chunk();
639 let mut set = tokio::task::JoinSet::new();
640 for _ in 0..8 {
641 let s = std::sync::Arc::clone(&storage);
642 let n = nar.clone();
643 set.spawn(async move {
644 s.put_nar_stream("nar/raced.nar.xz", &BytesNarSource::new(n)).await
645 });
646 }
647 while let Some(r) = set.join_next().await {
648 r.expect("task panicked").expect("write failed");
649 }
650 let got = collect_nar(
651 storage.get_nar_stream("nar/raced.nar.xz").await.unwrap().unwrap(),
652 None,
653 )
654 .await
655 .unwrap();
656 assert_eq!(got, nar, "a raced write spliced the file");
657 }
658}