Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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);
        }
    }
}