Skip to main content

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/// Longest stamp accepted from the environment. The stamp is spelled into
58/// pipe and pid-file names, which have their own length limits.
59#[cfg(feature = "client")]
60const MAX_STAMP_LEN: usize = 64;
61
62/// Whether an inherited stamp is safe to spell into a pipe or pid-file name.
63///
64/// Rejects path separators, NUL and anything else outside the characters a
65/// `<version>-<hex>` stamp uses, because a hostile or mangled value would
66/// otherwise reach the filesystem namespace.
67#[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/// First 16 hex characters of `blake3_file(path)`.
77#[cfg(feature = "client")]
78fn file_hex16(path: &Path) -> io::Result<String> {
79    Ok(blake3_file(path)?.to_hex()[..16].to_owned())
80}
81
82/// `<version>-<hex16>`.
83#[cfg(feature = "client")]
84fn stamp_from_hex(version: &str, hex16: &str) -> String {
85    format!("{version}-{hex16}")
86}
87
88/// The stamp decision, with every input passed in so it is testable without
89/// touching the process environment.
90///
91/// Outside dev scope there is no stamp, whatever `inherited` holds: release
92/// scope keeps its bare identity and single-daemon upgrade semantics. In dev
93/// scope a safe inherited value is used verbatim (no re-hash); an unsafe one is
94/// recomputed rather than trusted.
95#[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/// The dev-scope daemon identity stamp for the calling tool (#1252), or `None`
111/// outside dev scope.
112///
113/// `version` is the *calling tool's* version, not running-process's: the stamp
114/// identifies the consumer binary (soldr, zccache, ...). When
115/// `RUNNING_PROCESS_DAEMON_IDENTITY_STAMP` is already set to a safe value it is
116/// returned as-is, so a 200-process build hashes once, not 200 times. Otherwise
117/// the stamp is computed from the running executable's bytes and cached for
118/// the life of the process.
119///
120/// This only *computes* the value. Putting it on child processes is the
121/// caller's job (see [`daemon_identity_stamp_env`]); a library mutating the
122/// process environment is unsound in a multithreaded host.
123///
124/// # Errors
125///
126/// Returns the underlying [`io::Error`] if the executable cannot be located or
127/// hashed.
128#[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/// [`daemon_identity_stamp`] as the `(name, value)` pair to put on a child
151/// [`std::process::Command`] with `.env(name, value)`.
152///
153/// # Errors
154///
155/// As [`daemon_identity_stamp`].
156#[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        // The digest must equal a plain blake3 hash of the same bytes: proof
184        // we hash file *contents*, independent of where the file lives.
185        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        // Two files with identical bytes but different paths must hash equal —
195        // this is what lets two worktrees / two dev builds of the same content
196        // resolve to the same identity, and different content to different.
197        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        // The documented consumer usage: `<version>-<first 16 hex chars>`.
229        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        // Recomputing over identical bytes yields the same stamp.
238        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}