use std::fmt;
use std::io::Write;
use std::path::Path;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sui_compat::nar::NarWriter;
use sui_compat::narinfo::NarInfo;
use crate::CacheError;
use crate::StorageBackend;
use crate::signing::CacheSigner;
#[derive(Debug, Clone)]
pub struct PushResult {
pub hash: String,
pub compressed_size: u64,
pub nar_size: u64,
}
pub async fn push_path(
storage: &dyn StorageBackend,
signer: &CacheSigner,
store_path: &str,
hash: &str,
references: &[String],
deriver: Option<&str>,
codec: NarCodec,
) -> Result<PushResult, CacheError> {
let path = Path::new(store_path);
if !path.exists() {
return Err(CacheError::PathNotFound(store_path.to_string()));
}
let nar_data = dump_path_to_nar(path)?;
let nar_hash = sha256_hex(&nar_data);
let nar_size = nar_data.len() as u64;
let compressed = codec.compress(&nar_data)?;
let compressed_size = compressed.len() as u64;
let file_hash = sha256_hex(&compressed);
let nar_url = format!("nar/{hash}{suffix}", suffix = codec.url_suffix());
let narinfo = NarInfo {
store_path: store_path.to_string(),
url: nar_url.clone(),
compression: codec.narinfo_name().to_string(),
file_hash: format!("sha256:{file_hash}"),
file_size: compressed_size,
nar_hash: format!("sha256:{nar_hash}"),
nar_size,
references: references.to_vec(),
deriver: deriver.map(String::from),
signatures: vec![],
ca: None,
};
let sig = signer.sign_narinfo(&narinfo);
let narinfo = NarInfo {
signatures: vec![sig],
..narinfo
};
storage.put_nar(&nar_url, &compressed).await?;
storage.put_narinfo(hash, &narinfo.serialize()).await?;
Ok(PushResult {
hash: hash.to_string(),
compressed_size,
nar_size,
})
}
fn dump_path_to_nar(path: &Path) -> Result<Vec<u8>, CacheError> {
let mut buf = Vec::new();
NarWriter::write_path(&mut buf, path)
.map_err(|e| CacheError::Io(std::io::Error::other(format!("NAR dump failed: {e}"))))?;
Ok(buf)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "codec", rename_all = "lowercase")]
pub enum NarCodec {
Zstd {
#[serde(default)]
level: ZstdLevel,
},
Xz {
#[serde(default)]
level: XzLevel,
},
}
impl Default for NarCodec {
fn default() -> Self {
Self::Zstd {
level: ZstdLevel::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LevelOutOfRange {
pub codec: &'static str,
pub got: i64,
pub min: i64,
pub max: i64,
}
impl fmt::Display for LevelOutOfRange {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} level {} is outside the accepted range {}..={}",
self.codec, self.got, self.min, self.max
)
}
}
impl std::error::Error for LevelOutOfRange {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "i32", into = "i32")]
pub struct ZstdLevel(i32);
impl ZstdLevel {
pub const MIN: i32 = 1;
pub const MAX: i32 = 22;
pub const PRESCRIBED: i32 = 12;
pub const fn new(level: i32) -> Result<Self, LevelOutOfRange> {
if level < Self::MIN || level > Self::MAX {
return Err(LevelOutOfRange {
codec: "zstd",
got: level as i64,
min: Self::MIN as i64,
max: Self::MAX as i64,
});
}
Ok(Self(level))
}
#[must_use]
pub const fn get(self) -> i32 {
self.0
}
}
impl Default for ZstdLevel {
fn default() -> Self {
Self(Self::PRESCRIBED)
}
}
impl TryFrom<i32> for ZstdLevel {
type Error = LevelOutOfRange;
fn try_from(v: i32) -> Result<Self, Self::Error> {
Self::new(v)
}
}
impl From<ZstdLevel> for i32 {
fn from(v: ZstdLevel) -> Self {
v.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "u32", into = "u32")]
pub struct XzLevel(u32);
impl XzLevel {
pub const MIN: u32 = 0;
pub const MAX: u32 = 9;
pub const PRESCRIBED: u32 = 6;
pub const fn new(level: u32) -> Result<Self, LevelOutOfRange> {
if level > Self::MAX {
return Err(LevelOutOfRange {
codec: "xz",
got: level as i64,
min: Self::MIN as i64,
max: Self::MAX as i64,
});
}
Ok(Self(level))
}
#[must_use]
pub const fn get(self) -> u32 {
self.0
}
}
impl Default for XzLevel {
fn default() -> Self {
Self(Self::PRESCRIBED)
}
}
impl TryFrom<u32> for XzLevel {
type Error = LevelOutOfRange;
fn try_from(v: u32) -> Result<Self, Self::Error> {
Self::new(v)
}
}
impl From<XzLevel> for u32 {
fn from(v: XzLevel) -> Self {
v.0
}
}
impl NarCodec {
#[must_use]
pub fn narinfo_name(self) -> &'static str {
match self {
Self::Zstd { .. } => "zstd",
Self::Xz { .. } => "xz",
}
}
#[must_use]
pub fn url_suffix(self) -> &'static str {
match self {
Self::Zstd { .. } => ".nar.zst",
Self::Xz { .. } => ".nar.xz",
}
}
pub fn compress(self, data: &[u8]) -> Result<Vec<u8>, CacheError> {
match self {
Self::Zstd { level } => {
let mut out = Vec::new();
let mut enc = zstd::Encoder::new(&mut out, level.get()).map_err(CacheError::Io)?;
let _ = enc.multithread(
u32::try_from(std::thread::available_parallelism().map_or(1, usize::from))
.unwrap_or(1),
);
enc.write_all(data).map_err(CacheError::Io)?;
enc.finish().map_err(CacheError::Io)?;
Ok(out)
}
Self::Xz { level } => {
let mut out = Vec::new();
let mut enc = xz2::write::XzEncoder::new(&mut out, level.get());
enc.write_all(data).map_err(CacheError::Io)?;
enc.finish().map_err(CacheError::Io)?;
Ok(out)
}
}
}
pub fn decompress(self, data: &[u8]) -> Result<Vec<u8>, CacheError> {
use std::io::Read;
let mut out = Vec::new();
match self {
Self::Zstd { .. } => {
zstd::Decoder::new(data)
.map_err(CacheError::Io)?
.read_to_end(&mut out)
.map_err(CacheError::Io)?;
}
Self::Xz { .. } => {
xz2::read::XzDecoder::new(data)
.read_to_end(&mut out)
.map_err(CacheError::Io)?;
}
}
Ok(out)
}
#[must_use]
pub fn from_narinfo_name(name: &str) -> Option<Self> {
match name {
"zstd" => Some(Self::Zstd {
level: ZstdLevel::default(),
}),
"xz" => Some(Self::Xz {
level: XzLevel::default(),
}),
_ => None,
}
}
}
fn sha256_hex(data: &[u8]) -> String {
let digest = Sha256::digest(data);
let mut s = String::with_capacity(64);
for b in digest.as_slice() {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::LocalStorage;
use crate::signing::CacheSigner;
#[tokio::test]
async fn push_single_file() {
let cache_dir = tempfile::tempdir().unwrap();
let storage = LocalStorage::new(cache_dir.path());
let signer = CacheSigner::generate("test-cache".to_string());
let store_dir = tempfile::tempdir().unwrap();
let fake_store = store_dir.path().join("nix/store/abc-hello-1.0");
std::fs::create_dir_all(&fake_store).unwrap();
std::fs::write(fake_store.join("hello.txt"), b"Hello world!").unwrap();
let result = push_path(
&storage,
&signer,
fake_store.to_str().unwrap(),
"abc",
&[],
None,
NarCodec::default(),
)
.await
.unwrap();
assert_eq!(result.hash, "abc");
assert!(result.nar_size > 0);
assert!(result.compressed_size > 0);
let narinfo = storage.get_narinfo("abc").await.unwrap().unwrap();
let parsed = NarInfo::parse(&narinfo).unwrap();
assert_eq!(parsed.compression, NarCodec::default().narinfo_name());
assert_eq!(parsed.signatures.len(), 1);
assert!(parsed.signatures[0].starts_with("test-cache:"));
let nar_key = format!("nar/abc{}", NarCodec::default().url_suffix());
let nar = storage.get_nar(&nar_key).await.unwrap().unwrap();
assert!(!nar.is_empty());
}
#[tokio::test]
async fn push_nonexistent_path_errors() {
let dir = tempfile::tempdir().unwrap();
let storage = LocalStorage::new(dir.path());
let signer = CacheSigner::generate("k".to_string());
let result = push_path(
&storage,
&signer,
"/nix/store/does-not-exist-12345",
"nope",
&[],
None,
NarCodec::default(),
)
.await;
assert!(result.is_err());
assert!(matches!(result, Err(CacheError::PathNotFound(_))));
}
#[tokio::test]
async fn push_with_references() {
let cache_dir = tempfile::tempdir().unwrap();
let storage = LocalStorage::new(cache_dir.path());
let signer = CacheSigner::generate("k".to_string());
let store_dir = tempfile::tempdir().unwrap();
let path = store_dir.path().join("pkg");
std::fs::create_dir_all(&path).unwrap();
std::fs::write(path.join("file"), b"data").unwrap();
let refs = vec!["dep1-glibc".to_string(), "dep2-gcc".to_string()];
let result = push_path(
&storage,
&signer,
path.to_str().unwrap(),
"xyz",
&refs,
Some("builder.drv"),
NarCodec::default(),
)
.await
.unwrap();
assert_eq!(result.hash, "xyz");
let narinfo = storage.get_narinfo("xyz").await.unwrap().unwrap();
let parsed = NarInfo::parse(&narinfo).unwrap();
assert_eq!(parsed.references, refs);
assert_eq!(parsed.deriver, Some("builder.drv".to_string()));
}
#[tokio::test]
async fn pushed_narinfo_is_valid_and_verifiable() {
let cache_dir = tempfile::tempdir().unwrap();
let storage = LocalStorage::new(cache_dir.path());
let signer = CacheSigner::generate("verify-key".to_string());
let pk_str = signer.public_key_string();
let store_dir = tempfile::tempdir().unwrap();
let path = store_dir.path().join("test-pkg");
std::fs::create_dir_all(&path).unwrap();
std::fs::write(path.join("data"), b"test content").unwrap();
push_path(
&storage,
&signer,
path.to_str().unwrap(),
"ttt",
&[],
None,
NarCodec::default(),
)
.await
.unwrap();
let narinfo_text = storage.get_narinfo("ttt").await.unwrap().unwrap();
let parsed = NarInfo::parse(&narinfo_text).unwrap();
let valid =
crate::signing::verify_narinfo_signature(&parsed, &parsed.signatures[0], &pk_str)
.unwrap();
assert!(valid);
}
#[test]
fn sha256_hex_produces_correct_output() {
let hash = sha256_hex(b"");
assert_eq!(
hash,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
fn codecs() -> Vec<NarCodec> {
let all = [
NarCodec::Zstd {
level: ZstdLevel::default(),
},
NarCodec::Xz {
level: XzLevel::default(),
},
];
for c in all {
match c {
NarCodec::Zstd { .. } | NarCodec::Xz { .. } => {}
}
}
all.to_vec()
}
#[test]
fn every_codec_round_trips() {
use std::io::Read;
let data = b"hello world, this is test data for NAR compression";
let xz = NarCodec::Xz {
level: XzLevel::default(),
}
.compress(data)
.unwrap();
let mut d = xz2::read::XzDecoder::new(xz.as_slice());
let mut out = Vec::new();
d.read_to_end(&mut out).unwrap();
assert_eq!(out, data, "xz must round-trip");
let z = NarCodec::Zstd {
level: ZstdLevel::default(),
}
.compress(data)
.unwrap();
let out = zstd::decode_all(z.as_slice()).unwrap();
assert_eq!(out, data, "zstd must round-trip");
}
#[test]
fn every_configurable_level_round_trips_under_its_own_codec() {
let data = b"hello world, this is test data for NAR compression".repeat(64);
for level in [ZstdLevel::MIN, ZstdLevel::PRESCRIBED, 19, ZstdLevel::MAX] {
let codec = NarCodec::Zstd {
level: ZstdLevel::new(level).unwrap(),
};
assert_eq!(
codec.decompress(&codec.compress(&data).unwrap()).unwrap(),
data
);
}
for level in [XzLevel::MIN, XzLevel::PRESCRIBED, XzLevel::MAX] {
let codec = NarCodec::Xz {
level: XzLevel::new(level).unwrap(),
};
assert_eq!(
codec.decompress(&codec.compress(&data).unwrap()).unwrap(),
data
);
}
}
#[test]
fn an_out_of_band_level_is_rejected_where_it_is_built() {
assert!(XzLevel::new(10).is_err(), "xz preset 10 panics xz2");
assert!(XzLevel::new(u32::MAX).is_err());
assert!(ZstdLevel::new(0).is_err());
assert!(ZstdLevel::new(23).is_err());
assert!(ZstdLevel::new(-5).is_err(), "unmeasured ultra-fast band");
let bad = r#"{ "codec": "xz", "level": 10 }"#;
assert!(
serde_json::from_str::<NarCodec>(bad).is_err(),
"a config naming an impossible level must fail to parse"
);
let good = r#"{ "codec": "xz", "level": 9 }"#;
assert_eq!(
serde_json::from_str::<NarCodec>(good).unwrap(),
NarCodec::Xz {
level: XzLevel::new(9).unwrap()
}
);
}
#[test]
fn a_codec_without_a_level_takes_its_own_prescribed_one() {
assert_eq!(
serde_json::from_str::<NarCodec>(r#"{ "codec": "zstd" }"#).unwrap(),
NarCodec::Zstd {
level: ZstdLevel::new(12).unwrap()
}
);
assert_eq!(
serde_json::from_str::<NarCodec>(r#"{ "codec": "xz" }"#).unwrap(),
NarCodec::Xz {
level: XzLevel::new(6).unwrap()
}
);
}
#[test]
fn the_suffix_and_the_narinfo_name_agree_for_every_codec() {
for codec in codecs() {
let suffix = codec.url_suffix();
let name = codec.narinfo_name();
let expected_suffix = match name {
"zstd" => ".nar.zst",
"xz" => ".nar.xz",
other => panic!("unknown codec name {other} — add its suffix pairing"),
};
assert_eq!(
suffix, expected_suffix,
"codec {codec:?} would publish a URL its own Compression field \
does not describe; every client would fail to decompress"
);
}
}
#[test]
fn the_narinfo_names_are_nix_wire_vocabulary() {
for level in [ZstdLevel::MIN, ZstdLevel::PRESCRIBED, ZstdLevel::MAX] {
let c = NarCodec::Zstd {
level: ZstdLevel::new(level).unwrap(),
};
assert_eq!(c.narinfo_name(), "zstd");
assert_eq!(c.url_suffix(), ".nar.zst");
}
for level in [XzLevel::MIN, XzLevel::PRESCRIBED, XzLevel::MAX] {
let c = NarCodec::Xz {
level: XzLevel::new(level).unwrap(),
};
assert_eq!(c.narinfo_name(), "xz");
assert_eq!(c.url_suffix(), ".nar.xz");
}
}
#[test]
fn the_default_codec_is_the_fast_one() {
assert_eq!(
NarCodec::default(),
NarCodec::Zstd {
level: ZstdLevel::new(ZstdLevel::PRESCRIBED).unwrap()
}
);
assert_eq!(
crate::CacheConfig::default().nar_codec,
NarCodec::default(),
"CacheConfig::default() must not describe a different cache"
);
assert_eq!(
<crate::CacheConfig as shikumi::TieredConfig>::prescribed_default().nar_codec,
NarCodec::default(),
"an operator who configures nothing must get the measured fast path"
);
}
#[tokio::test]
async fn a_configured_non_default_codec_still_agrees_end_to_end() {
for codec in codecs() {
assert_ne!(
codec.narinfo_name(),
"",
"every codec must name itself on the wire"
);
let cache_dir = tempfile::tempdir().unwrap();
let storage = LocalStorage::new(cache_dir.path());
let signer = CacheSigner::generate("cfg-key".to_string());
let store_dir = tempfile::tempdir().unwrap();
let path = store_dir.path().join("cfg-pkg");
std::fs::create_dir_all(&path).unwrap();
std::fs::write(
path.join("payload"),
b"configured-codec payload".repeat(512),
)
.unwrap();
push_path(
&storage,
&signer,
path.to_str().unwrap(),
"cfg",
&[],
None,
codec,
)
.await
.unwrap();
let parsed =
NarInfo::parse(&storage.get_narinfo("cfg").await.unwrap().unwrap()).unwrap();
let declared = NarCodec::from_narinfo_name(&parsed.compression)
.unwrap_or_else(|| panic!("unresolvable Compression: {}", parsed.compression));
assert!(
parsed.url.ends_with(declared.url_suffix()),
"narinfo for {codec:?} publishes URL {} under Compression {} — \
the suffix and the field disagree",
parsed.url,
parsed.compression
);
let blob = storage
.get_nar(&parsed.url)
.await
.unwrap()
.unwrap_or_else(|| panic!("no NAR stored at the advertised URL {}", parsed.url));
let plain = declared.decompress(&blob).unwrap_or_else(|e| {
panic!(
"narinfo declares {} but the bytes do not decode as it: {e}",
parsed.compression
)
});
assert_eq!(
parsed.nar_hash,
format!("sha256:{}", sha256_hex(&plain)),
"decoded bytes do not match the NarHash the narinfo advertises"
);
assert_eq!(parsed.nar_size, plain.len() as u64);
assert_eq!(
parsed.file_hash,
format!("sha256:{}", sha256_hex(&blob)),
"FileHash does not describe the stored blob"
);
}
}
#[tokio::test]
async fn the_codec_a_config_names_is_the_codec_a_push_uses() {
use shikumi::{ConfigTier, TieredConfig};
let cfg_dir = tempfile::tempdir().unwrap();
let cfg_path = cfg_dir.path().join("cache.yaml");
std::fs::write(&cfg_path, "nar_codec:\n codec: xz\n level: 1\n").unwrap();
let configured = crate::CacheConfig::resolve_tier(ConfigTier::Custom(cfg_path)).nar_codec;
assert_eq!(
configured,
NarCodec::Xz {
level: XzLevel::new(1).unwrap()
},
"the YAML overlay did not reach the codec field"
);
let cache_dir = tempfile::tempdir().unwrap();
let storage = LocalStorage::new(cache_dir.path());
let signer = CacheSigner::generate("cfg-key".to_string());
let store_dir = tempfile::tempdir().unwrap();
let path = store_dir.path().join("pkg");
std::fs::create_dir_all(&path).unwrap();
std::fs::write(path.join("f"), b"data").unwrap();
push_path(
&storage,
&signer,
path.to_str().unwrap(),
"cfgd",
&[],
None,
configured,
)
.await
.unwrap();
let parsed = NarInfo::parse(&storage.get_narinfo("cfgd").await.unwrap().unwrap()).unwrap();
assert_eq!(parsed.compression, "xz");
assert_eq!(parsed.url, "nar/cfgd.nar.xz");
assert_ne!(configured, NarCodec::default());
assert_ne!(parsed.compression, NarCodec::default().narinfo_name());
}
}