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/// data_dir path **combined with the build version string**.
41///
42/// Including the version ensures that two different release binaries
43/// (e.g. 0.40.2 and 0.41.0) that share the same channel data dir
44/// (`~/.agentmux/channels/stable/`) produce DISTINCT pipe names and
45/// therefore satisfy the CLAUDE.md multi-version concurrency guarantee:
46/// each version is an independent single-instance domain.
47///
48/// Without the version, both binaries hash to the same pipe name and
49/// the second one silently forwards its "open window" request to the
50/// first, activating the wrong version. `version` should be the semver
51/// string from `CARGO_PKG_VERSION` (e.g. `"0.41.0"`). The `\x00`
52/// separator never appears in a filesystem path, so path + version
53/// are always unambiguously distinguishable.
54pub fn data_dir_hash16(data_dir: &std::path::Path, version: &str) -> String {
55 let canonical = data_dir
56 .canonicalize()
57 .unwrap_or_else(|_| data_dir.to_path_buf());
58 let combined = format!("{}\x00{}", canonical.to_string_lossy().to_lowercase(), version);
59 format!("{:016x}", fnv1a_64(combined.as_bytes()))
60}
61
62#[cfg(test)]
63mod tests {
64 use super::*;
65
66 #[test]
67 fn fnv1a_known_vector() {
68 // Standard test vector for FNV-1a-64 on empty input.
69 assert_eq!(fnv1a_64(b""), FNV_OFFSET_BASIS);
70 // From http://www.isthe.com/chongo/tech/comp/fnv/test_vectors.html
71 // ("foobar" → 0x85944171f73967e8)
72 assert_eq!(fnv1a_64(b"foobar"), 0x85944171f73967e8);
73 }
74
75 #[test]
76 fn data_dir_hash_stable_across_calls() {
77 let p = std::path::PathBuf::from("C:\\Users\\test\\AgentMux");
78 assert_eq!(data_dir_hash16(&p, "0.41.0"), data_dir_hash16(&p, "0.41.0"));
79 assert_eq!(data_dir_hash16(&p, "0.41.0").len(), 16);
80 }
81
82 #[test]
83 fn data_dir_hash_case_insensitive() {
84 // Windows paths shouldn't produce different hashes for
85 // different casings of the same logical path.
86 let lower = std::path::PathBuf::from("c:\\users\\test");
87 let upper = std::path::PathBuf::from("C:\\Users\\Test");
88 assert_eq!(data_dir_hash16(&lower, "0.41.0"), data_dir_hash16(&upper, "0.41.0"));
89 }
90
91 #[test]
92 fn different_versions_same_dir_produce_different_hashes() {
93 // Core invariant: same data dir + different version → different pipe name.
94 // This is what prevents 0.40.2 and 0.41.0 from colliding on single-instance.
95 let p = std::path::PathBuf::from("C:\\Users\\test\\.agentmux\\channels\\stable\\data");
96 assert_ne!(data_dir_hash16(&p, "0.40.2"), data_dir_hash16(&p, "0.41.0"));
97 }
98
99 #[test]
100 fn same_version_different_dirs_produce_different_hashes() {
101 let a = std::path::PathBuf::from("C:\\Users\\test\\.agentmux\\channels\\stable\\data");
102 let b = std::path::PathBuf::from("C:\\Users\\test\\.agentmux\\channels\\beta\\data");
103 assert_ne!(data_dir_hash16(&a, "0.41.0"), data_dir_hash16(&b, "0.41.0"));
104 }
105
106 #[test]
107 fn portable_and_installed_never_collide() {
108 // The real 2026-06-03 scenario: a local v0.42.0 build launched
109 // alongside an installed v0.41.0. Different channel dir AND different
110 // version → distinct single-instance pipe → safe to run in parallel,
111 // per CLAUDE.md "Multiple Instances Run in Parallel" and
112 // SPEC_MULTI_INSTANCE_ISOLATION_HARDENING_2026_06_03.md.
113 let portable = std::path::PathBuf::from(
114 "C:\\Users\\test\\.agentmux\\channels\\local-main-b28b7a\\versions\\0.42.0\\data",
115 );
116 let installed = std::path::PathBuf::from(
117 "C:\\Users\\test\\.agentmux\\channels\\stable\\versions\\0.41.0\\data",
118 );
119 assert_ne!(
120 data_dir_hash16(&portable, "0.42.0"),
121 data_dir_hash16(&installed, "0.41.0")
122 );
123 }
124
125 #[test]
126 fn successive_local_builds_produce_different_hashes() {
127 // The LABEL axis: two successive `task package` runs on the same branch at
128 // the same semver get different AGENTMUX_BUILD_LABELs (different stamps),
129 // and the label is mixed into the pipe key, so the pipe differs even when
130 // the data dir is held equal. As of per-build channels the data dir ALSO
131 // differs per build (see `per_build_channels_isolate_data_dir_*` below), so
132 // isolation no longer rests on this axis alone — but this guards the label
133 // axis in isolation. Releases (no label) carry the semver instead.
134 // Regression guard for the 2026-06-09 bug (retro: retro-local-build-isolation-regression).
135 let data_dir = std::path::PathBuf::from(
136 "C:\\Users\\test\\.agentmux\\channels\\local-main-b28b7a\\versions\\0.43.1\\data",
137 );
138 let label_a = "0.43.1+gabc1234.20260609T1100.12345";
139 let label_b = "0.43.1+gabc1234.20260609T1145.67890";
140 assert_ne!(
141 data_dir_hash16(&data_dir, label_a),
142 data_dir_hash16(&data_dir, label_b)
143 );
144 }
145
146 #[test]
147 fn per_build_channels_isolate_data_dir_even_at_same_version() {
148 // The DATA-DIR axis added by the per-build channel (package.sh bakes a
149 // BUILD_ID into AGENTMUX_BUILD_CHANNEL_DEFAULT): two builds of the same
150 // branch at the same semver now resolve to distinct CHANNELS (build-id
151 // suffix differs) → distinct data dirs → distinct pipes, independent of
152 // the label. This is what makes cef-cache + data dir + pipe all agree on
153 // the build (the piece PR #1315 missed). pipe_version held equal here to
154 // prove the data-dir alone isolates.
155 let ver = "0.43.1";
156 let a = std::path::PathBuf::from(
157 "C:\\Users\\test\\.agentmux\\channels\\local-main-b28b7a-3f9a2c1d\\versions\\0.43.1\\data",
158 );
159 let b = std::path::PathBuf::from(
160 "C:\\Users\\test\\.agentmux\\channels\\local-main-b28b7a-7e1b0042\\versions\\0.43.1\\data",
161 );
162 assert_ne!(data_dir_hash16(&a, ver), data_dir_hash16(&b, ver));
163 }
164}