use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use sui_compat::nar::{NarReader, NarWriter};
use sui_compat::store_path::nix_base32_encode;
const PATH_SAMPLE_SIZE: usize = 50;
fn online_mode() -> bool {
std::env::var("SUI_TEST_ONLINE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
fn nix_available(bin: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| dir.join(bin).is_file())
}
fn skip_if_offline(test: &str, needs: &[&str]) -> bool {
if !online_mode() {
eprintln!("skip {test}: SUI_TEST_ONLINE not set");
return true;
}
for bin in needs {
if !nix_available(bin) {
eprintln!("skip {test}: {bin} not on PATH");
return true;
}
}
false
}
fn nix_store_root() -> PathBuf {
std::env::var("NIX_STORE_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/nix/store"))
}
fn sample_store_paths(n: usize) -> Vec<PathBuf> {
let root = nix_store_root();
if !root.is_dir() {
return Vec::new();
}
let Ok(iter) = std::fs::read_dir(&root) else {
return Vec::new();
};
let mut out: Vec<PathBuf> = iter
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) != Some("drv"))
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| {
!n.starts_with('.')
&& n != ".links"
&& !n.starts_with(".db")
&& !n.starts_with("trash")
})
.unwrap_or(false)
})
.collect();
out.sort();
out.truncate(n);
out
}
fn nix_store_dump(path: &PathBuf) -> Option<Vec<u8>> {
let out = Command::new("nix-store")
.args(["--dump", &path.to_string_lossy()])
.stderr(Stdio::piped())
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(out.stdout)
}
fn nix_path_info_narhash(path: &PathBuf) -> Option<String> {
let out = Command::new("nix")
.args([
"path-info",
"--json",
"--json-format",
"1",
&path.to_string_lossy(),
])
.stderr(Stdio::piped())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let v: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
if let Some(map) = v.as_object() {
if let Some(entry) = map.values().next() {
if let Some(h) = entry.get("narHash").and_then(|h| h.as_str()) {
return Some(h.to_string());
}
}
}
if let Some(arr) = v.as_array() {
if let Some(entry) = arr.first() {
if let Some(h) = entry.get("narHash").and_then(|h| h.as_str()) {
return Some(h.to_string());
}
}
}
None
}
#[test]
fn nar_reader_accepts_all_real_store_dumps() {
if skip_if_offline("nar_reader_accepts_all_real_store_dumps", &["nix-store"]) {
return;
}
let corpus = sample_store_paths(PATH_SAMPLE_SIZE);
if corpus.is_empty() {
eprintln!("skip: no sample store paths");
return;
}
let mut parsed = 0usize;
let mut failures: Vec<(PathBuf, String)> = Vec::new();
for path in &corpus {
let Some(bytes) = nix_store_dump(path) else {
failures.push((path.clone(), "nix-store --dump failed".to_string()));
continue;
};
let mut cursor = std::io::Cursor::new(bytes.as_slice());
match NarReader::read_complete(&mut cursor) {
Ok(_) => parsed += 1,
Err(e) => failures.push((path.clone(), format!("{e}"))),
}
}
eprintln!(
"nar_reader_accepts_all: parsed {parsed} / {} (failures {})",
corpus.len(),
failures.len()
);
if !failures.is_empty() {
let summary: String = failures
.iter()
.take(5)
.map(|(p, why)| format!(" {}\n {why}", p.display()))
.collect::<Vec<_>>()
.join("\n");
panic!(
"{} / {} NAR dumps failed to parse. First {}:\n{}",
failures.len(),
corpus.len(),
failures.len().min(5),
summary
);
}
}
#[test]
fn nar_round_trip_is_byte_identical() {
if skip_if_offline("nar_round_trip_is_byte_identical", &["nix-store"]) {
return;
}
let corpus = sample_store_paths(PATH_SAMPLE_SIZE);
if corpus.is_empty() {
eprintln!("skip: no sample store paths");
return;
}
let mut matched = 0usize;
let mut failures: Vec<(PathBuf, String)> = Vec::new();
for path in &corpus {
let Some(bytes) = nix_store_dump(path) else {
continue;
};
let mut cursor = std::io::Cursor::new(bytes.as_slice());
let tree = match NarReader::read_complete(&mut cursor) {
Ok(t) => t,
Err(_) => continue,
};
let mut rewritten = Vec::with_capacity(bytes.len());
if let Err(e) = NarWriter::write(&mut rewritten, &tree) {
failures.push((path.clone(), format!("write: {e}")));
continue;
}
if rewritten != bytes {
failures.push((
path.clone(),
format!("len orig={} rewritten={}", bytes.len(), rewritten.len()),
));
continue;
}
matched += 1;
}
eprintln!(
"nar_round_trip_is_byte_identical: {matched} / {} matched, {} mismatches",
corpus.len(),
failures.len()
);
if !failures.is_empty() {
let summary: String = failures
.iter()
.take(5)
.map(|(p, why)| format!(" {}\n {why}", p.display()))
.collect::<Vec<_>>()
.join("\n");
panic!(
"{} / {} NAR round-trip mismatches. First {}:\n{}",
failures.len(),
corpus.len(),
failures.len().min(5),
summary
);
}
}
#[test]
fn nar_hash_matches_nix_path_info() {
if skip_if_offline(
"nar_hash_matches_nix_path_info",
&["nix-store", "nix"],
) {
return;
}
let corpus = sample_store_paths(PATH_SAMPLE_SIZE);
if corpus.is_empty() {
eprintln!("skip: no sample store paths");
return;
}
let mut matched = 0usize;
let mut failures: Vec<(PathBuf, String)> = Vec::new();
for path in &corpus {
let Some(bytes) = nix_store_dump(path) else {
continue;
};
let Some(expected_narhash) = nix_path_info_narhash(path) else {
continue;
};
let digest = Sha256::digest(&bytes);
let ours = format!("sha256:{}", nix_base32_encode(&digest));
if ours == expected_narhash {
matched += 1;
continue;
}
if expected_narhash.starts_with("sha256-") {
use base64::Engine;
let sri = format!(
"sha256-{}",
base64::engine::general_purpose::STANDARD.encode(digest)
);
if sri == expected_narhash {
matched += 1;
continue;
}
}
failures.push((
path.clone(),
format!("expected {expected_narhash}, computed {ours}"),
));
}
eprintln!(
"nar_hash_matches: {matched} / {} matched, {} mismatches",
corpus.len(),
failures.len()
);
let tolerated = corpus.len() / 20; if failures.len() > tolerated.max(1) {
let summary: String = failures
.iter()
.take(5)
.map(|(p, why)| format!(" {}\n {why}", p.display()))
.collect::<Vec<_>>()
.join("\n");
panic!(
"{} nar-hash mismatches (> {} tolerated). First {}:\n{}",
failures.len(),
tolerated.max(1),
failures.len().min(5),
summary
);
}
}