Skip to main content

sui_castore/storage/
nar_refs.rs

1//! The **narhash → store-hash reverse index**: which narinfos advertise a NAR.
2//!
3//! # Why this exists
4//!
5//! The two halves of a binary cache are keyed differently and always have been:
6//!
7//! | Artifact | Key |
8//! |---|---|
9//! | narinfo | the 32-char **store-path** hash |
10//! | NAR blob | the **narhash** — `nar/<filehash>.nar.xz`, taken from the narinfo's `URL:` |
11//!
12//! Going *forward* is easy: read the narinfo, take `URL:`. Going *backward* —
13//! from a NAR to the narinfo(s) that advertise it — was not expressible at all,
14//! and three backends carried a comment saying exactly that while
15//! [`delete`](super::StorageBackend::delete) worked around the gap by
16//! best-effort-guessing `nar/{store-hash}.{xz,zst,nar}`. That guess is wrong on
17//! both sides: it deletes keys that were never this path's NAR, and it leaves
18//! the real NAR behind.
19//!
20//! # What the missing direction costs
21//!
22//! **A narinfo whose advertised NAR is gone is worse than a miss.** A client
23//! fetches `<hash>.narinfo` (200 OK, `URL: nar/…`), fetches that NAR, and gets
24//! 404. Nix treats a missing *advertised* NAR as a hard failure, not a cache
25//! miss — the same outage class as 2026-07-26, where 500s from a substituter
26//! failed every build on the cluster. So nothing may remove a NAR without first
27//! answering "who still advertises it?", and that question needs this index.
28//!
29//! Two narinfos genuinely can advertise one NAR: a NAR serializes a store path's
30//! *contents*, not its name, so two store paths with byte-identical contents
31//! produce one narhash and one `URL:`. Removing either path must not take the
32//! NAR the other one still points at. Hence a **set** of referrers, not one.
33//!
34//! # The shape
35//!
36//! One edge per `(nar_path, store_hash)` pair — never a set-valued record.
37//! Recording is then a blind write of a key that names its own content, so two
38//! concurrent writers cannot lose each other's edge the way a
39//! read-modify-write of a shared set would. Backends that key by string
40//! ([`RedisBackend`](super::RedisBackend), [`S3Storage`](super::S3Storage),
41//! [`PgStorageBackend`](super::PgStorageBackend)) store the edge under
42//! [`NarRefKey`]; [`LocalStorage`](super::LocalStorage) mirrors the same shape
43//! as an empty file at `<root>/nar-refs/<nar_path>/<store_hash>`.
44//!
45//! # Tier honesty
46//!
47//! - The **decision** to have an index is *parse-time-rejected*:
48//!   [`StorageBackend::nar_ref_index`](super::StorageBackend::nar_ref_index) is
49//!   required and has no default, so a new backend cannot inherit a silently
50//!   empty one — the same mechanism, and for the same reason, as
51//!   [`nar_residency`](super::StorageBackend::nar_residency).
52//! - The **maintenance** is *structural but overridable*: `put_narinfo` and
53//!   `delete` are provided methods that record and forget the edge, so a backend
54//!   implements only the raw record verbs and cannot forget to index. A backend
55//!   that overrides them can still get it wrong; that is caught by
56//!   `every_production_backend_pairs_its_nar_with_its_narinfo` in CI, not by the
57//!   type system. **Only-mitigated, not unrepresentable.**
58//! - A store written **before** the index existed has no edges. `delete` on such
59//!   a store behaves as it was always intended to (it removes its own NAR) but
60//!   cannot see a co-referrer, so the strand is possible until
61//!   [`reindex_nar_refs`](super::StorageBackend::reindex_nar_refs) has run once.
62//!   That is a migration gap, stated, not a property of the index.
63
64use std::collections::{BTreeMap, BTreeSet};
65use std::fmt;
66use std::sync::Mutex;
67
68use async_trait::async_trait;
69
70use crate::StoreError;
71
72/// Key-space prefix owning every reverse edge.
73///
74/// Deliberately a sibling of `nar/` rather than a child: a Nix client only ever
75/// fetches `nix-cache-info`, `<hash>.narinfo` and the exact `URL:` a narinfo
76/// advertises, so an extra top-level directory is invisible to it, while a child
77/// of `nar/` would sit in the namespace a NAR key is drawn from.
78pub const NAR_REF_PREFIX: &str = "nar-refs/";
79
80/// The typed key of one reverse edge: "`hash`'s narinfo advertises `nar_path`".
81///
82/// A `Display` surface rather than an ad-hoc `format!` at each backend, so the
83/// four key-value tiers cannot drift into four encodings of the same edge
84/// (★★ TYPED EMISSION).
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct NarRefKey<'a> {
87    /// The advertised NAR path, e.g. `nar/<filehash>.nar.xz`.
88    pub nar_path: &'a str,
89    /// The 32-char store-path hash of the narinfo advertising it.
90    pub hash: &'a str,
91}
92
93impl fmt::Display for NarRefKey<'_> {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        write!(f, "{NAR_REF_PREFIX}{}/{}", self.nar_path, self.hash)
96    }
97}
98
99/// The typed key **prefix** enumerating every edge into one NAR.
100///
101/// The trailing `/` is load-bearing: without it `nar/ab.nar` would also scan
102/// `nar/ab.nar.xz`'s edges.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct NarRefScan<'a> {
105    /// The advertised NAR path whose referrers are wanted.
106    pub nar_path: &'a str,
107}
108
109impl fmt::Display for NarRefScan<'_> {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        write!(f, "{NAR_REF_PREFIX}{}/", self.nar_path)
112    }
113}
114
115/// Recover the referring store-path hash from a scanned edge key.
116///
117/// Returns `None` for a key that is not under `scan` — a listing that returns a
118/// neighbouring key must not be silently read as a referrer.
119#[must_use]
120pub fn referrer_of<'k>(scan: &NarRefScan<'_>, key: &'k str) -> Option<&'k str> {
121    let prefix = scan.to_string();
122    let rest = key.strip_prefix(&prefix)?;
123    if rest.is_empty() || rest.contains('/') {
124        return None;
125    }
126    Some(rest)
127}
128
129/// Whether a narinfo's `URL:` is a NAR path this store can safely address.
130///
131/// A narinfo is *input* — it arrives over `PUT /<hash>.narinfo` — and its `URL:`
132/// is used both as a key and, on [`LocalStorage`](super::LocalStorage), as a
133/// path joined onto the cache root. `URL: ../../etc/passwd` therefore has to be
134/// refused at the boundary rather than sanitized at each use; this predicate is
135/// that boundary.
136///
137/// Accepts a non-empty relative path of non-empty segments, none of which is
138/// `.` or `..`, with no control characters and no backslash.
139#[must_use]
140pub fn is_addressable_nar_path(url: &str) -> bool {
141    !url.is_empty()
142        && !url.starts_with('/')
143        && !url.contains('\\')
144        && !url.chars().any(char::is_control)
145        && url.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..")
146}
147
148/// The NAR path a stored narinfo advertises, if it advertises an addressable
149/// one.
150///
151/// `None` covers both "no `URL:` line here" and "its `URL:` is not
152/// addressable". Both mean the same thing to a caller: there is no NAR here we
153/// are entitled to key, index, or delete.
154///
155/// # Why this reads the one field instead of calling `NarInfo::parse`
156///
157/// [`NarInfo::parse`](sui_compat::narinfo::NarInfo::parse) requires `FileHash`
158/// and `FileSize`, and rejects the whole document when either is absent. A
159/// narinfo missing them still **advertises a NAR** — a client will still fetch
160/// that `URL:` and still hard-fail on a 404 — so indexing through the full
161/// parse would leave exactly those narinfos out of the index. That is not a
162/// harmless gap: an unindexed narinfo sharing a narhash with an indexed one gets
163/// stranded the moment the indexed one is deleted. The reverse index must see
164/// every narinfo that names a NAR, whatever else is wrong with it, so it reads
165/// the field that matters and judges nothing else.
166#[must_use]
167pub fn advertised_nar_url(narinfo: &str) -> Option<String> {
168    let url = advertised_url_line(narinfo)?;
169    is_addressable_nar_path(url).then(|| url.to_string())
170}
171
172/// The raw `URL:` field of a narinfo, **unjudged**.
173///
174/// Separate from [`advertised_nar_url`] so the write boundary can tell "there is
175/// no URL here" (index nothing, store it) apart from "there is a URL and it is
176/// not one we will address" (refuse the write). Collapsing the two would let a
177/// traversal URL through as a silently-unindexed narinfo.
178#[must_use]
179pub fn advertised_url_line(narinfo: &str) -> Option<&str> {
180    narinfo.lines().find_map(|line| {
181        let (key, value) = line.split_once(':')?;
182        (key.trim() == "URL").then(|| value.trim())
183    })
184}
185
186/// Is this narinfo text usable by a Nix client at all?
187///
188/// `StorePath:` is what makes a narinfo a narinfo — nix's own reader rejects
189/// text without one as `corrupt: StorePath missing` — so this is the minimum
190/// bar for both accepting an upload and serving a stored entry.
191///
192/// ── WHY THIS IS A SHARED PREDICATE, not an `is_empty()` at one call site ────
193/// Measured on camelot-eks 2026-08-05: two rows in the durable tier held a
194/// ZERO-LENGTH value, and the read path served them as `200` with an empty
195/// body. Nix aborted the whole operation on the first one it met while asking
196/// the destination which paths it already had, so two poisoned rows out of 6898
197/// broke EVERY `nix copy --to` against the cache.
198///
199/// An unusable hit is worse than a miss: a miss makes the client build, a
200/// malformed hit makes it fail — and the error names nix, not the cache. Both
201/// boundaries therefore ask the same question, because fixing only the write
202/// leaves existing poison fatal, and fixing only the read leaves the tier
203/// accumulating garbage.
204#[must_use]
205pub fn is_servable_narinfo(narinfo: &str) -> bool {
206    narinfo
207        .lines()
208        .any(|line| line.split_once(':').is_some_and(|(k, v)| k.trim() == "StorePath" && !v.trim().is_empty()))
209}
210
211/// The reverse index of a single [`StorageBackend`](super::StorageBackend).
212///
213/// Three verbs, all idempotent. Every implementation persists edges in the
214/// backend's own store, so the index survives exactly as long as the data it
215/// describes.
216///
217/// # Which way to be wrong
218///
219/// Over-reporting a referrer keeps a NAR that could have been reclaimed — a
220/// leak. Under-reporting deletes a NAR another narinfo still advertises — an
221/// outage. **Every implementation rounds toward over-reporting**, and any place
222/// that cannot (a hot tier's key expiring, a fan-out where one tier is down)
223/// says so at that site.
224#[async_trait]
225pub trait NarRefIndex: Send + Sync {
226    /// Record "`hash`'s narinfo advertises `nar_path`". Idempotent.
227    ///
228    /// # Errors
229    ///
230    /// Propagates the backend's write failure.
231    async fn record(&self, nar_path: &str, hash: &str) -> Result<(), StoreError>;
232
233    /// Forget that edge. Idempotent — forgetting an absent edge is `Ok(())`.
234    ///
235    /// # Errors
236    ///
237    /// Propagates the backend's delete failure.
238    async fn forget(&self, nar_path: &str, hash: &str) -> Result<(), StoreError>;
239
240    /// Every store-path hash whose narinfo advertises `nar_path`, sorted and
241    /// deduplicated.
242    ///
243    /// # Errors
244    ///
245    /// Propagates the backend's read failure. An empty vector is a real answer
246    /// ("nothing advertises this NAR"), never a stand-in for a failed lookup —
247    /// which is why this returns `Result<Vec<_>>` and not `Vec<_>`.
248    async fn referrers(&self, nar_path: &str) -> Result<Vec<String>, StoreError>;
249}
250
251/// In-memory [`NarRefIndex`] — the reference semantics, and what every test
252/// double uses.
253///
254/// Exported rather than test-only on purpose: a `StorageBackend` double that has
255/// to hand-roll an index will hand-roll it differently, and a double whose
256/// reverse index disagrees with production's is a gate that proves nothing.
257#[derive(Debug, Default)]
258pub struct MemNarRefIndex {
259    edges: Mutex<BTreeMap<String, BTreeSet<String>>>,
260}
261
262impl MemNarRefIndex {
263    /// An empty index.
264    #[must_use]
265    pub fn new() -> Self {
266        Self::default()
267    }
268
269    /// Total edge count, across every NAR. Diagnostics and gates.
270    #[must_use]
271    pub fn len(&self) -> usize {
272        self.edges.lock().unwrap_or_else(std::sync::PoisonError::into_inner).values().map(BTreeSet::len).sum()
273    }
274
275    /// Whether the index holds no edges at all.
276    #[must_use]
277    pub fn is_empty(&self) -> bool {
278        self.len() == 0
279    }
280}
281
282#[async_trait]
283impl NarRefIndex for MemNarRefIndex {
284    async fn record(&self, nar_path: &str, hash: &str) -> Result<(), StoreError> {
285        self.edges
286            .lock()
287            .unwrap_or_else(std::sync::PoisonError::into_inner)
288            .entry(nar_path.to_string())
289            .or_default()
290            .insert(hash.to_string());
291        Ok(())
292    }
293
294    async fn forget(&self, nar_path: &str, hash: &str) -> Result<(), StoreError> {
295        let mut edges = self.edges.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
296        if let Some(set) = edges.get_mut(nar_path) {
297            set.remove(hash);
298            if set.is_empty() {
299                edges.remove(nar_path);
300            }
301        }
302        Ok(())
303    }
304
305    async fn referrers(&self, nar_path: &str) -> Result<Vec<String>, StoreError> {
306        Ok(self
307            .edges
308            .lock()
309            .unwrap_or_else(std::sync::PoisonError::into_inner)
310            .get(nar_path)
311            .map(|s| s.iter().cloned().collect())
312            .unwrap_or_default())
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn an_edge_key_is_scanned_by_its_own_prefix() {
322        let key = NarRefKey { nar_path: "nar/abc.nar.xz", hash: "sss" }.to_string();
323        assert_eq!(key, "nar-refs/nar/abc.nar.xz/sss");
324        let scan = NarRefScan { nar_path: "nar/abc.nar.xz" };
325        assert!(key.starts_with(&scan.to_string()));
326        assert_eq!(referrer_of(&scan, &key), Some("sss"));
327    }
328
329    #[test]
330    fn a_scan_prefix_does_not_reach_a_longer_neighbour() {
331        // Without the trailing `/`, `nar/ab.nar` would also match
332        // `nar/ab.nar.xz`'s edges and over-report a referrer onto the wrong NAR.
333        let neighbour = NarRefKey { nar_path: "nar/ab.nar.xz", hash: "sss" }.to_string();
334        let scan = NarRefScan { nar_path: "nar/ab.nar" };
335        assert!(!neighbour.starts_with(&scan.to_string()));
336        assert_eq!(referrer_of(&scan, &neighbour), None);
337    }
338
339    #[test]
340    fn referrer_of_rejects_a_key_from_another_nar() {
341        let scan = NarRefScan { nar_path: "nar/a.nar" };
342        assert_eq!(referrer_of(&scan, "nar-refs/nar/b.nar/sss"), None);
343        assert_eq!(referrer_of(&scan, "nar-refs/nar/a.nar/"), None);
344        assert_eq!(referrer_of(&scan, "nar-refs/nar/a.nar/deep/sss"), None);
345    }
346
347    #[test]
348    fn traversal_and_absolute_urls_are_not_addressable() {
349        assert!(is_addressable_nar_path("nar/abc.nar.xz"));
350        assert!(is_addressable_nar_path("nar/deep/abc.nar"));
351        assert!(!is_addressable_nar_path(""));
352        assert!(!is_addressable_nar_path("/etc/passwd"));
353        assert!(!is_addressable_nar_path("../../etc/passwd"));
354        assert!(!is_addressable_nar_path("nar/../../etc/passwd"));
355        assert!(!is_addressable_nar_path("nar/./abc.nar"));
356        assert!(!is_addressable_nar_path("nar//abc.nar"));
357        assert!(!is_addressable_nar_path("nar\\abc.nar"));
358        assert!(!is_addressable_nar_path("nar/abc\n.nar"));
359    }
360
361    #[test]
362    fn an_unaddressable_url_advertises_nothing() {
363        let good = "StorePath: /nix/store/abc-hello\nURL: nar/abc.nar.xz\nCompression: xz\n\
364                    FileHash: sha256:aaa\nFileSize: 100\nNarHash: sha256:bbb\nNarSize: 200\n\
365                    References: \n";
366        assert_eq!(advertised_nar_url(good).as_deref(), Some("nar/abc.nar.xz"));
367
368        let traversal = "StorePath: /nix/store/abc-hello\nURL: ../../etc/passwd\n\
369                         Compression: xz\nFileHash: sha256:aaa\nFileSize: 100\n\
370                         NarHash: sha256:bbb\nNarSize: 200\nReferences: \n";
371        assert_eq!(advertised_nar_url(traversal), None);
372
373        assert_eq!(advertised_nar_url("not a narinfo at all"), None);
374    }
375
376    /// A narinfo that [`NarInfo::parse`](sui_compat::narinfo::NarInfo::parse)
377    /// **rejects** still advertises a NAR, and must still be indexed.
378    ///
379    /// This one has no `FileHash`/`FileSize`, so the full parser returns
380    /// `MissingField` — yet a client fetching it will still request
381    /// `nar/abc.nar.xz` and still hard-fail if that 404s. Indexing through the
382    /// full parse would leave it out of the index, and an unindexed narinfo
383    /// sharing a narhash with an indexed one is the strand this whole module
384    /// exists to prevent.
385    #[test]
386    fn a_narinfo_the_strict_parser_rejects_still_advertises_its_nar() {
387        let partial = "StorePath: /nix/store/abc-hello\nURL: nar/abc.nar.xz\n\
388                       Compression: xz\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n";
389        assert!(
390            sui_compat::narinfo::NarInfo::parse(partial).is_err(),
391            "fixture must actually be one the strict parser rejects",
392        );
393        assert_eq!(advertised_nar_url(partial).as_deref(), Some("nar/abc.nar.xz"));
394    }
395
396    #[tokio::test]
397    async fn the_in_memory_index_is_a_set_per_nar() {
398        let ix = MemNarRefIndex::new();
399        assert!(ix.is_empty());
400
401        ix.record("nar/x.nar", "aaa").await.unwrap();
402        ix.record("nar/x.nar", "bbb").await.unwrap();
403        // Idempotent: the same edge twice is still one edge.
404        ix.record("nar/x.nar", "aaa").await.unwrap();
405        assert_eq!(ix.referrers("nar/x.nar").await.unwrap(), vec!["aaa", "bbb"]);
406        assert_eq!(ix.len(), 2);
407
408        ix.forget("nar/x.nar", "aaa").await.unwrap();
409        assert_eq!(ix.referrers("nar/x.nar").await.unwrap(), vec!["bbb"]);
410
411        // Forgetting an absent edge is not an error.
412        ix.forget("nar/x.nar", "aaa").await.unwrap();
413        ix.forget("nar/absent.nar", "zzz").await.unwrap();
414
415        ix.forget("nar/x.nar", "bbb").await.unwrap();
416        assert!(ix.referrers("nar/x.nar").await.unwrap().is_empty());
417        assert!(ix.is_empty());
418    }
419}