agentmux_launcher/hash.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Stable 64-bit hash for deriving per-data-dir IPC names. Used to
5// build the named-pipe path `\\.\pipe\agentmux-{hash16}\command`
6// so each AgentMux instance (per CLAUDE.md, multiple parallel
7// instances are supported per-data-dir) gets a distinct IPC
8// surface, kernel-isolated from siblings.
9//
10// We hand-roll FNV-1a because:
11// * Adding `sha2` for ~64 bits of stable hash is overkill (3+ MB
12// deps) when this is non-cryptographic.
13// * `std::collections::hash_map::DefaultHasher` is explicitly
14// NOT documented as stable across runs / Rust versions; we
15// need stability so the same launcher binary always picks the
16// same pipe name for the same data dir.
17// * FNV-1a is deterministic, well-known, and ~20 lines of code.
18//
19// Collisions are non-cryptographic but adequate for this scope:
20// the hash inputs are filesystem paths, the keyspace is tiny, and
21// a collision just means two installs at different paths share a
22// pipe name — they'd need to be running simultaneously AND have
23// the SAME data dir hash, which is astronomically unlikely with
24// 16 hex chars (64 bits) of namespace.
25
26const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
27const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
28
29/// 64-bit FNV-1a hash of bytes. Stable across runs.
30pub fn fnv1a_64(bytes: &[u8]) -> u64 {
31 let mut hash = FNV_OFFSET_BASIS;
32 for b in bytes {
33 hash ^= *b as u64;
34 hash = hash.wrapping_mul(FNV_PRIME);
35 }
36 hash
37}
38
39/// First 16 hex chars of FNV-1a-64 over the canonical-lowercase
40/// representation of the data_dir path. Used as the per-instance
41/// IPC namespace component.
42pub fn data_dir_hash16(data_dir: &std::path::Path) -> String {
43 // Canonicalize if possible (resolves `..`, mixed casing on
44 // Windows), but fall back to the raw path if canonicalize fails
45 // (e.g. data_dir doesn't exist yet during early startup).
46 let canonical = data_dir
47 .canonicalize()
48 .unwrap_or_else(|_| data_dir.to_path_buf());
49 let s = canonical.to_string_lossy().to_lowercase();
50 format!("{:016x}", fnv1a_64(s.as_bytes()))
51}
52
53#[cfg(test)]
54mod tests {
55 use super::*;
56
57 #[test]
58 fn fnv1a_known_vector() {
59 // Standard test vector for FNV-1a-64 on empty input.
60 assert_eq!(fnv1a_64(b""), FNV_OFFSET_BASIS);
61 // From http://www.isthe.com/chongo/tech/comp/fnv/test_vectors.html
62 // ("foobar" → 0x85944171f73967e8)
63 assert_eq!(fnv1a_64(b"foobar"), 0x85944171f73967e8);
64 }
65
66 #[test]
67 fn data_dir_hash_stable_across_calls() {
68 let p = std::path::PathBuf::from("C:\\Users\\test\\AgentMux");
69 assert_eq!(data_dir_hash16(&p), data_dir_hash16(&p));
70 assert_eq!(data_dir_hash16(&p).len(), 16);
71 }
72
73 #[test]
74 fn data_dir_hash_case_insensitive() {
75 // Windows paths shouldn't produce different hashes for
76 // different casings of the same logical path.
77 let lower = std::path::PathBuf::from("c:\\users\\test");
78 let upper = std::path::PathBuf::from("C:\\Users\\Test");
79 assert_eq!(data_dir_hash16(&lower), data_dir_hash16(&upper));
80 }
81}