Skip to main content

sui_castore/storage/
local.rs

1//! Local filesystem storage backend.
2//!
3//! Layout:
4//! ```text
5//! <root>/
6//!   <hash>.narinfo          -- text narinfo metadata
7//!   nar/
8//!     <narhash>.nar.xz      -- compressed NAR blobs
9//!   nar-refs/
10//!     nar/<narhash>.nar.xz/
11//!       <hash>              -- empty file: "this store hash advertises that NAR"
12//! ```
13//!
14//! `nar-refs/` mirrors the NAR key space one level down, one empty file per
15//! reverse edge (see [`nar_refs`](super::nar_refs)). A directory listing *is*
16//! the referrer set, so a lookup is one `read_dir` of a directory holding as
17//! many entries as there are referrers — normally exactly one. Recording an edge
18//! is a blind `create`, so two writers cannot lose each other's edge.
19
20use 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/// Filesystem-backed binary cache storage.
33#[derive(Debug, Clone)]
34pub struct LocalStorage {
35    /// Root directory for all cache data.
36    root: PathBuf,
37}
38
39impl LocalStorage {
40    /// Create a new local storage backend rooted at `path`.
41    ///
42    /// The directory structure is created lazily on first write.
43    pub fn new(path: impl Into<PathBuf>) -> Self {
44        Self { root: path.into() }
45    }
46
47    /// Return the root path.
48    #[must_use]
49    pub fn root(&self) -> &Path {
50        &self.root
51    }
52
53    /// Ensure a directory exists.
54    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    /// Path to a narinfo file.
62    fn narinfo_path(&self, hash: &str) -> PathBuf {
63        self.root.join(format!("{hash}.narinfo"))
64    }
65
66    /// Path to a NAR blob. The `nar_path` is a relative path like
67    /// `nar/xyz.nar.xz`.
68    fn nar_blob_path(&self, nar_path: &str) -> PathBuf {
69        self.root.join(nar_path)
70    }
71
72    /// Directory holding one empty file per narinfo advertising `nar_path`.
73    fn nar_ref_dir(&self, nar_path: &str) -> PathBuf {
74        self.root.join(NarRefScan { nar_path }.to_string())
75    }
76
77    /// A unique scratch path beside `final_path`, for the write-then-rename in
78    /// [`put_nar_stream`](StorageBackend::put_nar_stream).
79    ///
80    /// Unique **per write, not per key**: two pods (or two tasks) racing to push
81    /// the same content-addressed key is the normal case, and a shared temp name
82    /// would have them interleave chunks into one file and rename a spliced NAR
83    /// into place. Process id + a monotonic counter makes that unrepresentable
84    /// without a lock. Beside the target, never in `/tmp`, so the rename stays
85    /// on one filesystem and therefore atomic.
86    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        // ONE code path: the whole-value verb is the streaming verb drained.
136        // Two independent readers would be two chances to diverge.
137        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    /// **O(chunk).** Reads and writes go through a bounded buffer; the file's
148    /// size never appears in this process's heap.
149    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    /// Write chunk-by-chunk **through a temp file, then rename**.
163    ///
164    /// The rename is not tidiness: a streamed write is no longer atomic the way
165    /// a single `write(2)` of a whole buffer was, so a crash (or an `ENOSPC`
166    /// three chunks in — the exact live failure on the full tmpfs) would
167    /// otherwise leave a *truncated* NAR at the real path, and a truncated NAR
168    /// is silent corruption, strictly worse than the OOM being fixed. Writing
169    /// aside and renaming means a partial write leaves nothing: the next read is
170    /// a clean miss and the client rebuilds.
171    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        // Anything that leaves this block early must not leave the temp file
179        // behind, so the result is captured and the cleanup runs unconditionally.
180        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    /// Complete L3 wipe: remove the entire cache directory (narinfos + the
216    /// `nar/` blob subtree), reclaiming NAR bytes a per-hash `delete` cannot
217    /// reach. The directory is re-created lazily on the next `put`. Returns
218    /// the narinfo count removed.
219    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/// The reverse index as a directory tree: one empty file per edge.
229///
230/// `record` is a blind `create` of a path that names its own content, so it is
231/// idempotent and two writers racing on the same edge simply write the same
232/// empty file. Nothing here reads a set to write it back, which is why
233/// concurrent narinfo pushes cannot lose an edge.
234#[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        // Tidy the now-possibly-empty directory. `remove_dir` fails on a
250        // non-empty directory, which is exactly the "another referrer is still
251        // here" case, so the error is the answer and is discarded.
252        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        // Write a non-narinfo file.
337        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    // ── the on-disk reverse index ──────────────────────────────────────────
377
378    /// A narinfo for a store path advertising `url`.
379    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    /// The edge is a file whose path names the pair, so writing it twice is
387    /// writing the same file twice and a listing is the referrer set.
388    #[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        // A re-push of the same narinfo is the same file, not a second edge.
403        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    /// `nar-refs/` sits beside `nar/`, so it is neither a narinfo nor a NAR.
411    #[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    /// Forgetting the last edge removes the directory too, so the index does
420    /// not accumulate one empty directory per NAR ever cached.
421    #[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    /// A `URL:` that would escape the cache root is refused at the write
432    /// boundary, not sanitized at each of the several places it is used (a
433    /// filesystem join here, a key elsewhere).
434    #[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    /// A store filled **before** the index existed has no edges. `reindex_nar_refs`
451    /// rebuilds them from the narinfos already on disk, and it is idempotent.
452    #[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        // Write narinfos through the RECORD verb — exactly what a pre-index
457        // binary did, since it had no index to maintain.
458        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        // Idempotent: a second run records the same edges, not duplicates.
472        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    // ── streamed NAR I/O ───────────────────────────────────────────────────
524
525    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    /// A source whose stream fails partway — a client that hung up mid-upload,
571    /// or a lower tier that died mid-promotion.
572    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        // A streamed write is not atomic the way a single whole-buffer `write`
591        // was, so without the write-then-rename this would leave a TRUNCATED
592        // NAR at the real path — silent corruption, strictly worse than the OOM
593        // this change is against. Nothing must be published, and no scratch
594        // file may survive.
595        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        // The other half of write-then-rename: an existing good NAR must
617        // survive a failed re-put rather than being truncated in place.
618        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        // Two pushes of the same content-addressed key race routinely. A shared
633        // scratch name would let them interleave into one file and rename a
634        // spliced NAR into place; per-write scratch names make that
635        // unreachable.
636        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}