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}