Skip to main content

sui_castore/storage/
mod.rs

1//! Storage backend trait and implementations.
2//!
3//! The `StorageBackend` trait abstracts over where narinfo metadata and
4//! compressed NAR blobs are persisted. Implementations provided:
5//!
6//! - [`LocalStorage`] — local filesystem (default)
7//! - [`S3Storage`] — S3-compatible object storage (AWS, MinIO, R2, RustFS)
8//! - [`RedisBackend`] — Redis L1 hot cache (sub-ms, TTL/eviction-aware)
9//! - [`PgStorageBackend`] — Postgres L2 durable cache tier (shared,
10//!   authoritative)
11//! - [`TieredBackend`] — L1→L2→L3 read-through/write-through resolver
12//! - [`StorageIndex`] — redb ephemeral metadata index (accelerates S3 lookups)
13//!
14//! [`build_backend`] is the typed config-select factory: it dispatches a
15//! [`BackendConfig`](crate::config::BackendConfig) to its concrete backend
16//! (recursing for the tiered arm), so a deployment picks `{disk | s3 | redis |
17//! pg | tiered}` by configuration — never a silent hard-coded constructor.
18
19pub mod index;
20pub mod local;
21pub mod nar_refs;
22pub mod nar_stream;
23pub mod pg;
24pub mod redis;
25pub mod s3;
26pub mod tiered;
27
28use std::sync::Arc;
29
30pub use index::StorageIndex;
31pub use local::LocalStorage;
32pub use nar_refs::{
33    advertised_nar_url, advertised_url_line, is_addressable_nar_path, referrer_of, MemNarRefIndex,
34    NarRefIndex, NarRefKey, NarRefScan, NAR_REF_PREFIX,
35};
36pub use nar_stream::{
37    bytes_stream, collect_nar, empty_stream, file_stream, spool_or_buffer, whole_value_stream,
38    BytesNarSource, FileNarSource, NarSource, NarStream, SpooledNarSource,
39    DEFAULT_INGEST_MEMORY_CAP, NAR_CHUNK_BYTES,
40};
41pub use pg::{PgCacheConn, PgStorageBackend, PgTable};
42pub use redis::{RedisBackend, RedisConn};
43pub use s3::S3Storage;
44pub use tiered::{TieredBackend, TieredTier, WritePolicy, TIERED_BACKEND_TIER};
45
46#[cfg(feature = "redis-client")]
47pub use redis::RedisConnectionManager;
48
49#[cfg(feature = "postgres")]
50pub use pg::SqlxPgCacheConn;
51
52use async_trait::async_trait;
53use futures::future::BoxFuture;
54
55use crate::config::BackendConfig;
56use crate::StoreError;
57
58/// What a backend's NAR path costs in resident memory.
59///
60/// **Every [`StorageBackend`] implementor must state this — it has no default.**
61/// That is the mechanism, not decoration: the streaming verbs
62/// ([`get_nar_stream`](StorageBackend::get_nar_stream) /
63/// [`put_nar_stream`](StorageBackend::put_nar_stream)) *do* carry a buffering
64/// fallback so a test double stays a few lines, and without a required
65/// declaration a new production backend could silently inherit it and
66/// reintroduce the OOM. Making the declaration mandatory means adding a backend
67/// without deciding is a **compile error**, and shipping a production backend
68/// that declares [`WholeValue`](NarResidency::WholeValue) is caught by
69/// [`every_production_backend_bounds_its_nar_path`] in CI.
70///
71/// Tier-honest: this is *parse-time-rejected* (you cannot omit the decision) plus
72/// *CI-gate-caught* (you cannot ship the wrong one from the factory). It is
73/// **not** truly-unrepresentable — the buffering code path still exists and a
74/// hand-constructed backend may use it.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum NarResidency {
77    /// **O(chunk).** The backend never holds more than [`NAR_CHUNK_BYTES`] of a
78    /// NAR, whatever the NAR's size.
79    Streaming,
80    /// **O(min(nar, cap)).** Bounded by a configured cap; a NAR past the cap is
81    /// *refused* ([`StoreError::TooLarge`]), never buffered. The hot-tier shape:
82    /// a cap is a real bound because the durable tiers below still take the
83    /// write.
84    Capped(usize),
85    /// **O(nar).** The whole NAR is materialized. Legal only for in-memory test
86    /// doubles and small-value stores — never for a tier that serves real
87    /// builds.
88    WholeValue,
89}
90
91impl NarResidency {
92    /// Whether the peak is bounded independently of NAR size.
93    #[must_use]
94    pub const fn is_bounded(self) -> bool {
95        !matches!(self, NarResidency::WholeValue)
96    }
97
98    /// The **weaker** of two residencies — the honest answer for a composite
99    /// backend, whose real cost is its worst tier's.
100    ///
101    /// Order: `Streaming` (best) < `Capped` < `WholeValue` (worst); two
102    /// `Capped`s compose to the larger cap, because either one may be the one
103    /// that holds the bytes.
104    #[must_use]
105    pub fn weaker(self, other: Self) -> Self {
106        match (self, other) {
107            (NarResidency::WholeValue, _) | (_, NarResidency::WholeValue) => {
108                NarResidency::WholeValue
109            }
110            (NarResidency::Capped(a), NarResidency::Capped(b)) => NarResidency::Capped(a.max(b)),
111            (NarResidency::Capped(a), NarResidency::Streaming)
112            | (NarResidency::Streaming, NarResidency::Capped(a)) => NarResidency::Capped(a),
113            (NarResidency::Streaming, NarResidency::Streaming) => NarResidency::Streaming,
114        }
115    }
116}
117
118/// Abstraction over binary cache storage.
119///
120/// Narinfo files are keyed by the 32-character store path hash.
121/// NAR blobs are keyed by their relative URL path (e.g. `nar/<hash>.nar.xz`).
122///
123/// # NAR verbs come in two shapes; prefer the streaming pair
124///
125/// [`get_nar`](StorageBackend::get_nar) / [`put_nar`](StorageBackend::put_nar)
126/// hand whole `Vec<u8>` / `&[u8]` values across the boundary and are therefore
127/// **O(nar) resident by signature**. They remain for callers that genuinely have
128/// or want the whole thing (tests, small values, the GC).
129///
130/// [`get_nar_stream`](StorageBackend::get_nar_stream) /
131/// [`put_nar_stream`](StorageBackend::put_nar_stream) move the same content in
132/// [`NAR_CHUNK_BYTES`] chunks and are what the HTTP server and every tier-to-tier
133/// transfer use. `narinfo` is ~728 bytes and deliberately has no streaming pair.
134///
135/// # Record verbs vs. composed verbs
136///
137/// The `*_record` verbs ([`put_narinfo_record`](StorageBackend::put_narinfo_record),
138/// [`delete_narinfo_record`](StorageBackend::delete_narinfo_record),
139/// [`delete_nar_record`](StorageBackend::delete_nar_record)) each touch **exactly
140/// one key and maintain nothing**. They are what a backend implements.
141///
142/// [`put_narinfo`](StorageBackend::put_narinfo) and
143/// [`delete`](StorageBackend::delete) are *composed* on top: they keep the
144/// [`NarRefIndex`] in step and, in `delete`'s case, refuse to remove a NAR that
145/// another narinfo still advertises. They are provided, so a backend cannot
146/// forget to index — see [`nar_refs`] for what the two directions cost.
147#[async_trait]
148pub trait StorageBackend: Send + Sync {
149    /// Retrieve narinfo text by store path hash.
150    async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, StoreError>;
151
152    /// Store narinfo text keyed by store path hash — **the record verb**: one
153    /// key, no index maintenance.
154    ///
155    /// Callers want [`put_narinfo`](Self::put_narinfo), which also records the
156    /// reverse edge.
157    async fn put_narinfo_record(&self, hash: &str, content: &str) -> Result<(), StoreError>;
158
159    /// Remove a narinfo record by store path hash. Idempotent; removing an
160    /// absent narinfo is `Ok(())`.
161    ///
162    /// **The record verb**: it does not touch the NAR the narinfo advertises and
163    /// does not maintain the index. Callers want [`delete`](Self::delete).
164    async fn delete_narinfo_record(&self, hash: &str) -> Result<(), StoreError>;
165
166    /// Remove one NAR blob by its relative path. Idempotent.
167    ///
168    /// **The record verb, and the one with teeth**: removing a NAR that a live
169    /// narinfo still advertises is the stranding hazard this module exists to
170    /// prevent, and *nothing here checks*. Callers want [`delete`](Self::delete);
171    /// reach for this directly only after consulting
172    /// [`nar_ref_index`](Self::nar_ref_index).
173    async fn delete_nar_record(&self, nar_path: &str) -> Result<(), StoreError>;
174
175    /// This backend's **narhash → store-hash reverse index**.
176    ///
177    /// **Required — no default.** A default would be an empty index, and an
178    /// empty index does not read as "unknown", it reads as "nobody advertises
179    /// this NAR" — which is precisely the answer that authorizes deleting a NAR
180    /// out from under a live narinfo. Same mechanism, and the same reason, as
181    /// [`nar_residency`](Self::nar_residency): the decision cannot be omitted.
182    fn nar_ref_index(&self) -> &dyn NarRefIndex;
183
184    /// Retrieve a NAR blob by its relative path.
185    ///
186    /// **O(nar) resident.** Prefer [`get_nar_stream`](Self::get_nar_stream) on
187    /// any path that serves real build artifacts.
188    async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, StoreError>;
189
190    /// Store a NAR blob at the given relative path.
191    ///
192    /// **O(nar) resident.** Prefer [`put_nar_stream`](Self::put_nar_stream) on
193    /// any path that ingests real build artifacts.
194    async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), StoreError>;
195
196    /// Declare what this backend's NAR path costs in resident memory.
197    ///
198    /// **Required — no default.** See [`NarResidency`] for why.
199    fn nar_residency(&self) -> NarResidency;
200
201    /// Retrieve a NAR blob as a bounded-chunk stream.
202    ///
203    /// The default materializes via [`get_nar`](Self::get_nar) — correct, and
204    /// **O(nar) resident**. A backend that declares
205    /// [`NarResidency::Streaming`] must override this.
206    ///
207    /// # Errors
208    ///
209    /// Propagates the backend's read failure. `Ok(None)` is a clean miss.
210    async fn get_nar_stream(&self, path: &str) -> Result<Option<NarStream>, StoreError> {
211        Ok(self.get_nar(path).await?.map(nar_stream::whole_value_stream))
212    }
213
214    /// Store a NAR blob from a re-openable bounded-chunk source.
215    ///
216    /// The default drains the source into one buffer and calls
217    /// [`put_nar`](Self::put_nar) — correct, and **O(nar) resident**. A backend
218    /// that declares [`NarResidency::Streaming`] must override this.
219    ///
220    /// # Errors
221    ///
222    /// Propagates the source's read failure or the backend's write failure.
223    async fn put_nar_stream(&self, path: &str, src: &dyn NarSource) -> Result<(), StoreError> {
224        let data = nar_stream::collect_nar(src.open().await?, None).await?;
225        self.put_nar(path, &data).await
226    }
227
228    /// Store narinfo text **and record the reverse edge it creates**.
229    ///
230    /// # Ordering, and why it is this way round
231    ///
232    /// The edge is recorded **before** the narinfo record is written. A crash
233    /// between the two then leaves an edge with no narinfo — an over-report,
234    /// which costs a NAR that could have been reclaimed. The other order leaves
235    /// a narinfo with no edge, which is an under-report, and an under-report is
236    /// what lets a later `delete` take the NAR this narinfo advertises. Leak
237    /// over strand, every time.
238    ///
239    /// A narinfo whose `URL:` is not an addressable relative path is **refused**
240    /// (see [`is_addressable_nar_path`]): it arrives over the wire, it is used
241    /// as a key and joined onto a filesystem root, and there is no sanitizing it
242    /// safely at each of those uses. Text carrying no `URL:` at all is stored
243    /// as-is and indexes nothing — it advertises no NAR, so there is nothing to
244    /// strand.
245    ///
246    /// # Errors
247    ///
248    /// Propagates the index write or the record write, and returns
249    /// [`StoreError::NarInfo`] for an unaddressable `URL:`.
250    async fn put_narinfo(&self, hash: &str, content: &str) -> Result<(), StoreError> {
251        match nar_refs::advertised_url_line(content) {
252            Some(url) if nar_refs::is_addressable_nar_path(url) => {
253                self.nar_ref_index().record(url, hash).await?;
254            }
255            Some(url) => {
256                return Err(StoreError::NarInfo(format!(
257                    "narinfo {hash} advertises an unaddressable URL: {url:?}",
258                )));
259            }
260            None => {}
261        }
262        self.put_narinfo_record(hash, content).await
263    }
264
265    /// The NAR path this store path's narinfo advertises — **resolved, never
266    /// guessed**.
267    ///
268    /// `None` means the narinfo is absent, unparseable, or advertises a URL this
269    /// store will not address. All three mean the same thing to a caller: there
270    /// is no NAR here it is entitled to touch.
271    ///
272    /// # Errors
273    ///
274    /// Propagates the narinfo read failure. A read failure is **not** flattened
275    /// into `None`: "the tier is down" must never be mistaken for "this path has
276    /// no NAR" by something about to delete.
277    async fn advertised_nar(&self, hash: &str) -> Result<Option<String>, StoreError> {
278        Ok(self.get_narinfo(hash).await?.as_deref().and_then(nar_refs::advertised_nar_url))
279    }
280
281    /// Delete a store path's narinfo, and its NAR **only if nothing else
282    /// advertises that NAR**.
283    ///
284    /// # What changed, and why it is not cosmetic
285    ///
286    /// This used to guess: every backend best-effort-deleted
287    /// `nar/{store-hash}.{xz,zst,nar}`. The NAR is keyed by *narhash*, not by
288    /// store hash, so the guess normally deleted three keys that were never this
289    /// path's NAR and left the real one behind. It now **resolves** the key from
290    /// the narinfo's own `URL:`.
291    ///
292    /// Resolving alone would be a regression: two store paths with identical
293    /// contents share one narhash and therefore one `URL:`, so deleting either
294    /// would take the NAR the other still advertises — and a narinfo whose
295    /// advertised NAR 404s is a hard Nix failure, not a cache miss. So the NAR
296    /// goes only when [`nar_ref_index`](Self::nar_ref_index) reports no other
297    /// referrer.
298    ///
299    /// # Ordering
300    ///
301    /// The narinfo record is removed **first**, then its edge. A crash between
302    /// the two leaves a stale edge — an over-report that costs a retained NAR.
303    /// The other order would leave a live narinfo with no edge, and the next
304    /// `delete` of a co-referrer would strand it.
305    ///
306    /// # Errors
307    ///
308    /// Propagates the narinfo read, the record delete, or the index update.
309    async fn delete(&self, hash: &str) -> Result<(), StoreError> {
310        let advertised = self.advertised_nar(hash).await?;
311
312        self.delete_narinfo_record(hash).await?;
313
314        let Some(nar_path) = advertised else { return Ok(()) };
315        self.nar_ref_index().forget(&nar_path, hash).await?;
316
317        let others = self.nar_ref_index().referrers(&nar_path).await?;
318        if others.is_empty() {
319            self.delete_nar_record(&nar_path).await?;
320        } else {
321            tracing::debug!(
322                hash = %hash,
323                nar_path = %nar_path,
324                referrers = others.len(),
325                "delete: NAR retained — another narinfo still advertises it; removing it \
326                 would 404 an advertised URL, which Nix treats as a hard failure",
327            );
328        }
329        Ok(())
330    }
331
332    /// Rebuild every reverse edge from the narinfos this backend holds, and
333    /// return the number of edges recorded.
334    ///
335    /// The index is maintained forward from [`put_narinfo`](Self::put_narinfo),
336    /// so a store filled **before** the index existed has none — and an absent
337    /// edge reads as "nobody advertises this NAR". Running this once after an
338    /// upgrade closes that gap; it is idempotent, so running it again is free.
339    ///
340    /// O(narinfos), one narinfo read each: a maintenance verb, not something on
341    /// a request path.
342    ///
343    /// # Errors
344    ///
345    /// Propagates the listing, a narinfo read, or an index write.
346    async fn reindex_nar_refs(&self) -> Result<usize, StoreError> {
347        let mut recorded = 0usize;
348        for hash in self.list_narinfos().await? {
349            if let Some(nar_path) = self.advertised_nar(&hash).await? {
350                self.nar_ref_index().record(&nar_path, &hash).await?;
351                recorded += 1;
352            }
353        }
354        Ok(recorded)
355    }
356
357    /// List all stored narinfo hashes.
358    async fn list_narinfos(&self) -> Result<Vec<String>, StoreError>;
359
360    /// Clear EVERY narinfo and NAR blob from this backend. Returns the number
361    /// of narinfos removed.
362    ///
363    /// The default lists every narinfo and best-effort `delete`s it (narinfo-only
364    /// clear; NAR blobs keyed by *narhash* are not reached). Concrete durable
365    /// tiers override with a real truncation that reclaims NAR bytes.
366    async fn wipe_all(&self) -> Result<usize, StoreError> {
367        let hashes = self.list_narinfos().await?;
368        let n = hashes.len();
369        for hash in hashes {
370            self.delete(&hash).await?;
371        }
372        Ok(n)
373    }
374}
375
376/// Config-select factory: build the concrete [`StorageBackend`] a
377/// [`BackendConfig`] names.
378///
379/// This is **typed dispatch, not stringly** — a new backend kind is a
380/// non-exhaustive-`match` compile error, and the [`Tiered`](BackendConfig::Tiered)
381/// arm recurses, composing each sub-backend into a [`TieredBackend`]. The result
382/// is an `Arc<dyn StorageBackend>` ready for injection into any consumer.
383///
384/// The `Redis` and `Pg` arms require their production transports; without the
385/// corresponding Cargo feature (`redis-client` / `postgres`) they return a typed
386/// [`StoreError::NotImplemented`] rather than silently falling back to disk.
387///
388/// Returns a boxed future because the `Tiered` arm is recursive.
389///
390/// # Errors
391///
392/// Propagates any backend construction failure, or [`StoreError::NotImplemented`]
393/// when a config selects a backend whose feature is not compiled in.
394pub fn build_backend(
395    config: &BackendConfig,
396) -> BoxFuture<'_, Result<Arc<dyn StorageBackend>, StoreError>> {
397    Box::pin(async move {
398        match config {
399            BackendConfig::Local { path } => {
400                Ok(Arc::new(LocalStorage::new(path.clone())) as Arc<dyn StorageBackend>)
401            }
402            BackendConfig::S3 { bucket, region, endpoint } => {
403                let s3 = S3Storage::new(bucket.clone(), region.clone(), endpoint.clone())?;
404                Ok(Arc::new(s3) as Arc<dyn StorageBackend>)
405            }
406            BackendConfig::Redis { url, ttl_secs } => build_redis(url, *ttl_secs).await,
407            BackendConfig::Pg { url, max_conns } => build_pg(url, *max_conns).await,
408            BackendConfig::Tiered { l1, l2, l3, write_policy } => {
409                let l1 = build_backend(l1).await?;
410                let l2 = build_backend(l2).await?;
411                let l3 = build_backend(l3).await?;
412                Ok(Arc::new(TieredBackend::with_write_policy(l1, l2, l3, *write_policy))
413                    as Arc<dyn StorageBackend>)
414            }
415        }
416    })
417}
418
419#[cfg(feature = "redis-client")]
420async fn build_redis(
421    url: &str,
422    ttl_secs: Option<u64>,
423) -> Result<Arc<dyn StorageBackend>, StoreError> {
424    let backend = match ttl_secs {
425        Some(t) => RedisBackend::connect_with_ttl(url, t).await?,
426        None => RedisBackend::connect(url).await?,
427    };
428    Ok(Arc::new(backend) as Arc<dyn StorageBackend>)
429}
430
431#[cfg(not(feature = "redis-client"))]
432async fn build_redis(
433    _url: &str,
434    _ttl_secs: Option<u64>,
435) -> Result<Arc<dyn StorageBackend>, StoreError> {
436    Err(StoreError::NotImplemented(
437        "redis L1 backend requires building sui-castore with --features redis-client",
438    ))
439}
440
441#[cfg(feature = "postgres")]
442async fn build_pg(url: &str, max_conns: u32) -> Result<Arc<dyn StorageBackend>, StoreError> {
443    let backend = PgStorageBackend::connect(url, max_conns).await?;
444    Ok(Arc::new(backend) as Arc<dyn StorageBackend>)
445}
446
447#[cfg(not(feature = "postgres"))]
448async fn build_pg(_url: &str, _max_conns: u32) -> Result<Arc<dyn StorageBackend>, StoreError> {
449    Err(StoreError::NotImplemented(
450        "postgres L2 backend requires building sui-castore with --features postgres",
451    ))
452}
453
454#[cfg(test)]
455mod residency_gate {
456    use super::*;
457
458    /// **The gate.** Every backend a production [`BackendConfig`] can name must
459    /// bound its NAR path. A backend that regresses to
460    /// [`NarResidency::WholeValue`] fails here.
461    ///
462    /// The tripwire that keeps this honest as the fleet grows is
463    /// [`build_backend`]'s exhaustive `match`: a new [`BackendConfig`] arm is a
464    /// compile error there, and the author lands here next.
465    ///
466    /// Feature-gated arms (`Redis`, `Pg`) are exercised in their own modules
467    /// against their mock seams; this covers what a default build can construct.
468    #[tokio::test]
469    async fn every_production_backend_bounds_its_nar_path() {
470        let dir = tempfile::tempdir().unwrap();
471
472        let local = build_backend(&BackendConfig::Local { path: dir.path().to_path_buf() })
473            .await
474            .unwrap();
475        assert_eq!(
476            local.nar_residency(),
477            NarResidency::Streaming,
478            "the local/L3 tier must stream — it is the durable object tier",
479        );
480
481        let s3 = build_backend(&BackendConfig::S3 {
482            bucket: "b".to_string(),
483            region: "us-east-1".to_string(),
484            endpoint: Some("http://127.0.0.1:9".to_string()),
485        })
486        .await
487        .unwrap();
488        assert_eq!(s3.nar_residency(), NarResidency::Streaming, "S3 must multipart-stream");
489
490        // The composite: three streaming tiers compose to streaming.
491        let tiered = build_backend(&BackendConfig::Tiered {
492            l1: Box::new(BackendConfig::Local { path: dir.path().join("l1") }),
493            l2: Box::new(BackendConfig::Local { path: dir.path().join("l2") }),
494            l3: Box::new(BackendConfig::Local { path: dir.path().join("l3") }),
495            write_policy: WritePolicy::WriteThrough,
496        })
497        .await
498        .unwrap();
499        assert_eq!(tiered.nar_residency(), NarResidency::Streaming);
500        assert!(tiered.nar_residency().is_bounded());
501    }
502
503    #[test]
504    fn residency_composes_to_the_weaker_side() {
505        use NarResidency::{Capped, Streaming, WholeValue};
506        assert_eq!(Streaming.weaker(Streaming), Streaming);
507        assert_eq!(Streaming.weaker(Capped(8)), Capped(8));
508        assert_eq!(Capped(8).weaker(Capped(64)), Capped(64), "the larger cap governs");
509        assert_eq!(Capped(8).weaker(WholeValue), WholeValue);
510        assert_eq!(WholeValue.weaker(Streaming), WholeValue);
511    }
512
513    #[test]
514    fn only_whole_value_is_unbounded() {
515        assert!(NarResidency::Streaming.is_bounded());
516        assert!(NarResidency::Capped(1).is_bounded());
517        assert!(!NarResidency::WholeValue.is_bounded());
518    }
519}
520
521/// **The stranding gate.** Every backend a production [`BackendConfig`] can name
522/// must never leave a narinfo advertising a NAR it has deleted.
523///
524/// A narinfo is served 200 OK with `URL: nar/…`; a client then fetches that NAR.
525/// Nix treats a **missing advertised NAR** as a hard failure, not a cache miss —
526/// the same outage class as 2026-07-26, where 500s from a substituter failed
527/// every build on the cluster. So the property is not "delete frees bytes", it
528/// is "**every narinfo that survives a delete is still servable end to end**",
529/// and that is what these assert.
530///
531/// The per-backend equivalents for the feature-gated tiers live beside their
532/// mock seams (`pg::tests`, `redis::tests`); `s3::tests` runs the same scenario
533/// against an in-process object store. This module covers what a default build
534/// can construct through [`build_backend`], which is the factory a deployment
535/// actually goes through.
536#[cfg(test)]
537mod nar_ref_gate {
538    use super::*;
539
540    /// Two narinfos advertising ONE NAR — the case that makes the index
541    /// necessary. A NAR serializes a store path's *contents*, not its name, so
542    /// two paths with identical contents produce one narhash and one `URL:`.
543    const SHARED_NAR: &str = "nar/sharednarhash.nar.xz";
544
545    fn narinfo_for(url: &str) -> String {
546        format!(
547            "StorePath: /nix/store/pkg\nURL: {url}\nCompression: xz\nFileHash: sha256:aaa\n\
548             FileSize: 100\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n"
549        )
550    }
551
552    /// Run the whole scenario against one backend, naming it in every failure.
553    async fn assert_never_strands(name: &str, backend: &dyn StorageBackend) {
554        backend.put_narinfo("pathA", &narinfo_for(SHARED_NAR)).await.unwrap();
555        backend.put_narinfo("pathB", &narinfo_for(SHARED_NAR)).await.unwrap();
556        backend.put_nar(SHARED_NAR, b"shared contents").await.unwrap();
557        // A decoy shaped like the old extension guess, which the store hash
558        // would have produced. Nothing advertises it.
559        backend.put_nar("nar/pathA.nar.zst", b"unrelated").await.unwrap();
560
561        backend.delete("pathA").await.unwrap();
562
563        // The surviving narinfo is still servable END TO END: the narinfo is
564        // there AND the NAR it names is there. Checking only one of the two is
565        // how a strand hides.
566        let surviving = backend
567            .get_narinfo("pathB")
568            .await
569            .unwrap()
570            .unwrap_or_else(|| panic!("{name}: pathB's narinfo vanished"));
571        let advertised = nar_refs::advertised_nar_url(&surviving)
572            .unwrap_or_else(|| panic!("{name}: pathB advertises nothing"));
573        assert!(
574            backend.get_nar(&advertised).await.unwrap().is_some(),
575            "{name}: STRANDED — pathB's narinfo advertises {advertised}, which is gone. \
576             A client would get 200 on the narinfo and 404 on the NAR, which nix treats \
577             as a hard build failure.",
578        );
579        assert_eq!(
580            backend.nar_ref_index().referrers(SHARED_NAR).await.unwrap(),
581            vec!["pathB".to_string()],
582            "{name}: the index must have dropped exactly pathA's edge",
583        );
584        let decoy = backend.get_nar("nar/pathA.nar.zst").await.unwrap().unwrap_or_else(|| {
585            panic!(
586                "{name}: GUESSED — delete removed nar/pathA.nar.zst, a key built from the \
587                 STORE hash that no narinfo ever advertised. A NAR is keyed by narhash; \
588                 delete must resolve the advertised URL, never guess an extension.",
589            )
590        });
591        assert_eq!(decoy, b"unrelated", "{name}: the decoy's bytes were altered");
592
593        // With the last referrer gone the NAR is reclaimable — otherwise the
594        // gate above would pass trivially by never deleting anything.
595        backend.delete("pathB").await.unwrap();
596        assert!(
597            backend.get_nar(SHARED_NAR).await.unwrap().is_none(),
598            "{name}: nothing advertises the NAR any more; it must be reclaimed",
599        );
600    }
601
602    #[tokio::test]
603    async fn every_production_backend_pairs_its_nar_with_its_narinfo() {
604        let dir = tempfile::tempdir().unwrap();
605
606        let local = build_backend(&BackendConfig::Local { path: dir.path().join("solo") })
607            .await
608            .unwrap();
609        assert_never_strands("LocalStorage", local.as_ref()).await;
610
611        let tiered = build_backend(&BackendConfig::Tiered {
612            l1: Box::new(BackendConfig::Local { path: dir.path().join("l1") }),
613            l2: Box::new(BackendConfig::Local { path: dir.path().join("l2") }),
614            l3: Box::new(BackendConfig::Local { path: dir.path().join("l3") }),
615            write_policy: WritePolicy::WriteThrough,
616        })
617        .await
618        .unwrap();
619        assert_never_strands("TieredBackend", tiered.as_ref()).await;
620    }
621
622    /// The **migration gap**, stated as a test rather than a doc line.
623    ///
624    /// A store written by a pre-index binary has narinfos and no edges, and an
625    /// absent edge reads as "nobody advertises this NAR". Deleting one of two
626    /// co-referring paths therefore strands the other — until
627    /// [`reindex_nar_refs`](StorageBackend::reindex_nar_refs) has run once. Both
628    /// halves are asserted, so the gap cannot be quietly forgotten *or* quietly
629    /// claimed to be closed.
630    #[tokio::test]
631    async fn an_unindexed_store_can_strand_until_reindexed() {
632        let dir = tempfile::tempdir().unwrap();
633
634        // The gap: narinfos written the pre-index way, via the record verb.
635        let stale = LocalStorage::new(dir.path().join("stale"));
636        stale.put_narinfo_record("pathA", &narinfo_for(SHARED_NAR)).await.unwrap();
637        stale.put_narinfo_record("pathB", &narinfo_for(SHARED_NAR)).await.unwrap();
638        stale.put_nar(SHARED_NAR, b"shared").await.unwrap();
639        stale.delete("pathA").await.unwrap();
640        assert!(
641            stale.get_narinfo("pathB").await.unwrap().is_some(),
642            "pathB's narinfo is still there…",
643        );
644        assert!(
645            stale.get_nar(SHARED_NAR).await.unwrap().is_none(),
646            "…and its NAR is gone: this IS the strand, and it is what an un-reindexed \
647             upgrade looks like",
648        );
649
650        // The close: same fixture, reindexed before the delete.
651        let healed = LocalStorage::new(dir.path().join("healed"));
652        healed.put_narinfo_record("pathA", &narinfo_for(SHARED_NAR)).await.unwrap();
653        healed.put_narinfo_record("pathB", &narinfo_for(SHARED_NAR)).await.unwrap();
654        healed.put_nar(SHARED_NAR, b"shared").await.unwrap();
655        assert_eq!(healed.reindex_nar_refs().await.unwrap(), 2);
656        healed.delete("pathA").await.unwrap();
657        assert!(
658            healed.get_nar(SHARED_NAR).await.unwrap().is_some(),
659            "after a reindex the co-referrer is visible and the NAR is retained",
660        );
661    }
662}