running_process/content_hash.rs
1//! Content-hash primitive: blake3 of a file's *bytes* (#891).
2//!
3//! soldr-daemon, `FastLED/fbuild`, and standalone zccache all obtain their
4//! daemon identity/discovery through running-process, and all three hit the
5//! same failure in **dev**: two builds sharing one home root rendezvous on the
6//! same daemon pipe + pid file, each sees the other as "stale-version", and
7//! displaces it on every invocation — a `displace-stale` war that wedges the
8//! compile daemon. The shims are already version-namespaced; the daemon
9//! *identity* is not. Rather than reimplement isolation in each consumer, this
10//! module provides the shared primitive: a content hash of a file, so a dev
11//! build can stamp its own identity with `"<version>-<first-16-hex of
12//! blake3_file(current_exe)>"`. Distinct dev builds → distinct identities → no
13//! cross-build displacement. (Full root-cause + evidence: zackees/soldr#2352.)
14//!
15//! ## Why the bytes, and why mmap the file (not the loaded image)
16//!
17//! - **Hash the bytes, not the path string.** Path-string hashing returns the
18//! same value across rebuilds → no isolation. The file's contents change
19//! every build → the identity changes every build (isolating same-*version*
20//! rebuilds), which is the whole point.
21//! - **mmap the file, do NOT hash the in-memory mapped image.** The loaded
22//! module is mutated by ASLR base relocations, the resolved IAT, and live
23//! `.data`/`.bss`, so it differs from the file **and differs every run**
24//! (ASLR) → effectively a nonce → non-reproducible identity.
25//! [`blake3::Hasher::update_mmap_rayon`] on the file is page-cache-warm (the
26//! exe just executed) → memory-speed, no `read()` copy, multi-core. A 20 MB
27//! binary is ~1–3 ms this way (vs ~20 ms for a naive read), paid at most
28//! once per build (compute the stamp once and propagate the *value* down the
29//! process tree — see the issue for the client/daemon agreement).
30
31use std::io;
32use std::path::Path;
33
34/// The blake3 digest type, re-exported so callers of [`blake3_file`] can name
35/// the return type without taking their own `blake3` dependency.
36pub use blake3::Hash;
37
38/// blake3 of the **file's bytes** at `path` (open → mmap → hash, multi-core).
39///
40/// This hashes the on-disk contents, not the path string and not the
41/// in-memory mapped image — see the [module docs](self) for why that
42/// distinction is the whole point of the primitive.
43///
44/// Uses [`blake3::Hasher::update_mmap_rayon`]: the file is memory-mapped
45/// (page-cache-warm for a just-executed binary) and hashed across all cores.
46///
47/// # Errors
48///
49/// Returns the underlying [`io::Error`] if the file cannot be opened or mapped
50/// (e.g. it does not exist, or permission is denied).
51pub 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(test)]
58mod tests {
59 use super::*;
60 use std::io::Write;
61
62 fn write_temp(name: &str, bytes: &[u8]) -> std::path::PathBuf {
63 let mut path = std::env::temp_dir();
64 path.push(format!(
65 "rp-content-hash-{}-{}-{}",
66 std::process::id(),
67 name,
68 bytes.len()
69 ));
70 let mut f = std::fs::File::create(&path).expect("create temp file");
71 f.write_all(bytes).expect("write temp file");
72 f.flush().expect("flush temp file");
73 path
74 }
75
76 #[test]
77 fn hashes_the_bytes_not_the_path() {
78 // The digest must equal a plain blake3 hash of the same bytes: proof
79 // we hash file *contents*, independent of where the file lives.
80 let bytes = b"the quick brown fox jumps over the lazy dog";
81 let path = write_temp("bytes", bytes);
82 let got = blake3_file(&path).expect("hash temp file");
83 std::fs::remove_file(&path).ok();
84 assert_eq!(got, blake3::hash(bytes));
85 }
86
87 #[test]
88 fn same_contents_at_different_paths_hash_equal() {
89 // Two files with identical bytes but different paths must hash equal —
90 // this is what lets two worktrees / two dev builds of the same content
91 // resolve to the same identity, and different content to different.
92 let bytes = b"identical contents";
93 let a = write_temp("dup-a", bytes);
94 let b = write_temp("dup-b", bytes);
95 let ha = blake3_file(&a).expect("hash a");
96 let hb = blake3_file(&b).expect("hash b");
97 std::fs::remove_file(&a).ok();
98 std::fs::remove_file(&b).ok();
99 assert_eq!(ha, hb, "content-based hash must ignore the path");
100 }
101
102 #[test]
103 fn different_contents_hash_differently() {
104 let a = write_temp("diff-a", b"content one");
105 let b = write_temp("diff-b", b"content two");
106 let ha = blake3_file(&a).expect("hash a");
107 let hb = blake3_file(&b).expect("hash b");
108 std::fs::remove_file(&a).ok();
109 std::fs::remove_file(&b).ok();
110 assert_ne!(ha, hb);
111 }
112
113 #[test]
114 fn empty_file_hashes_like_empty_input() {
115 let path = write_temp("empty", b"");
116 let got = blake3_file(&path).expect("hash empty file");
117 std::fs::remove_file(&path).ok();
118 assert_eq!(got, blake3::hash(b""));
119 }
120
121 #[test]
122 fn first_16_hex_is_a_stable_stamp() {
123 // The documented consumer usage: `<version>-<first 16 hex chars>`.
124 let bytes = b"stamp me";
125 let path = write_temp("stamp", bytes);
126 let hash = blake3_file(&path).expect("hash temp file");
127 std::fs::remove_file(&path).ok();
128 let hex = hash.to_hex();
129 let stamp16 = &hex[..16];
130 assert_eq!(stamp16.len(), 16);
131 assert!(stamp16.chars().all(|c| c.is_ascii_hexdigit()));
132 // Recomputing over identical bytes yields the same stamp.
133 assert_eq!(stamp16, &blake3::hash(bytes).to_hex()[..16]);
134 }
135
136 #[test]
137 fn missing_file_is_an_io_error() {
138 let mut path = std::env::temp_dir();
139 path.push(format!(
140 "rp-content-hash-does-not-exist-{}",
141 std::process::id()
142 ));
143 let err = blake3_file(&path).expect_err("missing file must error");
144 assert_eq!(err.kind(), io::ErrorKind::NotFound);
145 }
146}