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}