crates/gunnar-recipe/build.rs
//! **The recipe's own content hash**, computed over its own sources.
//!
//! A receipt addresses its recipe as `krate:version:hash`. The version says
//! which recipe was *intended*; only the hash says which bytes actually ran, and
//! the two diverge the day someone rebuilds a version without bumping it. That
//! day is exactly when the field is needed and far too late to add it.
//!
//! # What is hashed, and what deliberately is not
//!
//! ```text
//! hashed every *.rs under src/, by sorted relative path
//! NOT hashed Cargo.toml, build.rs, tests/, target/
//! ```
//!
//! `Cargo.toml` is excluded because **cargo rewrites it when it packages a
//! crate** — path dependencies are stripped, `[workspace]` is removed, fields are
//! normalised. Hashing it would make the same recipe report one hash when built
//! from this checkout and a different one when built from the published `.crate`,
//! which is the one comparison the hash exists to make. The `src/**/*.rs` files
//! are byte-identical in both, so they are what gets hashed.
//!
//! `build.rs` is excluded for a second reason on top of that: it is this file,
//! and a hash of the hasher is a fixed point nobody can reason about.
//!
//! # Determinism
//!
//! The file list is sorted by relative path, and each file contributes its path,
//! its length and its bytes with explicit separators. The length is in there so
//! two files cannot be concatenated into one that hashes the same — the standard
//! length-extension shape, cheap to close and expensive to notice missing.
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
/// The env var the crate reads its own hash out of.
const HASH_VAR: &str = "GUNNAR_BUILD_THING_CONTENT_HASH";
fn main() {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR");
let src = Path::new(&manifest_dir).join("src");
let mut files = Vec::new();
collect_rs(&src, &mut files);
files.sort();
assert!(
!files.is_empty(),
"no *.rs found under {} — the recipe would hash the empty string and every \
version of it would carry the same content hash, which is a hash that has \
stopped measuring anything",
src.display()
);
let mut hasher = Sha256::new();
for path in &files {
let rel = path
.strip_prefix(&src)
.expect("collected under src/")
.to_string_lossy()
// A Windows checkout would otherwise hash a different separator for
// the same file and report a different recipe.
.replace('\\', "/");
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
hasher.update(rel.as_bytes());
hasher.update(b"\0");
hasher.update(bytes.len().to_le_bytes());
hasher.update(b"\0");
hasher.update(&bytes);
println!("cargo:rerun-if-changed={}", path.display());
}
let digest = hasher.finalize();
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
println!("cargo:rerun-if-changed={}", src.display());
println!("cargo:rustc-env={HASH_VAR}={hex}");
}
/// Every `*.rs` under `dir`, recursively.
fn collect_rs(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_rs(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}