maat-plugin-build 0.18.5

Build-script helper for maat plugins — emits a stable content_hash from a plugin crate's source.
Documentation
//! Build-script helper for maat plugin crates.
//
// POL-4d: `.expect()` is intentional in this crate — it runs at
// `build.rs` time. If `CARGO_MANIFEST_DIR` is unset or `src/` walk
// fails, panicking is the correct outcome (build environment is
// broken; no diagnostic value in returning Err to a build.rs caller
// that would just `unwrap` it anyway).
#![allow(clippy::expect_used)]
// POL-4e Phase 1: indexing in `decode_blake3_hex` is bounds-safe by
// construction (length check above). Sole site is annotated inline.
#![cfg_attr(not(test), deny(clippy::indexing_slicing))]
//!
//! A plugin's `build.rs` is a one-liner:
//!
//! ```ignore
//! fn main() { maat_plugin_build::emit_content_hash(); }
//! ```
//!
//! The function walks the plugin's `src/` tree (sorted), hashes the file
//! contents along with `Cargo.toml`, optionally folds in the workspace
//! `Cargo.lock` entries for the crate's deps, and emits
//! `cargo:rustc-env=METAGAMER_PLUGIN_CONTENT_HASH=<hex>`.
//!
//! Lockfile-folding is best-effort: standalone-crate builds with no
//! reachable workspace lockfile fall back to (src + Cargo.toml) and
//! print a warning.

use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

/// Walk the calling crate's `src/`, hash everything that affects build
/// output, and emit the `METAGAMER_PLUGIN_CONTENT_HASH` rustc-env
/// variable plus rerun-if-changed directives.
pub fn emit_content_hash() {
    let crate_root = env::var("CARGO_MANIFEST_DIR")
        .expect("CARGO_MANIFEST_DIR not set — emit_content_hash must run inside a build.rs");
    let crate_root = PathBuf::from(crate_root);

    let mut hasher = blake3::Hasher::new();

    // (i) src/ files in sorted-path order.
    let src_dir = crate_root.join("src");
    let mut src_files = Vec::new();
    if src_dir.is_dir() {
        collect_files(&src_dir, &mut src_files).expect("walk src/");
    }
    src_files.sort();
    for path in &src_files {
        let rel = path
            .strip_prefix(&crate_root)
            .unwrap_or(path)
            .to_string_lossy()
            .replace('\\', "/");
        hasher.update(rel.as_bytes());
        hasher.update(b"\0");
        let bytes = fs::read(path).expect("read source file");
        hasher.update(&bytes);
        hasher.update(b"\0");
    }

    // (ii) Cargo.toml verbatim.
    let cargo_toml = crate_root.join("Cargo.toml");
    if let Ok(bytes) = fs::read(&cargo_toml) {
        hasher.update(b"Cargo.toml\0");
        hasher.update(&bytes);
        hasher.update(b"\0");
    }

    // (iii) workspace Cargo.lock entries, best-effort. Walk up the
    // directory tree looking for a `Cargo.lock`. If found, fold its
    // bytes verbatim (sorted-package extraction would require a real
    // toml parser; verbatim is conservative and still detects dep
    // changes).
    let mut lockfile_used = false;
    let mut search = crate_root.clone();
    for _ in 0..6 {
        let candidate = search.join("Cargo.lock");
        if candidate.is_file() {
            if let Ok(bytes) = fs::read(&candidate) {
                hasher.update(b"Cargo.lock\0");
                hasher.update(&bytes);
                hasher.update(b"\0");
                lockfile_used = true;
                println!(
                    "cargo:rerun-if-changed={}",
                    candidate.to_string_lossy().replace('\\', "/")
                );
            }
            break;
        }
        let Some(parent) = search.parent() else { break };
        search = parent.to_path_buf();
    }
    if !lockfile_used {
        println!(
            "cargo:warning=maat-plugin-build: no Cargo.lock found in 6 ancestors of {}; \
             content_hash falls back to src/ + Cargo.toml only",
            crate_root.to_string_lossy()
        );
    }

    let hex = hex::encode(hasher.finalize().as_bytes());
    println!("cargo:rustc-env=METAGAMER_PLUGIN_CONTENT_HASH={hex}");
    println!("cargo:rerun-if-changed=src");
    println!("cargo:rerun-if-changed=Cargo.toml");
    println!("cargo:rerun-if-changed=build.rs");
}

fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        let ft = entry.file_type()?;
        if ft.is_dir() {
            collect_files(&path, out)?;
        } else if ft.is_file() {
            out.push(path);
        }
    }
    Ok(())
}

/// Decode a 64-hex-char string (such as the value baked into
/// `METAGAMER_PLUGIN_CONTENT_HASH`) into a 32-byte blake3 digest at
/// runtime. Returns `None` if the input isn't well-formed hex of
/// length 64.
#[allow(
    clippy::indexing_slicing,
    reason = "explicit `s.len() != 64` early return above guarantees \
              s.as_bytes()[i*2] and s.as_bytes()[i*2+1] are in range \
              for every i in 0..32"
)]
pub fn decode_blake3_hex(s: &str) -> Option<[u8; 32]> {
    if s.len() != 64 {
        return None;
    }
    let mut out = [0u8; 32];
    for (i, byte) in out.iter_mut().enumerate() {
        let hi = hex_digit(s.as_bytes()[i * 2])?;
        let lo = hex_digit(s.as_bytes()[i * 2 + 1])?;
        *byte = (hi << 4) | lo;
    }
    Some(out)
}

fn hex_digit(b: u8) -> Option<u8> {
    match b {
        b'0'..=b'9' => Some(b - b'0'),
        b'a'..=b'f' => Some(b - b'a' + 10),
        b'A'..=b'F' => Some(b - b'A' + 10),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn decode_blake3_hex_roundtrip() {
        let bytes = [0xABu8; 32];
        let s = hex::encode(bytes);
        let back = decode_blake3_hex(&s).unwrap();
        assert_eq!(back, bytes);
    }

    #[test]
    fn decode_blake3_hex_rejects_bad_length() {
        assert!(decode_blake3_hex("abcd").is_none());
        assert!(decode_blake3_hex("").is_none());
    }

    #[test]
    fn decode_blake3_hex_rejects_non_hex() {
        let bad: String = std::iter::repeat_n('z', 64).collect();
        assert!(decode_blake3_hex(&bad).is_none());
    }

    #[test]
    fn collect_files_sorted_after_explicit_sort() {
        // Build a temp dir with two files; verify collect+sort yields
        // deterministic order.
        let dir = std::env::temp_dir().join(format!(
            "maat-plugin-build-test-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join("b.txt"), b"B").unwrap();
        fs::write(dir.join("a.txt"), b"A").unwrap();

        let mut files = Vec::new();
        collect_files(&dir, &mut files).unwrap();
        files.sort();
        assert_eq!(files.len(), 2);
        assert!(files[0].ends_with("a.txt"));
        assert!(files[1].ends_with("b.txt"));

        let _ = fs::remove_dir_all(&dir);
    }
}