1use 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#[derive(Debug, Clone)]
20pub struct PushResult {
21 pub hash: String,
23 pub compressed_size: u64,
25 pub nar_size: u64,
27}
28
29pub 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 let nar_data = dump_path_to_nar(path)?;
58
59 let nar_hash = sha256_hex(&nar_data);
61 let nar_size = nar_data.len() as u64;
62
63 let codec = NarCodec::default();
66 let compressed = codec.compress(&nar_data)?;
67 let compressed_size = compressed.len() as u64;
68
69 let file_hash = sha256_hex(&compressed);
71
72 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 let sig = signer.sign_narinfo(&narinfo);
90 let narinfo = NarInfo {
91 signatures: vec![sig],
92 ..narinfo
93 };
94
95 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
106fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
160pub enum NarCodec {
161 #[default]
163 Zstd,
164 Xz,
169}
170
171const ZSTD_LEVEL: i32 = 12;
174
175impl NarCodec {
176 #[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 #[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 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
231fn 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 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 let narinfo = storage.get_narinfo("abc").await.unwrap().unwrap();
277 let parsed = NarInfo::parse(&narinfo).unwrap();
278 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 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 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 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 #[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 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 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 assert_eq!(NarCodec::default(), NarCodec::Zstd);
441 }
442}