sui_cache/push.rs
1//! Push pipeline — build output to NAR to sign to upload.
2//!
3//! Takes a store path, dumps it as NAR, compresses it under the configured
4//! [`NarCodec`], builds narinfo metadata, signs it, and uploads both to the
5//! configured storage backend.
6//!
7//! The codec is **typed configuration**, not a compile-time constant: it is a
8//! field of [`CacheConfig`](crate::CacheConfig), which is a
9//! [`shikumi::TieredConfig`] (★★ CONFIGURATION MANAGEMENT). See [`NarCodec`]
10//! for why zstd is the prescribed default and why the level rides *inside* the
11//! codec rather than beside it.
12
13use std::fmt;
14use std::io::Write;
15use std::path::Path;
16
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19use sui_compat::nar::NarWriter;
20use sui_compat::narinfo::NarInfo;
21
22use crate::CacheError;
23use crate::StorageBackend;
24use crate::signing::CacheSigner;
25
26/// Result of pushing a single store path.
27#[derive(Debug, Clone)]
28pub struct PushResult {
29 /// The store path hash used as the narinfo key.
30 pub hash: String,
31 /// Size of the compressed NAR blob uploaded.
32 pub compressed_size: u64,
33 /// Size of the uncompressed NAR.
34 pub nar_size: u64,
35}
36
37/// Push a store path to the binary cache.
38///
39/// 1. Dump the path as NAR
40/// 2. Hash the uncompressed NAR (sha256)
41/// 3. Compress under `codec`
42/// 4. Hash the compressed NAR (sha256)
43/// 5. Build narinfo metadata
44/// 6. Sign the narinfo
45/// 7. Upload NAR blob and narinfo
46///
47/// The `store_path` should be an absolute path like `/nix/store/abc-hello-1.0`.
48/// The `hash` is the 32-character store path hash (the `abc` part).
49///
50/// `references` are the runtime dependency store path basenames.
51///
52/// `codec` is the packing posture — resolved from
53/// [`CacheConfig::nar_codec`](crate::CacheConfig::nar_codec), never chosen
54/// here. It is one value and it is *threaded*, not re-stated: the bytes, the
55/// URL suffix and the `Compression:` field below all read the same parameter,
56/// so a caller has no way to pick zstd bytes and an `xz` narinfo. See
57/// [`NarCodec`].
58///
59/// # Errors
60///
61/// [`CacheError::PathNotFound`] if `store_path` does not exist,
62/// [`CacheError::Io`] if the NAR dump or compression fails, or whatever the
63/// backend returns from the two uploads.
64pub async fn push_path(
65 storage: &dyn StorageBackend,
66 signer: &CacheSigner,
67 store_path: &str,
68 hash: &str,
69 references: &[String],
70 deriver: Option<&str>,
71 codec: NarCodec,
72) -> Result<PushResult, CacheError> {
73 let path = Path::new(store_path);
74 if !path.exists() {
75 return Err(CacheError::PathNotFound(store_path.to_string()));
76 }
77
78 // 1. Dump to NAR.
79 let nar_data = dump_path_to_nar(path)?;
80
81 // 2. Hash uncompressed NAR.
82 let nar_hash = sha256_hex(&nar_data);
83 let nar_size = nar_data.len() as u64;
84
85 // 3. Compress. ONE codec value drives the bytes, the suffix and the
86 // narinfo field below — see `NarCodec`.
87 let compressed = codec.compress(&nar_data)?;
88 let compressed_size = compressed.len() as u64;
89
90 // 4. Hash compressed NAR.
91 let file_hash = sha256_hex(&compressed);
92
93 // 5. Build narinfo.
94 let nar_url = format!("nar/{hash}{suffix}", suffix = codec.url_suffix());
95 let narinfo = NarInfo {
96 store_path: store_path.to_string(),
97 url: nar_url.clone(),
98 compression: codec.narinfo_name().to_string(),
99 file_hash: format!("sha256:{file_hash}"),
100 file_size: compressed_size,
101 nar_hash: format!("sha256:{nar_hash}"),
102 nar_size,
103 references: references.to_vec(),
104 deriver: deriver.map(String::from),
105 signatures: vec![],
106 ca: None,
107 };
108
109 // 6. Sign.
110 let sig = signer.sign_narinfo(&narinfo);
111 let narinfo = NarInfo {
112 signatures: vec![sig],
113 ..narinfo
114 };
115
116 // 7. Upload.
117 storage.put_nar(&nar_url, &compressed).await?;
118 storage.put_narinfo(hash, &narinfo.serialize()).await?;
119
120 Ok(PushResult {
121 hash: hash.to_string(),
122 compressed_size,
123 nar_size,
124 })
125}
126
127/// Dump a filesystem path to NAR format in memory.
128fn dump_path_to_nar(path: &Path) -> Result<Vec<u8>, CacheError> {
129 let mut buf = Vec::new();
130 NarWriter::write_path(&mut buf, path)
131 .map_err(|e| CacheError::Io(std::io::Error::other(format!("NAR dump failed: {e}"))))?;
132 Ok(buf)
133}
134
135/// How a NAR is packed for the cache.
136///
137/// ── ★ ONE VALUE — THE BYTES, THE SUFFIX AND THE NARINFO ALL DERIVE ──────
138/// The codec used to be stated in THREE disconnected places in `push_path`:
139/// the call to `compress_xz`, the literal `.nar.xz` in the URL, and
140/// `compression: "xz".to_string()` in the narinfo. Three declarations of one
141/// fact, free to disagree — and disagreement is not a cosmetic bug: a narinfo
142/// that says `xz` over zstd bytes makes EVERY client fail to decompress, so
143/// the cache would serve corruption while reporting success. That is the
144/// failure mode this type removes, by leaving no way to state the codec twice.
145///
146/// ── WHY zstd IS THE DEFAULT — MEASURED, NOT ASSUMED ─────────────────────
147/// Benchmarked on a real 48 MB NAR (git 2.51.2), 10 cores, 2026-08-05:
148///
149/// ```text
150/// codec ms size %orig
151/// xz -6 (previous) 8368 8 MB 17%
152/// xz -6 -T0 7615 8 MB 17% <- multithreading xz buys 9%
153/// zstd -19 -T0 11298 8 MB 17% <- SLOWER than xz for the ratio
154/// zstd -12 -T0 440 10 MB 21% <- 19x faster than xz -6
155/// zstd -9 -T0 243 10 MB 22% <- 34x faster
156/// ```
157///
158/// Two beliefs died there. "Just add `-T0` to xz" gains 9%, not the order of
159/// magnitude it promises — liblzma's block splitting barely engages at this
160/// size. And zstd is only faster at *lower* levels; at -19 it loses to xz on
161/// both axes. The knee is -12: 19x the speed for four percentage points of
162/// ratio.
163///
164/// That trade is obviously right HERE and the reason is architectural: this
165/// cache is a LOCAL origin serving a handful of fleet nodes over tailscale.
166/// Bandwidth is cheap; CPU-hours on the fleet's only x86_64-linux builder are
167/// not. MEASURED cost of the old default on rio 2026-08-05: a 2483-path
168/// closure spent FOUR HOURS in single-threaded xz, and because nix runs the
169/// post-build hook synchronously it blocked every build on that node — which
170/// is a different bug (fixed by detaching the hook) that this default made
171/// unsurvivable.
172///
173/// A mixed cache is fine and needs no migration: each narinfo declares its own
174/// codec, so paths already stored as `.nar.xz` keep resolving while new pushes
175/// land as `.nar.zst`.
176///
177/// ── WHY THE LEVEL LIVES *INSIDE* THE VARIANT ────────────────────────────
178/// The level used to be a free-standing `const ZSTD_LEVEL`. Lifting it to a
179/// sibling config field (`{ codec, level }`) would have been the obvious move
180/// and is wrong: `level = 12` means nothing when `codec = Xz`, and `level = 9`
181/// means two completely different things across the two codecs (near-max for
182/// xz, mid-range for zstd). A pair whose second component is only meaningful
183/// for some values of the first is a variant payload, not a field — so the
184/// codec choice and its one tuning knob travel as one value that cannot be
185/// split, reordered, or half-applied.
186///
187/// The levels are [`ZstdLevel`] / [`XzLevel`], not bare integers: `xz2`'s
188/// encoder **panics** on a preset above 9, so an out-of-range level in a config
189/// file used to be a crash waiting on the first push. It is now rejected where
190/// the value is built.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(tag = "codec", rename_all = "lowercase")]
193pub enum NarCodec {
194 /// zstd, multithreaded. The prescribed default at
195 /// [`ZstdLevel::PRESCRIBED`].
196 Zstd {
197 /// The compression level, bounded to zstd's accepted band.
198 #[serde(default)]
199 level: ZstdLevel,
200 },
201 /// xz — what this cache used before 2026-08-05. Kept selectable rather
202 /// than deleted (★★ MODULARIZE, DON'T DELETE): it is still the right
203 /// choice for an origin that is bandwidth-bound rather than CPU-bound, and
204 /// it is what every already-stored path is packed with.
205 Xz {
206 /// The preset, bounded to xz's accepted band.
207 #[serde(default)]
208 level: XzLevel,
209 },
210}
211
212impl Default for NarCodec {
213 /// The fast path is what you get without asking — see the benchmark in the
214 /// [`NarCodec`] docs.
215 fn default() -> Self {
216 Self::Zstd {
217 level: ZstdLevel::default(),
218 }
219 }
220}
221
222/// A compression level that fell outside its codec's accepted band.
223///
224/// Returned by [`ZstdLevel::new`] / [`XzLevel::new`] and, through
225/// `#[serde(try_from)]`, by deserializing a config that names an impossible
226/// level — so a bad level is a **config-parse rejection**, not a panic on the
227/// first push. (Tier-honest: parse-time-rejected, one rung below
228/// truly-unrepresentable — the inner field is private, so the only way to build
229/// a level is through the checked constructor.)
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub struct LevelOutOfRange {
232 /// Which codec's band was violated (`"zstd"` / `"xz"`).
233 pub codec: &'static str,
234 /// The rejected value.
235 pub got: i64,
236 /// The inclusive lower bound.
237 pub min: i64,
238 /// The inclusive upper bound.
239 pub max: i64,
240}
241
242impl fmt::Display for LevelOutOfRange {
243 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244 write!(
245 f,
246 "{} level {} is outside the accepted range {}..={}",
247 self.codec, self.got, self.min, self.max
248 )
249 }
250}
251
252impl std::error::Error for LevelOutOfRange {}
253
254/// A zstd compression level known to be inside the accepted band.
255///
256/// The band is `1..=22`, not zstd's full `ZSTD_minCLevel()..=22`. The negative
257/// "ultra-fast" levels are real but unmeasured here, and the benchmark that
258/// picked 12 only covers the positive range — admitting a level we have never
259/// timed would be a knob with no evidence behind it.
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(try_from = "i32", into = "i32")]
262pub struct ZstdLevel(i32);
263
264impl ZstdLevel {
265 /// Lowest accepted level.
266 pub const MIN: i32 = 1;
267 /// Highest accepted level (zstd's maximum).
268 pub const MAX: i32 = 22;
269 /// The measured knee (see [`NarCodec`]): 19x faster than xz -6 for four
270 /// percentage points of ratio. Not a round number chosen for looks.
271 pub const PRESCRIBED: i32 = 12;
272
273 /// Build a level, rejecting anything outside `MIN..=MAX`.
274 ///
275 /// # Errors
276 ///
277 /// [`LevelOutOfRange`] if `level` is outside the accepted band.
278 pub const fn new(level: i32) -> Result<Self, LevelOutOfRange> {
279 if level < Self::MIN || level > Self::MAX {
280 return Err(LevelOutOfRange {
281 codec: "zstd",
282 got: level as i64,
283 min: Self::MIN as i64,
284 max: Self::MAX as i64,
285 });
286 }
287 Ok(Self(level))
288 }
289
290 /// The level as the integer zstd's encoder wants.
291 #[must_use]
292 pub const fn get(self) -> i32 {
293 self.0
294 }
295}
296
297impl Default for ZstdLevel {
298 fn default() -> Self {
299 Self(Self::PRESCRIBED)
300 }
301}
302
303impl TryFrom<i32> for ZstdLevel {
304 type Error = LevelOutOfRange;
305 fn try_from(v: i32) -> Result<Self, Self::Error> {
306 Self::new(v)
307 }
308}
309
310impl From<ZstdLevel> for i32 {
311 fn from(v: ZstdLevel) -> Self {
312 v.0
313 }
314}
315
316/// An xz preset known to be inside the accepted band (`0..=9`).
317///
318/// The bound is load-bearing rather than decorative: `xz2::write::XzEncoder`
319/// unwraps `Stream::new_easy_encoder`, so a preset of 10 **panics** the pushing
320/// process rather than returning an error.
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(try_from = "u32", into = "u32")]
323pub struct XzLevel(u32);
324
325impl XzLevel {
326 /// Lowest accepted preset.
327 pub const MIN: u32 = 0;
328 /// Highest accepted preset — above this `xz2` panics.
329 pub const MAX: u32 = 9;
330 /// What this cache used before 2026-08-05, and therefore what every
331 /// already-stored `.nar.xz` is packed with.
332 pub const PRESCRIBED: u32 = 6;
333
334 /// Build a preset, rejecting anything outside `MIN..=MAX`.
335 ///
336 /// # Errors
337 ///
338 /// [`LevelOutOfRange`] if `level` is outside the accepted band.
339 pub const fn new(level: u32) -> Result<Self, LevelOutOfRange> {
340 if level > Self::MAX {
341 return Err(LevelOutOfRange {
342 codec: "xz",
343 got: level as i64,
344 min: Self::MIN as i64,
345 max: Self::MAX as i64,
346 });
347 }
348 Ok(Self(level))
349 }
350
351 /// The preset as the integer `xz2`'s encoder wants.
352 #[must_use]
353 pub const fn get(self) -> u32 {
354 self.0
355 }
356}
357
358impl Default for XzLevel {
359 fn default() -> Self {
360 Self(Self::PRESCRIBED)
361 }
362}
363
364impl TryFrom<u32> for XzLevel {
365 type Error = LevelOutOfRange;
366 fn try_from(v: u32) -> Result<Self, Self::Error> {
367 Self::new(v)
368 }
369}
370
371impl From<XzLevel> for u32 {
372 fn from(v: XzLevel) -> Self {
373 v.0
374 }
375}
376
377impl NarCodec {
378 /// The `Compression:` field value. **nix's wire vocabulary, not ours** —
379 /// verified 2026-08-05 by having nix write a zstd cache itself
380 /// (`nix copy --to 'file://…?compression=zstd'`) and reading back what it
381 /// emitted.
382 ///
383 /// The level is deliberately absent from this value: it is an *encoder*
384 /// setting, and a decompressor reads it out of the frame header. A codec
385 /// reconfigured from level 12 to level 3 still publishes `zstd`, and every
386 /// client still reads it.
387 #[must_use]
388 pub fn narinfo_name(self) -> &'static str {
389 match self {
390 Self::Zstd { .. } => "zstd",
391 Self::Xz { .. } => "xz",
392 }
393 }
394
395 /// The NAR URL suffix.
396 ///
397 /// `.nar.zst`, NOT `.nar.zstd` — taken from nix's own output in the same
398 /// experiment above. Guessing here would have produced a cache whose URLs
399 /// no client resolves, and nothing in our own types would have objected.
400 #[must_use]
401 pub fn url_suffix(self) -> &'static str {
402 match self {
403 Self::Zstd { .. } => ".nar.zst",
404 Self::Xz { .. } => ".nar.xz",
405 }
406 }
407
408 /// Compress a NAR under this codec.
409 ///
410 /// zstd runs multithreaded across the machine's cores; `workers(0)` asks
411 /// the library for one worker per core. A failure to enable threading is
412 /// deliberately NOT fatal — it costs speed, never correctness, and a cache
413 /// push that refuses to run is worse than a slow one.
414 ///
415 /// # Errors
416 ///
417 /// [`CacheError::Io`] if the encoder cannot be built or the write fails.
418 /// The level cannot be the cause: it is bounded at construction.
419 pub fn compress(self, data: &[u8]) -> Result<Vec<u8>, CacheError> {
420 match self {
421 Self::Zstd { level } => {
422 let mut out = Vec::new();
423 let mut enc = zstd::Encoder::new(&mut out, level.get()).map_err(CacheError::Io)?;
424 let _ = enc.multithread(
425 u32::try_from(std::thread::available_parallelism().map_or(1, usize::from))
426 .unwrap_or(1),
427 );
428 enc.write_all(data).map_err(CacheError::Io)?;
429 enc.finish().map_err(CacheError::Io)?;
430 Ok(out)
431 }
432 Self::Xz { level } => {
433 let mut out = Vec::new();
434 // `level.get()` is bounded to 0..=9 by construction — the
435 // unwrap inside `XzEncoder::new` cannot be reached.
436 let mut enc = xz2::write::XzEncoder::new(&mut out, level.get());
437 enc.write_all(data).map_err(CacheError::Io)?;
438 enc.finish().map_err(CacheError::Io)?;
439 Ok(out)
440 }
441 }
442 }
443
444 /// Decompress a NAR packed under this codec.
445 ///
446 /// The inverse of [`compress`](Self::compress), on the same value — so a
447 /// test (or a future serve-side verifier) can prove that what a narinfo
448 /// *declares* actually decodes the bytes it points at, rather than
449 /// asserting two strings match.
450 ///
451 /// # Errors
452 ///
453 /// [`CacheError::Io`] if the data is not a valid frame for this codec.
454 pub fn decompress(self, data: &[u8]) -> Result<Vec<u8>, CacheError> {
455 use std::io::Read;
456 let mut out = Vec::new();
457 match self {
458 Self::Zstd { .. } => {
459 zstd::Decoder::new(data)
460 .map_err(CacheError::Io)?
461 .read_to_end(&mut out)
462 .map_err(CacheError::Io)?;
463 }
464 Self::Xz { .. } => {
465 xz2::read::XzDecoder::new(data)
466 .read_to_end(&mut out)
467 .map_err(CacheError::Io)?;
468 }
469 }
470 Ok(out)
471 }
472
473 /// Resolve a codec from the `Compression:` field of a narinfo — the
474 /// *reader's* half of the one-value invariant.
475 ///
476 /// Level is irrelevant on the decode side (it lives in the frame header),
477 /// so the returned value carries the prescribed level as a placeholder and
478 /// is only ever used for its [`decompress`](Self::decompress) /
479 /// [`url_suffix`](Self::url_suffix) projections.
480 #[must_use]
481 pub fn from_narinfo_name(name: &str) -> Option<Self> {
482 match name {
483 "zstd" => Some(Self::Zstd {
484 level: ZstdLevel::default(),
485 }),
486 "xz" => Some(Self::Xz {
487 level: XzLevel::default(),
488 }),
489 _ => None,
490 }
491 }
492}
493
494/// Compute SHA-256 hash and return lowercase hex.
495fn sha256_hex(data: &[u8]) -> String {
496 let digest = Sha256::digest(data);
497 let mut s = String::with_capacity(64);
498 for b in digest.as_slice() {
499 use std::fmt::Write;
500 let _ = write!(s, "{b:02x}");
501 }
502 s
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use crate::LocalStorage;
509 use crate::signing::CacheSigner;
510
511 #[tokio::test]
512 async fn push_single_file() {
513 let cache_dir = tempfile::tempdir().unwrap();
514 let storage = LocalStorage::new(cache_dir.path());
515 let signer = CacheSigner::generate("test-cache".to_string());
516
517 // Create a store path to push.
518 let store_dir = tempfile::tempdir().unwrap();
519 let fake_store = store_dir.path().join("nix/store/abc-hello-1.0");
520 std::fs::create_dir_all(&fake_store).unwrap();
521 std::fs::write(fake_store.join("hello.txt"), b"Hello world!").unwrap();
522
523 let result = push_path(
524 &storage,
525 &signer,
526 fake_store.to_str().unwrap(),
527 "abc",
528 &[],
529 None,
530 NarCodec::default(),
531 )
532 .await
533 .unwrap();
534
535 assert_eq!(result.hash, "abc");
536 assert!(result.nar_size > 0);
537 assert!(result.compressed_size > 0);
538
539 // Verify narinfo was uploaded.
540 let narinfo = storage.get_narinfo("abc").await.unwrap().unwrap();
541 let parsed = NarInfo::parse(&narinfo).unwrap();
542 // Derived from NarCodec::default(), never restated — asserting a
543 // literal here is what let the bytes and the narinfo drift apart in
544 // the first place.
545 assert_eq!(parsed.compression, NarCodec::default().narinfo_name());
546 assert_eq!(parsed.signatures.len(), 1);
547 assert!(parsed.signatures[0].starts_with("test-cache:"));
548
549 // Verify NAR blob was uploaded.
550 let nar_key = format!("nar/abc{}", NarCodec::default().url_suffix());
551 let nar = storage.get_nar(&nar_key).await.unwrap().unwrap();
552 assert!(!nar.is_empty());
553 }
554
555 #[tokio::test]
556 async fn push_nonexistent_path_errors() {
557 let dir = tempfile::tempdir().unwrap();
558 let storage = LocalStorage::new(dir.path());
559 let signer = CacheSigner::generate("k".to_string());
560
561 let result = push_path(
562 &storage,
563 &signer,
564 "/nix/store/does-not-exist-12345",
565 "nope",
566 &[],
567 None,
568 NarCodec::default(),
569 )
570 .await;
571
572 assert!(result.is_err());
573 assert!(matches!(result, Err(CacheError::PathNotFound(_))));
574 }
575
576 #[tokio::test]
577 async fn push_with_references() {
578 let cache_dir = tempfile::tempdir().unwrap();
579 let storage = LocalStorage::new(cache_dir.path());
580 let signer = CacheSigner::generate("k".to_string());
581
582 let store_dir = tempfile::tempdir().unwrap();
583 let path = store_dir.path().join("pkg");
584 std::fs::create_dir_all(&path).unwrap();
585 std::fs::write(path.join("file"), b"data").unwrap();
586
587 let refs = vec!["dep1-glibc".to_string(), "dep2-gcc".to_string()];
588 let result = push_path(
589 &storage,
590 &signer,
591 path.to_str().unwrap(),
592 "xyz",
593 &refs,
594 Some("builder.drv"),
595 NarCodec::default(),
596 )
597 .await
598 .unwrap();
599
600 assert_eq!(result.hash, "xyz");
601
602 let narinfo = storage.get_narinfo("xyz").await.unwrap().unwrap();
603 let parsed = NarInfo::parse(&narinfo).unwrap();
604 assert_eq!(parsed.references, refs);
605 assert_eq!(parsed.deriver, Some("builder.drv".to_string()));
606 }
607
608 #[tokio::test]
609 async fn pushed_narinfo_is_valid_and_verifiable() {
610 let cache_dir = tempfile::tempdir().unwrap();
611 let storage = LocalStorage::new(cache_dir.path());
612 let signer = CacheSigner::generate("verify-key".to_string());
613 let pk_str = signer.public_key_string();
614
615 let store_dir = tempfile::tempdir().unwrap();
616 let path = store_dir.path().join("test-pkg");
617 std::fs::create_dir_all(&path).unwrap();
618 std::fs::write(path.join("data"), b"test content").unwrap();
619
620 push_path(
621 &storage,
622 &signer,
623 path.to_str().unwrap(),
624 "ttt",
625 &[],
626 None,
627 NarCodec::default(),
628 )
629 .await
630 .unwrap();
631
632 let narinfo_text = storage.get_narinfo("ttt").await.unwrap().unwrap();
633 let parsed = NarInfo::parse(&narinfo_text).unwrap();
634
635 // Verify the signature.
636 let valid =
637 crate::signing::verify_narinfo_signature(&parsed, &parsed.signatures[0], &pk_str)
638 .unwrap();
639 assert!(valid);
640 }
641
642 #[test]
643 fn sha256_hex_produces_correct_output() {
644 // SHA-256 of empty string is well-known.
645 let hash = sha256_hex(b"");
646 assert_eq!(
647 hash,
648 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
649 );
650 }
651
652 /// Every codec the config surface can name — the closed set the
653 /// invariant tests below sweep. A new variant that is not added here
654 /// leaves the exhaustive `match` in [`codecs`] failing to compile, so the
655 /// sweep cannot silently stop covering it.
656 fn codecs() -> Vec<NarCodec> {
657 // Exhaustive by construction: this match forces a compile error when a
658 // variant is added, which is the point of writing it as a match over a
659 // dummy rather than as a literal list.
660 let all = [
661 NarCodec::Zstd {
662 level: ZstdLevel::default(),
663 },
664 NarCodec::Xz {
665 level: XzLevel::default(),
666 },
667 ];
668 for c in all {
669 match c {
670 NarCodec::Zstd { .. } | NarCodec::Xz { .. } => {}
671 }
672 }
673 all.to_vec()
674 }
675
676 #[test]
677 fn every_codec_round_trips() {
678 use std::io::Read;
679 let data = b"hello world, this is test data for NAR compression";
680
681 let xz = NarCodec::Xz {
682 level: XzLevel::default(),
683 }
684 .compress(data)
685 .unwrap();
686 let mut d = xz2::read::XzDecoder::new(xz.as_slice());
687 let mut out = Vec::new();
688 d.read_to_end(&mut out).unwrap();
689 assert_eq!(out, data, "xz must round-trip");
690
691 let z = NarCodec::Zstd {
692 level: ZstdLevel::default(),
693 }
694 .compress(data)
695 .unwrap();
696 let out = zstd::decode_all(z.as_slice()).unwrap();
697 assert_eq!(out, data, "zstd must round-trip");
698 }
699
700 #[test]
701 fn every_configurable_level_round_trips_under_its_own_codec() {
702 // The level is now operator-supplied, so the round-trip has to hold
703 // across the band, not only at the prescribed knee.
704 let data = b"hello world, this is test data for NAR compression".repeat(64);
705 for level in [ZstdLevel::MIN, ZstdLevel::PRESCRIBED, 19, ZstdLevel::MAX] {
706 let codec = NarCodec::Zstd {
707 level: ZstdLevel::new(level).unwrap(),
708 };
709 assert_eq!(
710 codec.decompress(&codec.compress(&data).unwrap()).unwrap(),
711 data
712 );
713 }
714 for level in [XzLevel::MIN, XzLevel::PRESCRIBED, XzLevel::MAX] {
715 let codec = NarCodec::Xz {
716 level: XzLevel::new(level).unwrap(),
717 };
718 assert_eq!(
719 codec.decompress(&codec.compress(&data).unwrap()).unwrap(),
720 data
721 );
722 }
723 }
724
725 #[test]
726 fn an_out_of_band_level_is_rejected_where_it_is_built() {
727 // xz2's encoder PANICS above preset 9 — the bound is what stops a
728 // config typo from taking the pushing process down mid-closure.
729 assert!(XzLevel::new(10).is_err(), "xz preset 10 panics xz2");
730 assert!(XzLevel::new(u32::MAX).is_err());
731 assert!(ZstdLevel::new(0).is_err());
732 assert!(ZstdLevel::new(23).is_err());
733 assert!(ZstdLevel::new(-5).is_err(), "unmeasured ultra-fast band");
734
735 // …and the rejection reaches config parsing, so a bad YAML/JSON level
736 // is a startup error rather than a first-push surprise.
737 let bad = r#"{ "codec": "xz", "level": 10 }"#;
738 assert!(
739 serde_json::from_str::<NarCodec>(bad).is_err(),
740 "a config naming an impossible level must fail to parse"
741 );
742 let good = r#"{ "codec": "xz", "level": 9 }"#;
743 assert_eq!(
744 serde_json::from_str::<NarCodec>(good).unwrap(),
745 NarCodec::Xz {
746 level: XzLevel::new(9).unwrap()
747 }
748 );
749 }
750
751 #[test]
752 fn a_codec_without_a_level_takes_its_own_prescribed_one() {
753 // The level rides inside the variant, so omitting it in config picks
754 // the level that belongs to THAT codec — 12 for zstd, 6 for xz. A
755 // shared `level` field could not have done this.
756 assert_eq!(
757 serde_json::from_str::<NarCodec>(r#"{ "codec": "zstd" }"#).unwrap(),
758 NarCodec::Zstd {
759 level: ZstdLevel::new(12).unwrap()
760 }
761 );
762 assert_eq!(
763 serde_json::from_str::<NarCodec>(r#"{ "codec": "xz" }"#).unwrap(),
764 NarCodec::Xz {
765 level: XzLevel::new(6).unwrap()
766 }
767 );
768 }
769
770 // ── ★ THE INVARIANT THIS TYPE EXISTS FOR ────────────────────────────
771 // The codec used to be stated three times in `push_path` — the compress
772 // call, the `.nar.xz` URL literal, and `compression: "xz"`. A narinfo that
773 // disagrees with its bytes is not a cosmetic defect: every client fails to
774 // decompress, so the cache serves corruption while reporting success.
775 // These pin that the three can only ever come from one value.
776
777 #[test]
778 fn the_suffix_and_the_narinfo_name_agree_for_every_codec() {
779 for codec in codecs() {
780 let suffix = codec.url_suffix();
781 let name = codec.narinfo_name();
782 // `.nar.zst` carries `zstd`; `.nar.xz` carries `xz`. The suffix is
783 // nix's spelling, not ours — hence the explicit pairing rather
784 // than a string-derived assertion.
785 let expected_suffix = match name {
786 "zstd" => ".nar.zst",
787 "xz" => ".nar.xz",
788 other => panic!("unknown codec name {other} — add its suffix pairing"),
789 };
790 assert_eq!(
791 suffix, expected_suffix,
792 "codec {codec:?} would publish a URL its own Compression field \
793 does not describe; every client would fail to decompress"
794 );
795 }
796 }
797
798 #[test]
799 fn the_narinfo_names_are_nix_wire_vocabulary() {
800 // Verified 2026-08-05 against nix itself: `nix copy --to
801 // 'file://…?compression=zstd'` emits `Compression: zstd` and
802 // `URL: nar/….nar.zst`. These are nix's spellings, not ours, so they
803 // are pinned rather than derived — `.nar.zstd` would have been the
804 // natural guess and is WRONG.
805 //
806 // The level is varied deliberately: the wire vocabulary is a property
807 // of the CODEC, never of its tuning, so a re-levelled codec must still
808 // publish the same two strings.
809 for level in [ZstdLevel::MIN, ZstdLevel::PRESCRIBED, ZstdLevel::MAX] {
810 let c = NarCodec::Zstd {
811 level: ZstdLevel::new(level).unwrap(),
812 };
813 assert_eq!(c.narinfo_name(), "zstd");
814 assert_eq!(c.url_suffix(), ".nar.zst");
815 }
816 for level in [XzLevel::MIN, XzLevel::PRESCRIBED, XzLevel::MAX] {
817 let c = NarCodec::Xz {
818 level: XzLevel::new(level).unwrap(),
819 };
820 assert_eq!(c.narinfo_name(), "xz");
821 assert_eq!(c.url_suffix(), ".nar.xz");
822 }
823 }
824
825 #[test]
826 fn the_default_codec_is_the_fast_one() {
827 // The whole point of the change. If someone flips the default back to
828 // xz, they should have to edit this test and say why — a 2483-path
829 // closure cost FOUR HOURS under xz -6 on rio.
830 //
831 // Now that the codec is CONFIGURABLE the guarantee is bigger than the
832 // `Default` impl: the prescribed shikumi tier — what an operator who
833 // sets nothing actually gets — must be the fast one too. A default
834 // that is fast while the prescribed tier is slow would satisfy the old
835 // assertion and still hand every unconfigured origin xz.
836 assert_eq!(
837 NarCodec::default(),
838 NarCodec::Zstd {
839 level: ZstdLevel::new(ZstdLevel::PRESCRIBED).unwrap()
840 }
841 );
842 assert_eq!(
843 crate::CacheConfig::default().nar_codec,
844 NarCodec::default(),
845 "CacheConfig::default() must not describe a different cache"
846 );
847 assert_eq!(
848 <crate::CacheConfig as shikumi::TieredConfig>::prescribed_default().nar_codec,
849 NarCodec::default(),
850 "an operator who configures nothing must get the measured fast path"
851 );
852 }
853
854 // ── ★ THE DRIFT CLASS, UNDER CONFIGURATION ──────────────────────────
855 // Making the codec configurable introduces the risk the type was built to
856 // remove: a NON-default codec is now reachable in production, so it must
857 // agree with itself just as tightly as the default does.
858
859 #[tokio::test]
860 async fn a_configured_non_default_codec_still_agrees_end_to_end() {
861 // Not a string comparison: for EVERY codec the config can name, push a
862 // real path, then decode the stored blob using ONLY what the narinfo
863 // declares — the codec resolved from its `Compression:` field, at the
864 // URL its `URL:` field names — and check the bytes hash to the
865 // `NarHash:` it advertises. That is exactly what a nix client does, so
866 // a narinfo that disagrees with its bytes fails here the way it would
867 // fail in the field, rather than passing a lint.
868 for codec in codecs() {
869 assert_ne!(
870 codec.narinfo_name(),
871 "",
872 "every codec must name itself on the wire"
873 );
874 let cache_dir = tempfile::tempdir().unwrap();
875 let storage = LocalStorage::new(cache_dir.path());
876 let signer = CacheSigner::generate("cfg-key".to_string());
877
878 let store_dir = tempfile::tempdir().unwrap();
879 let path = store_dir.path().join("cfg-pkg");
880 std::fs::create_dir_all(&path).unwrap();
881 std::fs::write(
882 path.join("payload"),
883 b"configured-codec payload".repeat(512),
884 )
885 .unwrap();
886
887 push_path(
888 &storage,
889 &signer,
890 path.to_str().unwrap(),
891 "cfg",
892 &[],
893 None,
894 codec,
895 )
896 .await
897 .unwrap();
898
899 let parsed =
900 NarInfo::parse(&storage.get_narinfo("cfg").await.unwrap().unwrap()).unwrap();
901
902 // (a) The declared codec is a codec we can actually resolve.
903 let declared = NarCodec::from_narinfo_name(&parsed.compression)
904 .unwrap_or_else(|| panic!("unresolvable Compression: {}", parsed.compression));
905
906 // (b) The URL the narinfo publishes carries that codec's suffix.
907 assert!(
908 parsed.url.ends_with(declared.url_suffix()),
909 "narinfo for {codec:?} publishes URL {} under Compression {} — \
910 the suffix and the field disagree",
911 parsed.url,
912 parsed.compression
913 );
914
915 // (c) The blob really is at that URL…
916 let blob = storage
917 .get_nar(&parsed.url)
918 .await
919 .unwrap()
920 .unwrap_or_else(|| panic!("no NAR stored at the advertised URL {}", parsed.url));
921
922 // (d) …and it decodes under the DECLARED codec, to bytes matching
923 // the declared NarHash. This is the assertion that would have
924 // caught zstd bytes wearing an `xz` label.
925 let plain = declared.decompress(&blob).unwrap_or_else(|e| {
926 panic!(
927 "narinfo declares {} but the bytes do not decode as it: {e}",
928 parsed.compression
929 )
930 });
931 assert_eq!(
932 parsed.nar_hash,
933 format!("sha256:{}", sha256_hex(&plain)),
934 "decoded bytes do not match the NarHash the narinfo advertises"
935 );
936 assert_eq!(parsed.nar_size, plain.len() as u64);
937 assert_eq!(
938 parsed.file_hash,
939 format!("sha256:{}", sha256_hex(&blob)),
940 "FileHash does not describe the stored blob"
941 );
942 }
943 }
944
945 #[tokio::test]
946 async fn the_codec_a_config_names_is_the_codec_a_push_uses() {
947 // The whole wire, from a YAML file on disk to the bytes in the cache,
948 // through shikumi's REAL loader (`ConfigTier::Custom`) rather than a
949 // hand-rolled parse — a knob that resolves but never reaches the
950 // compressor is decorative, and the operator's belief about their
951 // cache would be wrong.
952 use shikumi::{ConfigTier, TieredConfig};
953
954 let cfg_dir = tempfile::tempdir().unwrap();
955 let cfg_path = cfg_dir.path().join("cache.yaml");
956 std::fs::write(&cfg_path, "nar_codec:\n codec: xz\n level: 1\n").unwrap();
957
958 let configured = crate::CacheConfig::resolve_tier(ConfigTier::Custom(cfg_path)).nar_codec;
959 assert_eq!(
960 configured,
961 NarCodec::Xz {
962 level: XzLevel::new(1).unwrap()
963 },
964 "the YAML overlay did not reach the codec field"
965 );
966
967 let cache_dir = tempfile::tempdir().unwrap();
968 let storage = LocalStorage::new(cache_dir.path());
969 let signer = CacheSigner::generate("cfg-key".to_string());
970 let store_dir = tempfile::tempdir().unwrap();
971 let path = store_dir.path().join("pkg");
972 std::fs::create_dir_all(&path).unwrap();
973 std::fs::write(path.join("f"), b"data").unwrap();
974
975 push_path(
976 &storage,
977 &signer,
978 path.to_str().unwrap(),
979 "cfgd",
980 &[],
981 None,
982 configured,
983 )
984 .await
985 .unwrap();
986
987 let parsed = NarInfo::parse(&storage.get_narinfo("cfgd").await.unwrap().unwrap()).unwrap();
988 assert_eq!(parsed.compression, "xz");
989 assert_eq!(parsed.url, "nar/cfgd.nar.xz");
990 // And it is NOT the default — otherwise this test would pass while the
991 // configuration was being ignored entirely.
992 assert_ne!(configured, NarCodec::default());
993 assert_ne!(parsed.compression, NarCodec::default().narinfo_name());
994 }
995}