running_process/
content_hash.rs1use std::io;
32use std::path::Path;
33
34pub use blake3::Hash;
37
38pub fn blake3_file(path: &Path) -> io::Result<Hash> {
52 let mut hasher = blake3::Hasher::new();
53 hasher.update_mmap_rayon(path)?;
54 Ok(hasher.finalize())
55}
56
57#[cfg(feature = "client")]
60const MAX_STAMP_LEN: usize = 64;
61
62#[cfg(feature = "client")]
68fn is_safe_stamp(value: &str) -> bool {
69 !value.is_empty()
70 && value.len() <= MAX_STAMP_LEN
71 && value
72 .bytes()
73 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'+'))
74}
75
76#[cfg(feature = "client")]
78fn file_hex16(path: &Path) -> io::Result<String> {
79 Ok(blake3_file(path)?.to_hex()[..16].to_owned())
80}
81
82#[cfg(feature = "client")]
84fn stamp_from_hex(version: &str, hex16: &str) -> String {
85 format!("{version}-{hex16}")
86}
87
88#[cfg(feature = "client")]
96fn resolve_stamp(
97 dev_scope: bool,
98 inherited: Option<String>,
99 compute: impl FnOnce() -> io::Result<String>,
100) -> io::Result<Option<String>> {
101 if !dev_scope {
102 return Ok(None);
103 }
104 match inherited {
105 Some(value) if is_safe_stamp(&value) => Ok(Some(value)),
106 _ => compute().map(Some),
107 }
108}
109
110#[cfg(feature = "client")]
129pub fn daemon_identity_stamp(version: &str) -> io::Result<Option<String>> {
130 static EXE_HEX: std::sync::OnceLock<String> = std::sync::OnceLock::new();
131 let dev_scope = crate::env_vars::DAEMON_SCOPE
132 .text()
133 .is_some_and(|scope| scope.eq_ignore_ascii_case("dev"));
134 resolve_stamp(
135 dev_scope,
136 crate::env_vars::DAEMON_IDENTITY_STAMP.text(),
137 || {
138 let hex = match EXE_HEX.get() {
139 Some(hex) => hex.clone(),
140 None => {
141 let hex = file_hex16(&std::env::current_exe()?)?;
142 EXE_HEX.get_or_init(|| hex).clone()
143 }
144 };
145 Ok(stamp_from_hex(version, &hex))
146 },
147 )
148}
149
150#[cfg(feature = "client")]
157pub fn daemon_identity_stamp_env(version: &str) -> io::Result<Option<(&'static str, String)>> {
158 Ok(daemon_identity_stamp(version)?
159 .map(|value| (crate::env_vars::DAEMON_IDENTITY_STAMP.name, value)))
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use std::io::Write;
166
167 fn write_temp(name: &str, bytes: &[u8]) -> std::path::PathBuf {
168 let mut path = std::env::temp_dir();
169 path.push(format!(
170 "rp-content-hash-{}-{}-{}",
171 std::process::id(),
172 name,
173 bytes.len()
174 ));
175 let mut f = std::fs::File::create(&path).expect("create temp file");
176 f.write_all(bytes).expect("write temp file");
177 f.flush().expect("flush temp file");
178 path
179 }
180
181 #[test]
182 fn hashes_the_bytes_not_the_path() {
183 let bytes = b"the quick brown fox jumps over the lazy dog";
186 let path = write_temp("bytes", bytes);
187 let got = blake3_file(&path).expect("hash temp file");
188 std::fs::remove_file(&path).ok();
189 assert_eq!(got, blake3::hash(bytes));
190 }
191
192 #[test]
193 fn same_contents_at_different_paths_hash_equal() {
194 let bytes = b"identical contents";
198 let a = write_temp("dup-a", bytes);
199 let b = write_temp("dup-b", bytes);
200 let ha = blake3_file(&a).expect("hash a");
201 let hb = blake3_file(&b).expect("hash b");
202 std::fs::remove_file(&a).ok();
203 std::fs::remove_file(&b).ok();
204 assert_eq!(ha, hb, "content-based hash must ignore the path");
205 }
206
207 #[test]
208 fn different_contents_hash_differently() {
209 let a = write_temp("diff-a", b"content one");
210 let b = write_temp("diff-b", b"content two");
211 let ha = blake3_file(&a).expect("hash a");
212 let hb = blake3_file(&b).expect("hash b");
213 std::fs::remove_file(&a).ok();
214 std::fs::remove_file(&b).ok();
215 assert_ne!(ha, hb);
216 }
217
218 #[test]
219 fn empty_file_hashes_like_empty_input() {
220 let path = write_temp("empty", b"");
221 let got = blake3_file(&path).expect("hash empty file");
222 std::fs::remove_file(&path).ok();
223 assert_eq!(got, blake3::hash(b""));
224 }
225
226 #[test]
227 fn first_16_hex_is_a_stable_stamp() {
228 let bytes = b"stamp me";
230 let path = write_temp("stamp", bytes);
231 let hash = blake3_file(&path).expect("hash temp file");
232 std::fs::remove_file(&path).ok();
233 let hex = hash.to_hex();
234 let stamp16 = &hex[..16];
235 assert_eq!(stamp16.len(), 16);
236 assert!(stamp16.chars().all(|c| c.is_ascii_hexdigit()));
237 assert_eq!(stamp16, &blake3::hash(bytes).to_hex()[..16]);
239 }
240
241 #[test]
242 fn missing_file_is_an_io_error() {
243 let mut path = std::env::temp_dir();
244 path.push(format!(
245 "rp-content-hash-does-not-exist-{}",
246 std::process::id()
247 ));
248 let err = blake3_file(&path).expect_err("missing file must error");
249 assert_eq!(err.kind(), io::ErrorKind::NotFound);
250 }
251
252 #[cfg(feature = "client")]
253 mod stamp {
254 use super::super::*;
255 use super::write_temp;
256
257 fn never_computed() -> io::Result<String> {
258 panic!("an inherited stamp must not be re-hashed")
259 }
260
261 #[test]
262 fn stamp_is_none_outside_dev_scope_even_when_inherited() {
263 let inherited = Some("4.1.0-deadbeefdeadbeef".to_owned());
264 assert_eq!(
265 resolve_stamp(false, inherited, never_computed).unwrap(),
266 None
267 );
268 assert_eq!(resolve_stamp(false, None, never_computed).unwrap(), None);
269 }
270
271 #[test]
272 fn stamp_uses_inherited_value_verbatim_without_hashing() {
273 let inherited = Some("4.1.0-deadbeefdeadbeef".to_owned());
274 assert_eq!(
275 resolve_stamp(true, inherited, never_computed).unwrap(),
276 Some("4.1.0-deadbeefdeadbeef".to_owned())
277 );
278 }
279
280 #[test]
281 fn stamp_is_computed_when_dev_scope_has_nothing_inherited() {
282 let got = resolve_stamp(true, None, || Ok("1.2.3-0123456789abcdef".into())).unwrap();
283 assert_eq!(got, Some("1.2.3-0123456789abcdef".to_owned()));
284 }
285
286 #[test]
287 fn unsafe_inherited_value_is_recomputed_not_trusted() {
288 for bad in ["", "a/b", "a\\b", "nul\0byte", "has space", &"x".repeat(65)] {
289 let got =
290 resolve_stamp(true, Some(bad.to_owned()), || Ok("1.0.0-cafe".into())).unwrap();
291 assert_eq!(got, Some("1.0.0-cafe".to_owned()), "{bad:?}");
292 }
293 }
294
295 #[test]
296 fn computed_stamp_is_version_dash_sixteen_hex_of_the_file_bytes() {
297 let path = write_temp("stamp-shape", b"some binary");
298 let stamp = stamp_from_hex("4.1.0", &file_hex16(&path).unwrap());
299 std::fs::remove_file(&path).ok();
300 let expected = blake3::hash(b"some binary").to_hex();
301 assert_eq!(stamp, format!("4.1.0-{}", &expected[..16]));
302 assert!(is_safe_stamp(&stamp));
303 }
304
305 #[test]
306 fn different_bytes_give_different_stamps_and_same_bytes_the_same() {
307 let a = write_temp("stamp-a", b"build one");
308 let b = write_temp("stamp-b", b"build two");
309 let c = write_temp("stamp-c", b"build one");
310 let stamp = |path| stamp_from_hex("4.1.0", &file_hex16(path).unwrap());
311 let (sa, sb, sc) = (stamp(&a), stamp(&b), stamp(&c));
312 for path in [&a, &b, &c] {
313 std::fs::remove_file(path).ok();
314 }
315 assert_ne!(sa, sb, "same version, different bytes must not collide");
316 assert_eq!(sa, sc, "the path must not matter");
317 }
318 }
319}