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/// The reverse index of a single [`StorageBackend`](super::StorageBackend).
187///
188/// Three verbs, all idempotent. Every implementation persists edges in the
189/// backend's own store, so the index survives exactly as long as the data it
190/// describes.
191///
192/// # Which way to be wrong
193///
194/// Over-reporting a referrer keeps a NAR that could have been reclaimed — a
195/// leak. Under-reporting deletes a NAR another narinfo still advertises — an
196/// outage. **Every implementation rounds toward over-reporting**, and any place
197/// that cannot (a hot tier's key expiring, a fan-out where one tier is down)
198/// says so at that site.
199#[async_trait]
200pub trait NarRefIndex: Send + Sync {
201 /// Record "`hash`'s narinfo advertises `nar_path`". Idempotent.
202 ///
203 /// # Errors
204 ///
205 /// Propagates the backend's write failure.
206 async fn record(&self, nar_path: &str, hash: &str) -> Result<(), StoreError>;
207
208 /// Forget that edge. Idempotent — forgetting an absent edge is `Ok(())`.
209 ///
210 /// # Errors
211 ///
212 /// Propagates the backend's delete failure.
213 async fn forget(&self, nar_path: &str, hash: &str) -> Result<(), StoreError>;
214
215 /// Every store-path hash whose narinfo advertises `nar_path`, sorted and
216 /// deduplicated.
217 ///
218 /// # Errors
219 ///
220 /// Propagates the backend's read failure. An empty vector is a real answer
221 /// ("nothing advertises this NAR"), never a stand-in for a failed lookup —
222 /// which is why this returns `Result<Vec<_>>` and not `Vec<_>`.
223 async fn referrers(&self, nar_path: &str) -> Result<Vec<String>, StoreError>;
224}
225
226/// In-memory [`NarRefIndex`] — the reference semantics, and what every test
227/// double uses.
228///
229/// Exported rather than test-only on purpose: a `StorageBackend` double that has
230/// to hand-roll an index will hand-roll it differently, and a double whose
231/// reverse index disagrees with production's is a gate that proves nothing.
232#[derive(Debug, Default)]
233pub struct MemNarRefIndex {
234 edges: Mutex<BTreeMap<String, BTreeSet<String>>>,
235}
236
237impl MemNarRefIndex {
238 /// An empty index.
239 #[must_use]
240 pub fn new() -> Self {
241 Self::default()
242 }
243
244 /// Total edge count, across every NAR. Diagnostics and gates.
245 #[must_use]
246 pub fn len(&self) -> usize {
247 self.edges.lock().unwrap_or_else(std::sync::PoisonError::into_inner).values().map(BTreeSet::len).sum()
248 }
249
250 /// Whether the index holds no edges at all.
251 #[must_use]
252 pub fn is_empty(&self) -> bool {
253 self.len() == 0
254 }
255}
256
257#[async_trait]
258impl NarRefIndex for MemNarRefIndex {
259 async fn record(&self, nar_path: &str, hash: &str) -> Result<(), StoreError> {
260 self.edges
261 .lock()
262 .unwrap_or_else(std::sync::PoisonError::into_inner)
263 .entry(nar_path.to_string())
264 .or_default()
265 .insert(hash.to_string());
266 Ok(())
267 }
268
269 async fn forget(&self, nar_path: &str, hash: &str) -> Result<(), StoreError> {
270 let mut edges = self.edges.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
271 if let Some(set) = edges.get_mut(nar_path) {
272 set.remove(hash);
273 if set.is_empty() {
274 edges.remove(nar_path);
275 }
276 }
277 Ok(())
278 }
279
280 async fn referrers(&self, nar_path: &str) -> Result<Vec<String>, StoreError> {
281 Ok(self
282 .edges
283 .lock()
284 .unwrap_or_else(std::sync::PoisonError::into_inner)
285 .get(nar_path)
286 .map(|s| s.iter().cloned().collect())
287 .unwrap_or_default())
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn an_edge_key_is_scanned_by_its_own_prefix() {
297 let key = NarRefKey { nar_path: "nar/abc.nar.xz", hash: "sss" }.to_string();
298 assert_eq!(key, "nar-refs/nar/abc.nar.xz/sss");
299 let scan = NarRefScan { nar_path: "nar/abc.nar.xz" };
300 assert!(key.starts_with(&scan.to_string()));
301 assert_eq!(referrer_of(&scan, &key), Some("sss"));
302 }
303
304 #[test]
305 fn a_scan_prefix_does_not_reach_a_longer_neighbour() {
306 // Without the trailing `/`, `nar/ab.nar` would also match
307 // `nar/ab.nar.xz`'s edges and over-report a referrer onto the wrong NAR.
308 let neighbour = NarRefKey { nar_path: "nar/ab.nar.xz", hash: "sss" }.to_string();
309 let scan = NarRefScan { nar_path: "nar/ab.nar" };
310 assert!(!neighbour.starts_with(&scan.to_string()));
311 assert_eq!(referrer_of(&scan, &neighbour), None);
312 }
313
314 #[test]
315 fn referrer_of_rejects_a_key_from_another_nar() {
316 let scan = NarRefScan { nar_path: "nar/a.nar" };
317 assert_eq!(referrer_of(&scan, "nar-refs/nar/b.nar/sss"), None);
318 assert_eq!(referrer_of(&scan, "nar-refs/nar/a.nar/"), None);
319 assert_eq!(referrer_of(&scan, "nar-refs/nar/a.nar/deep/sss"), None);
320 }
321
322 #[test]
323 fn traversal_and_absolute_urls_are_not_addressable() {
324 assert!(is_addressable_nar_path("nar/abc.nar.xz"));
325 assert!(is_addressable_nar_path("nar/deep/abc.nar"));
326 assert!(!is_addressable_nar_path(""));
327 assert!(!is_addressable_nar_path("/etc/passwd"));
328 assert!(!is_addressable_nar_path("../../etc/passwd"));
329 assert!(!is_addressable_nar_path("nar/../../etc/passwd"));
330 assert!(!is_addressable_nar_path("nar/./abc.nar"));
331 assert!(!is_addressable_nar_path("nar//abc.nar"));
332 assert!(!is_addressable_nar_path("nar\\abc.nar"));
333 assert!(!is_addressable_nar_path("nar/abc\n.nar"));
334 }
335
336 #[test]
337 fn an_unaddressable_url_advertises_nothing() {
338 let good = "StorePath: /nix/store/abc-hello\nURL: nar/abc.nar.xz\nCompression: xz\n\
339 FileHash: sha256:aaa\nFileSize: 100\nNarHash: sha256:bbb\nNarSize: 200\n\
340 References: \n";
341 assert_eq!(advertised_nar_url(good).as_deref(), Some("nar/abc.nar.xz"));
342
343 let traversal = "StorePath: /nix/store/abc-hello\nURL: ../../etc/passwd\n\
344 Compression: xz\nFileHash: sha256:aaa\nFileSize: 100\n\
345 NarHash: sha256:bbb\nNarSize: 200\nReferences: \n";
346 assert_eq!(advertised_nar_url(traversal), None);
347
348 assert_eq!(advertised_nar_url("not a narinfo at all"), None);
349 }
350
351 /// A narinfo that [`NarInfo::parse`](sui_compat::narinfo::NarInfo::parse)
352 /// **rejects** still advertises a NAR, and must still be indexed.
353 ///
354 /// This one has no `FileHash`/`FileSize`, so the full parser returns
355 /// `MissingField` — yet a client fetching it will still request
356 /// `nar/abc.nar.xz` and still hard-fail if that 404s. Indexing through the
357 /// full parse would leave it out of the index, and an unindexed narinfo
358 /// sharing a narhash with an indexed one is the strand this whole module
359 /// exists to prevent.
360 #[test]
361 fn a_narinfo_the_strict_parser_rejects_still_advertises_its_nar() {
362 let partial = "StorePath: /nix/store/abc-hello\nURL: nar/abc.nar.xz\n\
363 Compression: xz\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n";
364 assert!(
365 sui_compat::narinfo::NarInfo::parse(partial).is_err(),
366 "fixture must actually be one the strict parser rejects",
367 );
368 assert_eq!(advertised_nar_url(partial).as_deref(), Some("nar/abc.nar.xz"));
369 }
370
371 #[tokio::test]
372 async fn the_in_memory_index_is_a_set_per_nar() {
373 let ix = MemNarRefIndex::new();
374 assert!(ix.is_empty());
375
376 ix.record("nar/x.nar", "aaa").await.unwrap();
377 ix.record("nar/x.nar", "bbb").await.unwrap();
378 // Idempotent: the same edge twice is still one edge.
379 ix.record("nar/x.nar", "aaa").await.unwrap();
380 assert_eq!(ix.referrers("nar/x.nar").await.unwrap(), vec!["aaa", "bbb"]);
381 assert_eq!(ix.len(), 2);
382
383 ix.forget("nar/x.nar", "aaa").await.unwrap();
384 assert_eq!(ix.referrers("nar/x.nar").await.unwrap(), vec!["bbb"]);
385
386 // Forgetting an absent edge is not an error.
387 ix.forget("nar/x.nar", "aaa").await.unwrap();
388 ix.forget("nar/absent.nar", "zzz").await.unwrap();
389
390 ix.forget("nar/x.nar", "bbb").await.unwrap();
391 assert!(ix.referrers("nar/x.nar").await.unwrap().is_empty());
392 assert!(ix.is_empty());
393 }
394}