use std::collections::{BTreeSet, HashMap};
use std::process::{Command, Stdio};
use std::time::Duration;
use base64::Engine as _;
use sha2::{Digest, Sha256};
use crate::follows::{AttrPath, Segment, join_raw};
use crate::lock::{FlakeLock, NestedInput};
const PATH_INFO_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Default)]
pub struct InputSizes {
store_paths: HashMap<String, String>,
sizes: HashMap<String, u64>,
}
impl InputSizes {
pub fn query(lock: &FlakeLock, nested_inputs: &[NestedInput]) -> Self {
Self::query_with(lock, nested_inputs, query_path_sizes)
}
fn query_with(
lock: &FlakeLock,
nested_inputs: &[NestedInput],
size_lookup: impl FnOnce(&[&str]) -> HashMap<String, u64>,
) -> Self {
let store_dir = store_dir();
let mut store_paths: HashMap<String, String> = HashMap::new();
for (attr, node_key) in attr_to_node_map(lock, nested_inputs) {
let nar_hash = lock
.nodes
.get(&node_key)
.and_then(|node| node.locked.as_ref())
.and_then(|locked| locked.nar_hash.as_deref());
let Some(store_path) =
nar_hash.and_then(|hash| store_path_from_nar_hash(&store_dir, hash))
else {
continue;
};
store_paths.insert(attr, store_path);
}
let unique: BTreeSet<&str> = store_paths.values().map(String::as_str).collect();
let sizes = if unique.is_empty() {
HashMap::new()
} else {
let paths: Vec<&str> = unique.into_iter().collect();
size_lookup(&paths)
};
Self { store_paths, sizes }
}
pub fn from_attr_entries(
entries: impl IntoIterator<Item = (String, String, Option<u64>)>,
) -> Self {
let mut store_paths = HashMap::new();
let mut sizes = HashMap::new();
for (attr, store_path, size) in entries {
if let Some(size) = size {
sizes.insert(store_path.clone(), size);
}
store_paths.insert(attr, store_path);
}
Self { store_paths, sizes }
}
pub fn size_for(&self, path: &AttrPath) -> Option<u64> {
self.sizes.get(self.store_path_for(path)?).copied()
}
pub fn store_path_for(&self, path: &AttrPath) -> Option<&str> {
self.store_paths.get(&dotted(path)).map(String::as_str)
}
pub fn label_for_store_path(&self, store_path: &str) -> Option<&str> {
self.store_paths
.iter()
.filter(|(_, sp)| sp.as_str() == store_path)
.map(|(attr, _)| attr.as_str())
.min_by_key(|attr| (attr.matches('.').count(), attr.len(), *attr))
}
}
fn dotted(path: &AttrPath) -> String {
join_raw(path.segments())
}
fn store_dir() -> String {
std::env::var("NIX_STORE_DIR").unwrap_or_else(|_| "/nix/store".to_string())
}
fn attr_to_node_map(lock: &FlakeLock, nested_inputs: &[NestedInput]) -> HashMap<String, String> {
let mut out = HashMap::new();
if let Some(root_inputs) = lock
.nodes
.get(&lock.root)
.and_then(|node| node.inputs.as_ref())
{
for name in root_inputs.keys() {
let Ok(seg) = Segment::from_unquoted(name.clone()) else {
continue;
};
if let Ok(key) = lock.resolve_input_path(&AttrPath::new(seg)) {
out.insert(name.clone(), key);
}
}
}
for nested in nested_inputs {
if let Ok(key) = lock.resolve_input_path(&nested.path) {
out.insert(dotted(&nested.path), key);
}
}
out
}
fn store_path_from_nar_hash(store_dir: &str, nar_hash: &str) -> Option<String> {
const HEX: &[u8; 16] = b"0123456789abcdef";
let encoded = nar_hash.strip_prefix("sha256-")?;
let raw = base64::engine::general_purpose::STANDARD
.decode(encoded)
.ok()?;
if raw.len() != 32 {
return None;
}
let mut hex = String::with_capacity(64);
for byte in &raw {
hex.push(HEX[(byte >> 4) as usize] as char);
hex.push(HEX[(byte & 0x0f) as usize] as char);
}
let fingerprint = format!("source:sha256:{hex}:{store_dir}:source");
let digest = Sha256::digest(fingerprint.as_bytes());
let mut folded = [0u8; 20];
for (i, byte) in digest.iter().enumerate() {
folded[i % 20] ^= byte;
}
Some(format!("{store_dir}/{}-source", nix_base32(&folded)))
}
fn nix_base32(bytes: &[u8; 20]) -> String {
const ALPHABET: &[u8; 32] = b"0123456789abcdfghijklmnpqrsvwxyz";
let mut out = String::with_capacity(32);
for n in (0..32).rev() {
let bit = n * 5;
let byte = bit / 8;
let shift = bit % 8;
let mut value = bytes[byte] >> shift;
if shift > 0 && byte + 1 < bytes.len() {
value |= bytes[byte + 1] << (8 - shift);
}
out.push(ALPHABET[(value & 0x1f) as usize] as char);
}
out
}
fn query_path_sizes(paths: &[&str]) -> HashMap<String, u64> {
let mut args: Vec<&str> = vec![
"path-info",
"--json",
"--extra-experimental-features",
"nix-command",
];
args.extend(paths);
let Some(raw) = run_nix(&args, PATH_INFO_TIMEOUT) else {
return HashMap::new();
};
serde_json::from_slice::<serde_json::Value>(&raw)
.map(|json| parse_path_info(&json))
.unwrap_or_default()
}
fn parse_path_info(json: &serde_json::Value) -> HashMap<String, u64> {
let mut out = HashMap::new();
if let Some(entries) = json.as_array() {
for entry in entries {
if let (Some(path), Some(size)) = (
entry.get("path").and_then(|v| v.as_str()),
entry.get("narSize").and_then(|v| v.as_u64()),
) {
out.insert(path.to_string(), size);
}
}
} else if let Some(entries) = json.as_object() {
for (path, entry) in entries {
if let Some(size) = entry.get("narSize").and_then(|v| v.as_u64()) {
out.insert(path.clone(), size);
}
}
}
out
}
fn run_nix(args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
use std::io::Read;
let mut child = Command::new("nix")
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.ok()?;
let mut stdout_pipe = child.stdout.take()?;
let stdout_reader = std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = stdout_pipe.read_to_end(&mut buf);
buf
});
let mut stderr_pipe = child.stderr.take()?;
let stderr_reader = std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = stderr_pipe.read_to_end(&mut buf);
buf
});
let start = std::time::Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
tracing::debug!("nix {} timed out after {:?}", args.join(" "), timeout);
return None;
}
std::thread::sleep(Duration::from_millis(50));
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
};
let stdout = stdout_reader.join().unwrap_or_default();
if status.success() {
Some(stdout)
} else {
let stderr = stderr_reader.join().unwrap_or_default();
tracing::debug!(
"nix {} failed ({status}): {}",
args.join(" "),
String::from_utf8_lossy(&stderr).trim(),
);
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn store_path_from_nar_hash_matches_nix() {
assert_eq!(
store_path_from_nar_hash(
"/nix/store",
"sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
)
.as_deref(),
Some("/nix/store/3a2vdn5i7vd2wl654xs8nb52jf1v6cbh-source"),
);
}
#[test]
fn store_path_from_nar_hash_rejects_non_sri_sha256() {
for hash in [
"",
"not-a-hash",
"sha512-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
"sha256-@@@invalid@@@",
"sha256-YWJj",
] {
assert_eq!(
store_path_from_nar_hash("/nix/store", hash),
None,
"hash {hash:?} must not produce a store path",
);
}
}
#[test]
fn parse_path_info_handles_array_shape() {
let json: serde_json::Value = serde_json::from_str(
r#"[
{"path": "/nix/store/a", "narSize": 100},
{"path": "/nix/store/b", "narSize": 200}
]"#,
)
.unwrap();
let map = parse_path_info(&json);
assert_eq!(map.get("/nix/store/a"), Some(&100));
assert_eq!(map.get("/nix/store/b"), Some(&200));
}
#[test]
fn parse_path_info_handles_object_shape() {
let json: serde_json::Value = serde_json::from_str(
r#"{
"/nix/store/a": {"narSize": 100},
"/nix/store/b": {"narSize": 200}
}"#,
)
.unwrap();
let map = parse_path_info(&json);
assert_eq!(map.get("/nix/store/a"), Some(&100));
assert_eq!(map.get("/nix/store/b"), Some(&200));
}
#[test]
fn query_maps_follows_chain_to_shared_store_path() {
let lock = FlakeLock::read_from_str(
r#"{
"nodes": {
"nixpkgs": {
"locked": {
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
"owner": "nixos", "repo": "nixpkgs", "rev": "aaa", "type": "github"
},
"original": { "owner": "nixos", "repo": "nixpkgs", "type": "github" }
},
"home-manager": {
"inputs": { "nixpkgs": ["nixpkgs"] },
"locked": {
"narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"owner": "o", "repo": "r", "rev": "bbb", "type": "github"
},
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": {
"inputs": { "nixpkgs": "nixpkgs", "home-manager": "home-manager" }
}
},
"root": "root",
"version": 7
}"#,
)
.expect("lock parses");
let nested = lock.nested_inputs();
let nixpkgs_path = "/nix/store/3a2vdn5i7vd2wl654xs8nb52jf1v6cbh-source";
let sizes = InputSizes::query_with(&lock, &nested, |paths| {
assert!(
paths.contains(&nixpkgs_path),
"computed store path must reach the size lookup, got {paths:?}",
);
paths.iter().map(|p| ((*p).to_string(), 42)).collect()
});
let top: AttrPath = "nixpkgs".parse().unwrap();
let follower: AttrPath = "home-manager.nixpkgs".parse().unwrap();
assert_eq!(sizes.store_path_for(&top), Some(nixpkgs_path));
assert_eq!(
sizes.store_path_for(&follower),
sizes.store_path_for(&top),
"follows chain must land on the target's store path",
);
assert_eq!(sizes.size_for(&follower), Some(42));
assert_eq!(
sizes.label_for_store_path(nixpkgs_path),
Some("nixpkgs"),
"shortest attribute path must label the shared store path",
);
}
#[test]
fn from_attr_entries_shares_sizes_via_store_path() {
let sizes = InputSizes::from_attr_entries([
("nixpkgs".to_string(), "/store/a".to_string(), Some(7)),
("crane.nixpkgs".to_string(), "/store/a".to_string(), None),
("crane".to_string(), "/store/b".to_string(), None),
]);
let follower: AttrPath = "crane.nixpkgs".parse().unwrap();
let unknown: AttrPath = "crane".parse().unwrap();
assert_eq!(sizes.size_for(&follower), Some(7));
assert_eq!(sizes.size_for(&unknown), None);
assert_eq!(sizes.label_for_store_path("/store/a"), Some("nixpkgs"));
}
}