Skip to main content

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}