1#![allow(clippy::expect_used)]
9#![cfg_attr(not(test), deny(clippy::indexing_slicing))]
12use std::env;
29use std::fs;
30use std::io;
31use std::path::{Path, PathBuf};
32
33pub 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 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 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 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#[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 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}