Skip to main content

mcpmesh_local_api/
paths.rs

1//! Shared paths rule: resolved per-platform in ONE place (XDG on unix;
2//! APPDATA/LOCALAPPDATA + named-pipe names on windows). Featureless and std-only —
3//! it lives on the vocabulary crate precisely so EVERY consumer (the daemon, the CLI,
4//! and plugins, which are barred from `mcpmesh-trust`) resolves the same endpoint from
5//! the same rule instead of a per-crate replica. `mcpmesh_trust::paths` re-exports it.
6use std::path::{Path, PathBuf};
7use std::sync::OnceLock;
8
9/// The longest a Unix control-socket path may be (`sockaddr_un.sun_path`, minus the NUL): 103 on
10/// macOS/BSD, 107 on Linux. Used to keep a `--profile`/`MCPMESH_HOME` socket bindable no matter
11/// how deep the profile root sits (#32); the CLI keeps its own bind-time guard as a backstop.
12#[cfg(any(
13    target_os = "macos",
14    target_os = "ios",
15    target_os = "freebsd",
16    target_os = "openbsd"
17))]
18pub const MAX_SOCKET_PATH: usize = 103;
19#[cfg(not(any(
20    target_os = "macos",
21    target_os = "ios",
22    target_os = "freebsd",
23    target_os = "openbsd"
24)))]
25pub const MAX_SOCKET_PATH: usize = 107;
26
27/// In-process profile-root override, set by `mcpmesh --profile <dir>` / `--home <dir>` before
28/// dispatch. Preferred over mutating env because `std::env::set_var` is `unsafe` under the
29/// workspace's `forbid(unsafe_code)` (and edition 2024). First set wins.
30static ROOT_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
31
32/// Pin the profile root for THIS process (the in-process form of `--profile`/`--home`). Returns
33/// `Err(dir)` if a root was already pinned (first set wins). The spawned daemon inherits the same
34/// root via the `MCPMESH_HOME` env var the launcher sets, so both ends resolve identically.
35pub fn set_root(dir: PathBuf) -> Result<(), PathBuf> {
36    ROOT_OVERRIDE.set(dir)
37}
38
39/// The active profile root: the in-process override if set, else an absolute non-empty
40/// `MCPMESH_HOME`. `None` = the standard XDG (unix) / APPDATA (windows) layout. When `Some`, ALL
41/// state — config, data, state, and the runtime socket — roots under this ONE directory (#32),
42/// so isolating an instance takes one knob instead of overriding five XDG vars.
43pub fn profile_root() -> Option<PathBuf> {
44    if let Some(p) = ROOT_OVERRIDE.get() {
45        return Some(p.clone());
46    }
47    std::env::var("MCPMESH_HOME")
48        .ok()
49        .filter(|s| !s.is_empty() && Path::new(s).is_absolute())
50        .map(PathBuf::from)
51}
52
53/// The runtime dir under a profile root — `<root>/run` when the resulting `mcpmesh.sock` path fits
54/// within [`MAX_SOCKET_PATH`], else a short deterministic `<tmp>/mcpmesh-<fnv8(root)>` so a DEEP
55/// profile root still yields a bindable socket. Deterministic in `root`, so the daemon and any
56/// client resolve the same path. Pure (no env) for unit-testing.
57fn runtime_under_root(root: &Path, tmp: &Path) -> PathBuf {
58    let natural = root.join("run");
59    let sock_len = natural.join("mcpmesh.sock").as_os_str().len();
60    if sock_len <= MAX_SOCKET_PATH {
61        natural
62    } else {
63        tmp.join(format!("mcpmesh-{}", fnv8(&root.to_string_lossy())))
64    }
65}
66
67/// The ONE XDG-basedir rule shared by [`config_dir`]/[`data_dir`]/[`state_dir`]:
68/// `$<var>/mcpmesh` when the var is set, non-empty, and absolute; otherwise
69/// `$HOME/<segments…>/mcpmesh`. A missing/empty `HOME` is a typed error — the same
70/// posture as [`runtime_dir`] (never a panic). Unix-only wiring (see [`win_dir`] for
71/// the Windows sibling); `#[cfg(unix)]` because it is otherwise dead on Windows.
72#[cfg(unix)]
73fn xdg_dir(var: &str, home_segments: &[&str]) -> std::io::Result<PathBuf> {
74    let xdg = std::env::var(var).ok();
75    let home = std::env::var("HOME").ok();
76    xdg_dir_from(var, xdg.as_deref(), home.as_deref(), home_segments)
77}
78
79/// Pure core of the XDG rule (the same env-free split as [`runtime_dir_from`], so it is
80/// unit-testable without mutating process env). `xdg` is the raw `$<var>` value if set;
81/// `home` is the raw `$HOME` value if set. Deliberately un-cfg'd (like [`win_dir_from`]) so its
82/// unit tests run on every host; its only non-test caller is the `#[cfg(unix)]` [`xdg_dir`], so
83/// plain windows `lib` builds see it as unused — `allow(dead_code)` rather than `#[cfg(unix)]` here.
84#[allow(dead_code)]
85fn xdg_dir_from(
86    var: &str,
87    xdg: Option<&str>,
88    home: Option<&str>,
89    home_segments: &[&str],
90) -> std::io::Result<PathBuf> {
91    if let Some(x) = xdg
92        && !x.is_empty()
93        && std::path::Path::new(x).is_absolute()
94    {
95        return Ok(PathBuf::from(x).join("mcpmesh"));
96    }
97    match home {
98        Some(h) if !h.is_empty() => {
99            let mut dir = PathBuf::from(h);
100            for seg in home_segments {
101                dir.push(seg);
102            }
103            dir.push("mcpmesh");
104            Ok(dir)
105        }
106        _ => Err(std::io::Error::new(
107            std::io::ErrorKind::NotFound,
108            format!("HOME is not set; set HOME or {var} to an absolute path"),
109        )),
110    }
111}
112
113/// Windows sibling of [`xdg_dir_from`]: an absolute XDG override still wins (so the
114/// family's env-var test isolation works on every platform); otherwise
115/// `<base_env>\mcpmesh\<segments…>` where `base_env` is `%APPDATA%` (config) or
116/// `%LOCALAPPDATA%` (data/state). Missing base env is a typed error (HOME-parity).
117/// Deliberately un-cfg'd (like [`xdg_dir_from`]) so its unit tests run on every host;
118/// its only non-test caller is the `#[cfg(windows)]` [`win_dir`], so plain unix `lib`
119/// builds see it as unused — `allow(dead_code)` rather than `#[cfg(windows)]` here.
120#[allow(dead_code)]
121fn win_dir_from(
122    xdg: Option<&str>,
123    base: Option<&str>,
124    segments: &[&str],
125) -> std::io::Result<PathBuf> {
126    if let Some(x) = xdg
127        && !x.is_empty()
128        && std::path::Path::new(x).is_absolute()
129    {
130        return Ok(PathBuf::from(x).join("mcpmesh"));
131    }
132    match base {
133        Some(b) if !b.is_empty() => {
134            let mut dir = PathBuf::from(b);
135            dir.push("mcpmesh");
136            for seg in segments {
137                dir.push(seg);
138            }
139            Ok(dir)
140        }
141        _ => Err(std::io::Error::new(
142            std::io::ErrorKind::NotFound,
143            "APPDATA/LOCALAPPDATA is not set; set it or the XDG override to an absolute path",
144        )),
145    }
146}
147
148#[cfg(windows)]
149fn win_dir(xdg_var: &str, base_var: &str, segments: &[&str]) -> std::io::Result<PathBuf> {
150    let xdg = std::env::var(xdg_var).ok();
151    let base = std::env::var(base_var).ok();
152    win_dir_from(xdg.as_deref(), base.as_deref(), segments)
153}
154
155/// FNV-1a, folded to 32 bits — deterministic across processes AND rustc versions
156/// (a `DefaultHasher` is not), because the daemon and a plugin daemon may be
157/// different binaries that must resolve the SAME pipe name. Un-cfg'd like
158/// [`win_dir_from`] so its behavior is tested on every host; only used outside tests
159/// on `#[cfg(windows)]`, hence the `allow`.
160#[allow(dead_code)]
161fn fnv8(s: &str) -> String {
162    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
163    for b in s.as_bytes() {
164        h ^= u64::from(*b);
165        h = h.wrapping_mul(0x100_0000_01b3);
166    }
167    format!("{:08x}", ((h >> 32) ^ h) as u32)
168}
169
170/// keep `[a-z0-9_-]`, lowercase the rest in, map anything else to `-`. Un-cfg'd like
171/// [`win_dir_from`]; see its comment for why the unused-on-unix `allow` is here
172/// instead of a `#[cfg(windows)]` gate.
173#[allow(dead_code)]
174fn sanitize_pipe_component(s: &str) -> String {
175    s.chars()
176        .map(|c| match c {
177            'a'..='z' | '0'..='9' | '_' | '-' => c,
178            'A'..='Z' => c.to_ascii_lowercase(),
179            _ => '-',
180        })
181        .collect()
182}
183
184/// Windows local endpoint: a named pipe, not a filesystem socket.
185/// `\\.\pipe\mcpmesh-<domain>-<user>[-<fnv8(XDG_RUNTIME_DIR)>]`. The name is only a
186/// rendezvous label — same-user enforcement is the pipe's owner-only DACL
187/// (`mcpmesh-local-api`'s bind), never the name. USERDOMAIN disambiguates a local and
188/// a domain account sharing a username; XDG_RUNTIME_DIR (set by hermetic tests on
189/// every platform) isolates parallel test daemons exactly as it does on Unix.
190/// Un-cfg'd like [`win_dir_from`]; see its comment for why the unused-on-unix
191/// `allow` is here instead of a `#[cfg(windows)]` gate.
192#[allow(dead_code)]
193fn windows_pipe_name_from(
194    domain: Option<&str>,
195    user: Option<&str>,
196    xdg: Option<&str>,
197) -> std::io::Result<PathBuf> {
198    let user = user.filter(|u| !u.is_empty()).ok_or_else(|| {
199        std::io::Error::new(
200            std::io::ErrorKind::NotFound,
201            "USERNAME is not set; cannot derive a per-user pipe name",
202        )
203    })?;
204    let mut name = format!(
205        r"\\.\pipe\mcpmesh-{}-{}",
206        sanitize_pipe_component(domain.unwrap_or("local")),
207        sanitize_pipe_component(user)
208    );
209    if let Some(x) = xdg
210        && !x.is_empty()
211    {
212        name.push('-');
213        name.push_str(&fnv8(x));
214    }
215    Ok(PathBuf::from(name))
216}
217
218#[cfg(windows)]
219fn windows_pipe_name() -> std::io::Result<PathBuf> {
220    let domain = std::env::var("USERDOMAIN").ok();
221    let user = std::env::var("USERNAME").ok();
222    // A profile root (#32) isolates the pipe just as XDG_RUNTIME_DIR does — its path hashes into
223    // the name suffix, so two profiles on one machine get distinct pipes and rendezvous by
224    // construction. Falls back to XDG_RUNTIME_DIR when no profile is active.
225    let iso = profile_root()
226        .map(|r| r.to_string_lossy().into_owned())
227        .or_else(|| std::env::var("XDG_RUNTIME_DIR").ok());
228    windows_pipe_name_from(domain.as_deref(), user.as_deref(), iso.as_deref())
229}
230
231/// Per-platform config dir: `$XDG_CONFIG_HOME/mcpmesh` when that var is set,
232/// non-empty, and absolute; otherwise `$HOME/.config/mcpmesh` (unix). On Windows,
233/// `%APPDATA%\mcpmesh` (an absolute `XDG_CONFIG_HOME` override still wins, for test
234/// isolation).
235pub fn config_dir() -> std::io::Result<PathBuf> {
236    if let Some(root) = profile_root() {
237        return Ok(root.join("config"));
238    }
239    #[cfg(unix)]
240    return xdg_dir("XDG_CONFIG_HOME", &[".config"]);
241    #[cfg(windows)]
242    return win_dir("XDG_CONFIG_HOME", "APPDATA", &[]);
243}
244
245pub fn default_device_key_path() -> std::io::Result<PathBuf> {
246    Ok(config_dir()?.join("device.key"))
247}
248
249pub fn default_config_path() -> std::io::Result<PathBuf> {
250    Ok(config_dir()?.join("config.toml"))
251}
252
253/// Per-platform runtime dir for the control socket: `$XDG_RUNTIME_DIR/mcpmesh`
254/// when that var is set, non-empty, and absolute (Linux); otherwise `$TMPDIR/mcpmesh`, or
255/// `std::env::temp_dir()/mcpmesh` when `TMPDIR` is unset (macOS — its per-user `$TMPDIR` is
256/// itself private). Returns the path only; the daemon creates it 0700 + verifies
257/// ownership before binding (a bind-time concern — see cli `ipc::bind_control_socket`).
258pub fn runtime_dir() -> std::io::Result<PathBuf> {
259    let tmp = std::env::var("TMPDIR")
260        .ok()
261        .filter(|s| !s.is_empty())
262        .map(PathBuf::from)
263        .unwrap_or_else(std::env::temp_dir);
264    if let Some(root) = profile_root() {
265        // A profile roots the socket under its own tree, or a short hashed tmp dir when the tree
266        // is too deep for SUN_LEN — either way deterministic in `root`, so all ends rendezvous.
267        return Ok(runtime_under_root(&root, &tmp));
268    }
269    let xdg = std::env::var("XDG_RUNTIME_DIR").ok();
270    runtime_dir_from(xdg.as_deref(), tmp)
271}
272
273/// Pure core of the runtime-dir rule, split out so the env logic is unit-testable without
274/// mutating process env (no `temp_env` dev-dep). `xdg` is the raw `$XDG_RUNTIME_DIR`
275/// value if the var is set; `tmp` is the already-resolved fallback base. Prefers XDG
276/// iff it is non-empty and absolute; always returns the `mcpmesh` subdir.
277///
278/// Guards absoluteness: a relative base (e.g. `TMPDIR=""` with no XDG, where
279/// `std::env::temp_dir()` also yields `""`) would place the control socket in the
280/// process CWD — a per-user-endpoint violation and a double-daemon rendezvous hazard.
281/// Such a base is an error, not a silent relative path.
282fn runtime_dir_from(xdg: Option<&str>, tmp: PathBuf) -> std::io::Result<PathBuf> {
283    if let Some(x) = xdg
284        && !x.is_empty()
285        && std::path::Path::new(x).is_absolute()
286    {
287        return Ok(PathBuf::from(x).join("mcpmesh"));
288    }
289    let dir = tmp.join("mcpmesh");
290    if !dir.is_absolute() {
291        return Err(std::io::Error::new(
292            std::io::ErrorKind::NotFound,
293            "could not resolve a per-user runtime dir; \
294             set XDG_RUNTIME_DIR or TMPDIR to an absolute path",
295        ));
296    }
297    Ok(dir)
298}
299
300/// The local control endpoint, resolved for THIS platform: a Unix socket at
301/// `<runtime_dir>/mcpmesh.sock` on unix. On Windows there is no per-user runtime dir with
302/// the right ACL semantics, so the control plane is a named pipe instead — see the private
303/// `windows_pipe_name` helper; the returned `PathBuf` is the pipe name (`\\.\pipe\…`), which is
304/// what `transport::connect_local`/`bind_local` dial. The ONE resolver both wire ends
305/// share: the daemon binds exactly what this returns, so clients rendezvous by
306/// construction, never by copied formula.
307pub fn default_endpoint() -> std::io::Result<PathBuf> {
308    #[cfg(unix)]
309    return Ok(runtime_dir()?.join("mcpmesh.sock"));
310    #[cfg(windows)]
311    return windows_pipe_name();
312}
313
314/// Per-platform data dir for durable state: `$XDG_DATA_HOME/mcpmesh` when that
315/// var is set, non-empty, and absolute; otherwise `$HOME/.local/share/mcpmesh` (unix).
316/// `state.redb` (the peer allowlist) lives here. Unlike the runtime dir (ephemeral, per-boot),
317/// this is durable across reboots. On Windows, `%LOCALAPPDATA%\mcpmesh\data` (LOCALAPPDATA,
318/// not APPDATA — this data need not roam with the user profile).
319pub fn data_dir() -> std::io::Result<PathBuf> {
320    if let Some(root) = profile_root() {
321        return Ok(root.join("data"));
322    }
323    #[cfg(unix)]
324    return xdg_dir("XDG_DATA_HOME", &[".local", "share"]);
325    #[cfg(windows)]
326    return win_dir("XDG_DATA_HOME", "LOCALAPPDATA", &["data"]);
327}
328
329/// The peer allowlist store path (`<data_dir>/state.redb`).
330pub fn default_state_db_path() -> std::io::Result<PathBuf> {
331    Ok(data_dir()?.join("state.redb"))
332}
333
334/// The gated app-blob store directory (`<data_dir>/blobs/` — the daemon's gated iroh-blobs store).
335pub fn default_blobs_dir() -> std::io::Result<PathBuf> {
336    Ok(data_dir()?.join("blobs"))
337}
338
339/// The persisted blob-scope sidecar (`<data_dir>/blob-scopes.json`). One JSON document:
340/// `scope_name -> { hashes, grants }`, atomic-write single-writer.
341pub fn default_blob_scopes_path() -> std::io::Result<PathBuf> {
342    Ok(data_dir()?.join("blob-scopes.json"))
343}
344
345/// Per-platform STATE dir for durable, per-node runtime state: `$XDG_STATE_HOME/mcpmesh`
346/// when that var is set, non-empty, and absolute; otherwise `$HOME/.local/state/mcpmesh`. Distinct
347/// from `data_dir()` (`~/.local/share`, XDG_DATA_HOME): the XDG basedir spec places *state* data
348/// (logs, history — the audit JSONL here) under `~/.local/state`, separate from portable app data.
349/// Mirrors the `data_dir()` derivation exactly, swapping the var and the `.local/state` segment. On
350/// Windows, `%LOCALAPPDATA%\mcpmesh\state` (mirrors `data_dir()`'s Windows branch, `state` segment).
351pub fn state_dir() -> std::io::Result<PathBuf> {
352    if let Some(root) = profile_root() {
353        return Ok(root.join("state"));
354    }
355    #[cfg(unix)]
356    return xdg_dir("XDG_STATE_HOME", &[".local", "state"]);
357    #[cfg(windows)]
358    return win_dir("XDG_STATE_HOME", "LOCALAPPDATA", &["state"]);
359}
360
361/// The append-only audit-log directory (`<state_dir>/audit/`). One monthly JSONL
362/// file per calendar month (`YYYY-MM.jsonl`) lives here; the writer creates the directory lazily on
363/// first append. Local-only — nothing here is ever transmitted.
364pub fn default_audit_dir() -> std::io::Result<PathBuf> {
365    Ok(state_dir()?.join("audit"))
366}
367
368/// The installed roster document (`<config_dir>/roster.json`).
369pub fn default_roster_path() -> std::io::Result<PathBuf> {
370    Ok(config_dir()?.join("roster.json"))
371}
372
373/// The per-node roster-freshness sidecar (`<config_dir>/roster.confirmed`). One epoch-seconds
374/// integer: the last instant this node validated the installed roster as current via an authenticated
375/// channel (a TLS URL poll ≥ installed, a gossip-delivered roster passing validation, or a manual
376/// install). Per-node LIVENESS state, NOT a roster-document field — keeps roster.json a pure
377/// re-serialization.
378pub fn default_roster_confirmed_path() -> std::io::Result<PathBuf> {
379    Ok(config_dir()?.join("roster.confirmed"))
380}
381
382/// The org-root key (`<config_dir>/org-root.key`) — held by the operator. Present ONLY on
383/// the operator's node (minted by `org create`); signs rosters.
384pub fn default_org_root_key_path() -> std::io::Result<PathBuf> {
385    Ok(config_dir()?.join("org-root.key"))
386}
387
388/// This person's user key (`<config_dir>/user.key`). Minted by `join`; binds a
389/// person's devices. Present on every enrolled person's device (never moves between machines).
390pub fn default_user_key_path() -> std::io::Result<PathBuf> {
391    Ok(config_dir()?.join("user.key"))
392}
393
394#[cfg(test)]
395mod path_tests {
396    use super::*;
397
398    // Env-scoping approach (declared delta from the plan's `temp_env` suggestion):
399    // rather than mutate process env in tests (which would need the `temp_env` dev-dep
400    // plus test serialization), the §13 rule lives in the pure `runtime_dir_from`; we
401    // unit-test it directly with explicit inputs. `default_endpoint()` is still
402    // exercised against the real env, asserting the shape that holds for any env.
403
404    // Feeds POSIX path literals (`/tmp`), which `Path::is_absolute` + `join` only treat as
405    // intended on unix — and the SUN_LEN socket-path concern is unix-only anyway (Windows uses a
406    // named pipe, whose isolation rides `windows_pipe_name`). Same gate as the runtime_dir tests.
407    #[cfg(unix)]
408    #[test]
409    fn profile_runtime_uses_the_short_socket_path_for_a_deep_root() {
410        // #32: a shallow profile root keeps the socket beside its state (<root>/run); a DEEP root
411        // whose <root>/run/mcpmesh.sock would blow SUN_LEN falls back to a short hashed tmp dir —
412        // deterministic in the root, so the daemon and its clients still rendezvous.
413        let shallow = Path::new("/tmp/p");
414        assert_eq!(
415            runtime_under_root(shallow, Path::new("/tmp")),
416            PathBuf::from("/tmp/p/run")
417        );
418        let deep = PathBuf::from(format!("/home/user/{}", "very-deep-segment/".repeat(12)));
419        let got = runtime_under_root(&deep, Path::new("/tmp"));
420        assert!(
421            got.join("mcpmesh.sock").as_os_str().len() <= MAX_SOCKET_PATH,
422            "deep-root socket must fit SUN_LEN, got {}",
423            got.display()
424        );
425        assert!(
426            got.to_string_lossy().starts_with("/tmp/mcpmesh-"),
427            "deep root falls back to a short hashed tmp dir, got {}",
428            got.display()
429        );
430        // Deterministic: same root → same short dir.
431        assert_eq!(got, runtime_under_root(&deep, Path::new("/tmp")));
432    }
433
434    // The next three tests feed the pure cores POSIX literals like `/run/user/1000`,
435    // which `Path::is_absolute` only treats as absolute on unix — and the xdg/runtime
436    // rules they exercise are wired only on the unix arm anyway, so they run there.
437    #[cfg(unix)]
438    #[test]
439    fn runtime_dir_prefers_xdg_when_set() {
440        assert_eq!(
441            runtime_dir_from(Some("/run/user/1000"), PathBuf::from("/tmp")).unwrap(),
442            PathBuf::from("/run/user/1000/mcpmesh")
443        );
444    }
445
446    #[cfg(unix)]
447    #[test]
448    fn runtime_dir_falls_back_to_tmp_when_xdg_absent_empty_or_relative() {
449        let tmp = PathBuf::from("/var/folders/xx");
450        let want = PathBuf::from("/var/folders/xx/mcpmesh");
451        assert_eq!(runtime_dir_from(None, tmp.clone()).unwrap(), want);
452        assert_eq!(runtime_dir_from(Some(""), tmp.clone()).unwrap(), want);
453        // A relative XDG value is rejected (§13: must be absolute) → tmp fallback.
454        assert_eq!(runtime_dir_from(Some("relative/dir"), tmp).unwrap(), want);
455    }
456
457    #[test]
458    fn empty_tmpdir_without_xdg_errors_not_relative() {
459        // TMPDIR="" with no XDG resolves the base to "" → a relative "mcpmesh" dir, which
460        // would drop the control socket in the process CWD. The guard must Err (§13),
461        // never return a relative path.
462        let err = runtime_dir_from(None, PathBuf::from("")).unwrap_err();
463        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
464    }
465
466    // Unix-only: the control endpoint is a filesystem socket path under the runtime dir.
467    #[cfg(unix)]
468    #[test]
469    fn default_endpoint_is_under_runtime_dir() {
470        // Holds for any ambient env: the socket is <runtime_dir>/mcpmesh.sock, runtime_dir
471        // always ends in `mcpmesh`, and the resolved path is absolute (§13).
472        let sock = default_endpoint().unwrap();
473        assert!(sock.ends_with("mcpmesh/mcpmesh.sock"));
474        assert!(sock.is_absolute());
475    }
476
477    // Windows twin: the control endpoint is a per-user named pipe, not a filesystem socket.
478    #[cfg(windows)]
479    #[test]
480    fn default_endpoint_is_a_per_user_pipe_name() {
481        let sock = default_endpoint().unwrap();
482        assert!(sock.to_string_lossy().starts_with(r"\\.\pipe\mcpmesh-"));
483    }
484
485    #[test]
486    fn audit_dir_is_state_dir_slash_audit() {
487        // Holds for any ambient env ON EITHER PLATFORM: the audit dir is <state_dir>/audit
488        // and absolute (spec §13; unix `~/.local/state/mcpmesh/audit`, windows
489        // `%LOCALAPPDATA%\mcpmesh\state\audit` — different tails, same parent relation).
490        let audit = default_audit_dir().unwrap();
491        assert_eq!(audit.file_name().unwrap(), "audit");
492        assert!(audit.is_absolute());
493        // The audit dir is a child of the state dir (durable, distinct from data_dir()).
494        assert_eq!(audit.parent().unwrap(), state_dir().unwrap());
495    }
496
497    #[test]
498    fn state_dir_prefers_xdg_state_home_when_absolute() {
499        // Mirror of the data_dir rule for XDG_STATE_HOME (spec §13). Exercised against the
500        // real env: absolute, with a `mcpmesh` path component on either platform (unix ends
501        // in `mcpmesh`; windows default is `%LOCALAPPDATA%\mcpmesh\state`).
502        let sd = state_dir().unwrap();
503        assert!(sd.components().any(|c| c.as_os_str() == "mcpmesh"));
504        assert!(sd.is_absolute());
505    }
506
507    #[cfg(unix)]
508    #[test]
509    fn xdg_dir_prefers_the_var_when_absolute_else_home_segments() {
510        // The var wins when set + non-empty + absolute (§13).
511        assert_eq!(
512            xdg_dir_from(
513                "XDG_DATA_HOME",
514                Some("/xdg/data"),
515                Some("/home/u"),
516                &[".local", "share"]
517            )
518            .unwrap(),
519            PathBuf::from("/xdg/data/mcpmesh")
520        );
521        // Absent / empty / relative var → $HOME/<segments>/mcpmesh.
522        for bad in [None, Some(""), Some("relative/dir")] {
523            assert_eq!(
524                xdg_dir_from("XDG_DATA_HOME", bad, Some("/home/u"), &[".local", "share"]).unwrap(),
525                PathBuf::from("/home/u/.local/share/mcpmesh")
526            );
527        }
528    }
529
530    #[test]
531    fn xdg_dir_without_home_errors_never_panics() {
532        // M5: the old code `expect("HOME not set")`-panicked here; the rule now matches
533        // runtime_dir's typed-error posture. Empty HOME is as unusable as unset HOME.
534        for home in [None, Some("")] {
535            let err = xdg_dir_from("XDG_CONFIG_HOME", None, home, &[".config"]).unwrap_err();
536            assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
537        }
538    }
539
540    #[test]
541    fn win_dir_prefers_xdg_override_else_base_env() {
542        // XDG override wins when absolute (test isolation on Windows too). The literal is
543        // platform-selected because `is_absolute` is platform-semantic: `/xdg/cfg` is not
544        // absolute on windows, `C:\xdg\cfg` is not absolute on unix.
545        let abs_override = if cfg!(windows) {
546            r"C:\xdg\cfg"
547        } else {
548            "/xdg/cfg"
549        };
550        assert_eq!(
551            win_dir_from(Some(abs_override), Some(r"C:\Users\u\AppData\Roaming"), &[]).unwrap(),
552            PathBuf::from(abs_override).join("mcpmesh")
553        );
554        // No override → <base>\mcpmesh\<segments…>.
555        assert_eq!(
556            win_dir_from(None, Some(r"C:\Users\u\AppData\Local"), &["data"]).unwrap(),
557            PathBuf::from(r"C:\Users\u\AppData\Local")
558                .join("mcpmesh")
559                .join("data")
560        );
561        // Missing/empty base env → typed NotFound error (parity with missing HOME).
562        for base in [None, Some("")] {
563            let err = win_dir_from(None, base, &[]).unwrap_err();
564            assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
565        }
566    }
567
568    #[test]
569    fn windows_pipe_name_is_per_user_and_env_isolatable() {
570        // Stable per-user name from USERDOMAIN + USERNAME, sanitized + lowercased.
571        assert_eq!(
572            windows_pipe_name_from(Some("DESKTOP-AB12"), Some("Jo Hn"), None).unwrap(),
573            PathBuf::from(r"\\.\pipe\mcpmesh-desktop-ab12-jo-hn")
574        );
575        // XDG_RUNTIME_DIR set (the family's test-isolation var) → a deterministic
576        // suffix so hermetic tests get distinct pipes.
577        let a = windows_pipe_name_from(Some("D"), Some("u"), Some(r"C:\tmp\t1")).unwrap();
578        let b = windows_pipe_name_from(Some("D"), Some("u"), Some(r"C:\tmp\t2")).unwrap();
579        assert_ne!(a, b);
580        assert!(a.to_string_lossy().starts_with(r"\\.\pipe\mcpmesh-d-u-"));
581        // Same input → same name (deterministic across processes: FNV, not DefaultHasher).
582        assert_eq!(
583            windows_pipe_name_from(Some("D"), Some("u"), Some(r"C:\tmp\t1")).unwrap(),
584            a
585        );
586        // Missing USERNAME → typed error, never a shared anonymous pipe name.
587        assert!(windows_pipe_name_from(None, None, None).is_err());
588    }
589}