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#[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}