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 with xz,
4//! builds narinfo metadata, signs it, and uploads both to the
5//! configured storage backend.
6
7use std::io::Write;
8use std::path::Path;
9
10use sha2::{Digest, Sha256};
11use sui_compat::nar::NarWriter;
12use sui_compat::narinfo::NarInfo;
13
14use crate::CacheError;
15use crate::StorageBackend;
16use crate::signing::CacheSigner;
17
18/// Result of pushing a single store path.
19#[derive(Debug, Clone)]
20pub struct PushResult {
21    /// The store path hash used as the narinfo key.
22    pub hash: String,
23    /// Size of the compressed NAR blob uploaded.
24    pub compressed_size: u64,
25    /// Size of the uncompressed NAR.
26    pub nar_size: u64,
27}
28
29/// Push a store path to the binary cache.
30///
31/// 1. Dump the path as NAR
32/// 2. Hash the uncompressed NAR (sha256)
33/// 3. Compress with xz
34/// 4. Hash the compressed NAR (sha256)
35/// 5. Build narinfo metadata
36/// 6. Sign the narinfo
37/// 7. Upload NAR blob and narinfo
38///
39/// The `store_path` should be an absolute path like `/nix/store/abc-hello-1.0`.
40/// The `hash` is the 32-character store path hash (the `abc` part).
41///
42/// `references` are the runtime dependency store path basenames.
43pub async fn push_path(
44    storage: &dyn StorageBackend,
45    signer: &CacheSigner,
46    store_path: &str,
47    hash: &str,
48    references: &[String],
49    deriver: Option<&str>,
50) -> Result<PushResult, CacheError> {
51    let path = Path::new(store_path);
52    if !path.exists() {
53        return Err(CacheError::PathNotFound(store_path.to_string()));
54    }
55
56    // 1. Dump to NAR.
57    let nar_data = dump_path_to_nar(path)?;
58
59    // 2. Hash uncompressed NAR.
60    let nar_hash = sha256_hex(&nar_data);
61    let nar_size = nar_data.len() as u64;
62
63    // 3. Compress. ONE codec value drives the bytes, the suffix and the
64    //    narinfo field below — see `NarCodec`.
65    let codec = NarCodec::default();
66    let compressed = codec.compress(&nar_data)?;
67    let compressed_size = compressed.len() as u64;
68
69    // 4. Hash compressed NAR.
70    let file_hash = sha256_hex(&compressed);
71
72    // 5. Build narinfo.
73    let nar_url = format!("nar/{hash}{suffix}", suffix = codec.url_suffix());
74    let narinfo = NarInfo {
75        store_path: store_path.to_string(),
76        url: nar_url.clone(),
77        compression: codec.narinfo_name().to_string(),
78        file_hash: format!("sha256:{file_hash}"),
79        file_size: compressed_size,
80        nar_hash: format!("sha256:{nar_hash}"),
81        nar_size,
82        references: references.to_vec(),
83        deriver: deriver.map(String::from),
84        signatures: vec![],
85        ca: None,
86    };
87
88    // 6. Sign.
89    let sig = signer.sign_narinfo(&narinfo);
90    let narinfo = NarInfo {
91        signatures: vec![sig],
92        ..narinfo
93    };
94
95    // 7. Upload.
96    storage.put_nar(&nar_url, &compressed).await?;
97    storage.put_narinfo(hash, &narinfo.serialize()).await?;
98
99    Ok(PushResult {
100        hash: hash.to_string(),
101        compressed_size,
102        nar_size,
103    })
104}
105
106/// Dump a filesystem path to NAR format in memory.
107fn dump_path_to_nar(path: &Path) -> Result<Vec<u8>, CacheError> {
108    let mut buf = Vec::new();
109    NarWriter::write_path(&mut buf, path).map_err(|e| {
110        CacheError::Io(std::io::Error::new(
111            std::io::ErrorKind::Other,
112            format!("NAR dump failed: {e}"),
113        ))
114    })?;
115    Ok(buf)
116}
117
118/// How a NAR is packed for the cache.
119///
120/// ── ★ ONE VALUE — THE BYTES, THE SUFFIX AND THE NARINFO ALL DERIVE ──────
121/// The codec used to be stated in THREE disconnected places in `push_path`:
122/// the call to `compress_xz`, the literal `.nar.xz` in the URL, and
123/// `compression: "xz".to_string()` in the narinfo. Three declarations of one
124/// fact, free to disagree — and disagreement is not a cosmetic bug: a narinfo
125/// that says `xz` over zstd bytes makes EVERY client fail to decompress, so
126/// the cache would serve corruption while reporting success. That is the
127/// failure mode this type removes, by leaving no way to state the codec twice.
128///
129/// ── WHY zstd IS THE DEFAULT — MEASURED, NOT ASSUMED ─────────────────────
130/// Benchmarked on a real 48 MB NAR (git 2.51.2), 10 cores, 2026-08-05:
131///
132/// ```text
133///   codec              ms     size   %orig
134///   xz -6  (previous)  8368   8 MB    17%
135///   xz -6 -T0          7615   8 MB    17%     <- multithreading xz buys 9%
136///   zstd -19 -T0      11298   8 MB    17%     <- SLOWER than xz for the ratio
137///   zstd -12 -T0        440  10 MB    21%     <- 19x faster than xz -6
138///   zstd -9  -T0        243  10 MB    22%     <- 34x faster
139/// ```
140///
141/// Two beliefs died there. "Just add `-T0` to xz" gains 9%, not the order of
142/// magnitude it promises — liblzma's block splitting barely engages at this
143/// size. And zstd is only faster at *lower* levels; at -19 it loses to xz on
144/// both axes. The knee is -12: 19x the speed for four percentage points of
145/// ratio.
146///
147/// That trade is obviously right HERE and the reason is architectural: this
148/// cache is a LOCAL origin serving a handful of fleet nodes over tailscale.
149/// Bandwidth is cheap; CPU-hours on the fleet's only x86_64-linux builder are
150/// not. MEASURED cost of the old default on rio 2026-08-05: a 2483-path
151/// closure spent FOUR HOURS in single-threaded xz, and because nix runs the
152/// post-build hook synchronously it blocked every build on that node — which
153/// is a different bug (fixed by detaching the hook) that this default made
154/// unsurvivable.
155///
156/// A mixed cache is fine and needs no migration: each narinfo declares its own
157/// codec, so paths already stored as `.nar.xz` keep resolving while new pushes
158/// land as `.nar.zst`.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
160pub enum NarCodec {
161    /// zstd at [`ZSTD_LEVEL`], multithreaded. The default.
162    #[default]
163    Zstd,
164    /// xz level 6 — what this cache used before 2026-08-05. Kept selectable
165    /// rather than deleted (★★ MODULARIZE, DON'T DELETE): it is still the
166    /// right choice for an origin that is bandwidth-bound rather than
167    /// CPU-bound, and it is what every already-stored path is packed with.
168    Xz,
169}
170
171/// The measured knee (see [`NarCodec`]): 19x faster than xz -6 for four
172/// percentage points of ratio. Not a round number chosen for looks.
173const ZSTD_LEVEL: i32 = 12;
174
175impl NarCodec {
176    /// The `Compression:` field value. **nix's wire vocabulary, not ours** —
177    /// verified 2026-08-05 by having nix write a zstd cache itself
178    /// (`nix copy --to 'file://…?compression=zstd'`) and reading back what it
179    /// emitted.
180    #[must_use]
181    pub fn narinfo_name(self) -> &'static str {
182        match self {
183            Self::Zstd => "zstd",
184            Self::Xz => "xz",
185        }
186    }
187
188    /// The NAR URL suffix.
189    ///
190    /// `.nar.zst`, NOT `.nar.zstd` — taken from nix's own output in the same
191    /// experiment above. Guessing here would have produced a cache whose URLs
192    /// no client resolves, and nothing in our own types would have objected.
193    #[must_use]
194    pub fn url_suffix(self) -> &'static str {
195        match self {
196            Self::Zstd => ".nar.zst",
197            Self::Xz => ".nar.xz",
198        }
199    }
200
201    /// Compress a NAR under this codec.
202    ///
203    /// zstd runs multithreaded across the machine's cores; `workers(0)` asks
204    /// the library for one worker per core. A failure to enable threading is
205    /// deliberately NOT fatal — it costs speed, never correctness, and a cache
206    /// push that refuses to run is worse than a slow one.
207    pub fn compress(self, data: &[u8]) -> Result<Vec<u8>, CacheError> {
208        match self {
209            Self::Zstd => {
210                let mut out = Vec::new();
211                let mut enc = zstd::Encoder::new(&mut out, ZSTD_LEVEL).map_err(CacheError::Io)?;
212                let _ = enc.multithread(
213                    u32::try_from(std::thread::available_parallelism().map_or(1, usize::from))
214                        .unwrap_or(1),
215                );
216                enc.write_all(data).map_err(CacheError::Io)?;
217                enc.finish().map_err(CacheError::Io)?;
218                Ok(out)
219            }
220            Self::Xz => {
221                let mut out = Vec::new();
222                let mut enc = xz2::write::XzEncoder::new(&mut out, 6);
223                enc.write_all(data).map_err(CacheError::Io)?;
224                enc.finish().map_err(CacheError::Io)?;
225                Ok(out)
226            }
227        }
228    }
229}
230
231/// Compute SHA-256 hash and return lowercase hex.
232fn sha256_hex(data: &[u8]) -> String {
233    let digest = Sha256::digest(data);
234    let mut s = String::with_capacity(64);
235    for b in digest.as_slice() {
236        use std::fmt::Write;
237        let _ = write!(s, "{b:02x}");
238    }
239    s
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::LocalStorage;
246    use crate::signing::CacheSigner;
247
248    #[tokio::test]
249    async fn push_single_file() {
250        let cache_dir = tempfile::tempdir().unwrap();
251        let storage = LocalStorage::new(cache_dir.path());
252        let signer = CacheSigner::generate("test-cache".to_string());
253
254        // Create a store path to push.
255        let store_dir = tempfile::tempdir().unwrap();
256        let fake_store = store_dir.path().join("nix/store/abc-hello-1.0");
257        std::fs::create_dir_all(&fake_store).unwrap();
258        std::fs::write(fake_store.join("hello.txt"), b"Hello world!").unwrap();
259
260        let result = push_path(
261            &storage,
262            &signer,
263            fake_store.to_str().unwrap(),
264            "abc",
265            &[],
266            None,
267        )
268        .await
269        .unwrap();
270
271        assert_eq!(result.hash, "abc");
272        assert!(result.nar_size > 0);
273        assert!(result.compressed_size > 0);
274
275        // Verify narinfo was uploaded.
276        let narinfo = storage.get_narinfo("abc").await.unwrap().unwrap();
277        let parsed = NarInfo::parse(&narinfo).unwrap();
278        // Derived from NarCodec::default(), never restated — asserting a
279        // literal here is what let the bytes and the narinfo drift apart in
280        // the first place.
281        assert_eq!(parsed.compression, NarCodec::default().narinfo_name());
282        assert_eq!(parsed.signatures.len(), 1);
283        assert!(parsed.signatures[0].starts_with("test-cache:"));
284
285        // Verify NAR blob was uploaded.
286        let nar_key = format!("nar/abc{}", NarCodec::default().url_suffix());
287        let nar = storage.get_nar(&nar_key).await.unwrap().unwrap();
288        assert!(!nar.is_empty());
289    }
290
291    #[tokio::test]
292    async fn push_nonexistent_path_errors() {
293        let dir = tempfile::tempdir().unwrap();
294        let storage = LocalStorage::new(dir.path());
295        let signer = CacheSigner::generate("k".to_string());
296
297        let result = push_path(
298            &storage,
299            &signer,
300            "/nix/store/does-not-exist-12345",
301            "nope",
302            &[],
303            None,
304        )
305        .await;
306
307        assert!(result.is_err());
308        assert!(matches!(result, Err(CacheError::PathNotFound(_))));
309    }
310
311    #[tokio::test]
312    async fn push_with_references() {
313        let cache_dir = tempfile::tempdir().unwrap();
314        let storage = LocalStorage::new(cache_dir.path());
315        let signer = CacheSigner::generate("k".to_string());
316
317        let store_dir = tempfile::tempdir().unwrap();
318        let path = store_dir.path().join("pkg");
319        std::fs::create_dir_all(&path).unwrap();
320        std::fs::write(path.join("file"), b"data").unwrap();
321
322        let refs = vec!["dep1-glibc".to_string(), "dep2-gcc".to_string()];
323        let result = push_path(
324            &storage,
325            &signer,
326            path.to_str().unwrap(),
327            "xyz",
328            &refs,
329            Some("builder.drv"),
330        )
331        .await
332        .unwrap();
333
334        assert_eq!(result.hash, "xyz");
335
336        let narinfo = storage.get_narinfo("xyz").await.unwrap().unwrap();
337        let parsed = NarInfo::parse(&narinfo).unwrap();
338        assert_eq!(parsed.references, refs);
339        assert_eq!(parsed.deriver, Some("builder.drv".to_string()));
340    }
341
342    #[tokio::test]
343    async fn pushed_narinfo_is_valid_and_verifiable() {
344        let cache_dir = tempfile::tempdir().unwrap();
345        let storage = LocalStorage::new(cache_dir.path());
346        let signer = CacheSigner::generate("verify-key".to_string());
347        let pk_str = signer.public_key_string();
348
349        let store_dir = tempfile::tempdir().unwrap();
350        let path = store_dir.path().join("test-pkg");
351        std::fs::create_dir_all(&path).unwrap();
352        std::fs::write(path.join("data"), b"test content").unwrap();
353
354        push_path(&storage, &signer, path.to_str().unwrap(), "ttt", &[], None)
355            .await
356            .unwrap();
357
358        let narinfo_text = storage.get_narinfo("ttt").await.unwrap().unwrap();
359        let parsed = NarInfo::parse(&narinfo_text).unwrap();
360
361        // Verify the signature.
362        let valid =
363            crate::signing::verify_narinfo_signature(&parsed, &parsed.signatures[0], &pk_str)
364                .unwrap();
365        assert!(valid);
366    }
367
368    #[test]
369    fn sha256_hex_produces_correct_output() {
370        // SHA-256 of empty string is well-known.
371        let hash = sha256_hex(b"");
372        assert_eq!(
373            hash,
374            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
375        );
376    }
377
378    #[test]
379    fn every_codec_round_trips() {
380        use std::io::Read;
381        let data = b"hello world, this is test data for NAR compression";
382
383        let xz = NarCodec::Xz.compress(data).unwrap();
384        let mut d = xz2::read::XzDecoder::new(xz.as_slice());
385        let mut out = Vec::new();
386        d.read_to_end(&mut out).unwrap();
387        assert_eq!(out, data, "xz must round-trip");
388
389        let z = NarCodec::Zstd.compress(data).unwrap();
390        let out = zstd::decode_all(z.as_slice()).unwrap();
391        assert_eq!(out, data, "zstd must round-trip");
392    }
393
394    // ── ★ THE INVARIANT THIS TYPE EXISTS FOR ────────────────────────────
395    // The codec used to be stated three times in `push_path` — the compress
396    // call, the `.nar.xz` URL literal, and `compression: "xz"`. A narinfo that
397    // disagrees with its bytes is not a cosmetic defect: every client fails to
398    // decompress, so the cache serves corruption while reporting success.
399    // These pin that the three can only ever come from one value.
400
401    #[test]
402    fn the_suffix_and_the_narinfo_name_agree_for_every_codec() {
403        for codec in [NarCodec::Zstd, NarCodec::Xz] {
404            let suffix = codec.url_suffix();
405            let name = codec.narinfo_name();
406            // `.nar.zst` carries `zstd`; `.nar.xz` carries `xz`. The suffix is
407            // nix's spelling, not ours — hence the explicit pairing rather
408            // than a string-derived assertion.
409            let expected_suffix = match name {
410                "zstd" => ".nar.zst",
411                "xz" => ".nar.xz",
412                other => panic!("unknown codec name {other} — add its suffix pairing"),
413            };
414            assert_eq!(
415                suffix, expected_suffix,
416                "codec {codec:?} would publish a URL its own Compression field \
417                 does not describe; every client would fail to decompress"
418            );
419        }
420    }
421
422    #[test]
423    fn the_narinfo_names_are_nix_wire_vocabulary() {
424        // Verified 2026-08-05 against nix itself: `nix copy --to
425        // 'file://…?compression=zstd'` emits `Compression: zstd` and
426        // `URL: nar/….nar.zst`. These are nix's spellings, not ours, so they
427        // are pinned rather than derived — `.nar.zstd` would have been the
428        // natural guess and is WRONG.
429        assert_eq!(NarCodec::Zstd.narinfo_name(), "zstd");
430        assert_eq!(NarCodec::Zstd.url_suffix(), ".nar.zst");
431        assert_eq!(NarCodec::Xz.narinfo_name(), "xz");
432        assert_eq!(NarCodec::Xz.url_suffix(), ".nar.xz");
433    }
434
435    #[test]
436    fn the_default_codec_is_the_fast_one() {
437        // The whole point of the change. If someone flips the default back to
438        // xz, they should have to edit this test and say why — a 2483-path
439        // closure cost FOUR HOURS under xz -6 on rio.
440        assert_eq!(NarCodec::default(), NarCodec::Zstd);
441    }
442}