Skip to main content

maat_plugin_build/
lib.rs

1//! Build-script helper for maat plugin crates.
2//
3// POL-4d: `.expect()` is intentional in this crate — it runs at
4// `build.rs` time. If `CARGO_MANIFEST_DIR` is unset or `src/` walk
5// fails, panicking is the correct outcome (build environment is
6// broken; no diagnostic value in returning Err to a build.rs caller
7// that would just `unwrap` it anyway).
8#![allow(clippy::expect_used)]
9// POL-4e Phase 1: indexing in `decode_blake3_hex` is bounds-safe by
10// construction (length check above). Sole site is annotated inline.
11#![cfg_attr(not(test), deny(clippy::indexing_slicing))]
12//!
13//! A plugin's `build.rs` is a one-liner:
14//!
15//! ```ignore
16//! fn main() { maat_plugin_build::emit_content_hash(); }
17//! ```
18//!
19//! The function walks the plugin's `src/` tree (sorted), hashes the file
20//! contents along with `Cargo.toml`, optionally folds in the workspace
21//! `Cargo.lock` entries for the crate's deps, and emits
22//! `cargo:rustc-env=METAGAMER_PLUGIN_CONTENT_HASH=<hex>`.
23//!
24//! Lockfile-folding is best-effort: standalone-crate builds with no
25//! reachable workspace lockfile fall back to (src + Cargo.toml) and
26//! print a warning.
27
28use std::env;
29use std::fs;
30use std::io;
31use std::path::{Path, PathBuf};
32
33/// Walk the calling crate's `src/`, hash everything that affects build
34/// output, and emit the `METAGAMER_PLUGIN_CONTENT_HASH` rustc-env
35/// variable plus rerun-if-changed directives.
36pub fn emit_content_hash() {
37    let crate_root = env::var("CARGO_MANIFEST_DIR")
38        .expect("CARGO_MANIFEST_DIR not set — emit_content_hash must run inside a build.rs");
39    let crate_root = PathBuf::from(crate_root);
40
41    let mut hasher = blake3::Hasher::new();
42
43    // (i) src/ files in sorted-path order.
44    let src_dir = crate_root.join("src");
45    let mut src_files = Vec::new();
46    if src_dir.is_dir() {
47        collect_files(&src_dir, &mut src_files).expect("walk src/");
48    }
49    src_files.sort();
50    for path in &src_files {
51        let rel = path
52            .strip_prefix(&crate_root)
53            .unwrap_or(path)
54            .to_string_lossy()
55            .replace('\\', "/");
56        hasher.update(rel.as_bytes());
57        hasher.update(b"\0");
58        let bytes = fs::read(path).expect("read source file");
59        hasher.update(&bytes);
60        hasher.update(b"\0");
61    }
62
63    // (ii) Cargo.toml verbatim.
64    let cargo_toml = crate_root.join("Cargo.toml");
65    if let Ok(bytes) = fs::read(&cargo_toml) {
66        hasher.update(b"Cargo.toml\0");
67        hasher.update(&bytes);
68        hasher.update(b"\0");
69    }
70
71    // (iii) workspace Cargo.lock entries, best-effort. Walk up the
72    // directory tree looking for a `Cargo.lock`. If found, fold its
73    // bytes verbatim (sorted-package extraction would require a real
74    // toml parser; verbatim is conservative and still detects dep
75    // changes).
76    let mut lockfile_used = false;
77    let mut search = crate_root.clone();
78    for _ in 0..6 {
79        let candidate = search.join("Cargo.lock");
80        if candidate.is_file() {
81            if let Ok(bytes) = fs::read(&candidate) {
82                hasher.update(b"Cargo.lock\0");
83                hasher.update(&bytes);
84                hasher.update(b"\0");
85                lockfile_used = true;
86                println!(
87                    "cargo:rerun-if-changed={}",
88                    candidate.to_string_lossy().replace('\\', "/")
89                );
90            }
91            break;
92        }
93        let Some(parent) = search.parent() else { break };
94        search = parent.to_path_buf();
95    }
96    if !lockfile_used {
97        println!(
98            "cargo:warning=maat-plugin-build: no Cargo.lock found in 6 ancestors of {}; \
99             content_hash falls back to src/ + Cargo.toml only",
100            crate_root.to_string_lossy()
101        );
102    }
103
104    let hex = hex::encode(hasher.finalize().as_bytes());
105    println!("cargo:rustc-env=METAGAMER_PLUGIN_CONTENT_HASH={hex}");
106    println!("cargo:rerun-if-changed=src");
107    println!("cargo:rerun-if-changed=Cargo.toml");
108    println!("cargo:rerun-if-changed=build.rs");
109}
110
111fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
112    for entry in fs::read_dir(dir)? {
113        let entry = entry?;
114        let path = entry.path();
115        let ft = entry.file_type()?;
116        if ft.is_dir() {
117            collect_files(&path, out)?;
118        } else if ft.is_file() {
119            out.push(path);
120        }
121    }
122    Ok(())
123}
124
125/// Decode a 64-hex-char string (such as the value baked into
126/// `METAGAMER_PLUGIN_CONTENT_HASH`) into a 32-byte blake3 digest at
127/// runtime. Returns `None` if the input isn't well-formed hex of
128/// length 64.
129#[allow(
130    clippy::indexing_slicing,
131    reason = "explicit `s.len() != 64` early return above guarantees \
132              s.as_bytes()[i*2] and s.as_bytes()[i*2+1] are in range \
133              for every i in 0..32"
134)]
135pub fn decode_blake3_hex(s: &str) -> Option<[u8; 32]> {
136    if s.len() != 64 {
137        return None;
138    }
139    let mut out = [0u8; 32];
140    for (i, byte) in out.iter_mut().enumerate() {
141        let hi = hex_digit(s.as_bytes()[i * 2])?;
142        let lo = hex_digit(s.as_bytes()[i * 2 + 1])?;
143        *byte = (hi << 4) | lo;
144    }
145    Some(out)
146}
147
148fn hex_digit(b: u8) -> Option<u8> {
149    match b {
150        b'0'..=b'9' => Some(b - b'0'),
151        b'a'..=b'f' => Some(b - b'a' + 10),
152        b'A'..=b'F' => Some(b - b'A' + 10),
153        _ => None,
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn decode_blake3_hex_roundtrip() {
163        let bytes = [0xABu8; 32];
164        let s = hex::encode(bytes);
165        let back = decode_blake3_hex(&s).unwrap();
166        assert_eq!(back, bytes);
167    }
168
169    #[test]
170    fn decode_blake3_hex_rejects_bad_length() {
171        assert!(decode_blake3_hex("abcd").is_none());
172        assert!(decode_blake3_hex("").is_none());
173    }
174
175    #[test]
176    fn decode_blake3_hex_rejects_non_hex() {
177        let bad: String = std::iter::repeat_n('z', 64).collect();
178        assert!(decode_blake3_hex(&bad).is_none());
179    }
180
181    #[test]
182    fn collect_files_sorted_after_explicit_sort() {
183        // Build a temp dir with two files; verify collect+sort yields
184        // deterministic order.
185        let dir = std::env::temp_dir().join(format!(
186            "maat-plugin-build-test-{}",
187            std::process::id()
188        ));
189        let _ = fs::remove_dir_all(&dir);
190        fs::create_dir_all(&dir).unwrap();
191        fs::write(dir.join("b.txt"), b"B").unwrap();
192        fs::write(dir.join("a.txt"), b"A").unwrap();
193
194        let mut files = Vec::new();
195        collect_files(&dir, &mut files).unwrap();
196        files.sort();
197        assert_eq!(files.len(), 2);
198        assert!(files[0].ends_with("a.txt"));
199        assert!(files[1].ends_with("b.txt"));
200
201        let _ = fs::remove_dir_all(&dir);
202    }
203}