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/// Outstanding pairing invites (`<data_dir>/invites.json`, #87b).
335///
336/// Holds BEARER SECRETS and is written `0600`. Beside the trust store because it is durable
337/// per-node state; deleting it is a safe operator action that invalidates every outstanding invite.
338pub fn default_invites_path() -> std::io::Result<PathBuf> {
339    Ok(data_dir()?.join("invites.json"))
340}
341
342/// The gated app-blob store directory (`<data_dir>/blobs/` — the daemon's gated iroh-blobs store).
343pub fn default_blobs_dir() -> std::io::Result<PathBuf> {
344    Ok(data_dir()?.join("blobs"))
345}
346
347/// The persisted blob-scope keyed store (`<data_dir>/blob-scopes.redb`). Was one JSON document:
348/// `scope_name -> { hashes, grants }`, atomic-write single-writer.
349pub fn default_blob_scopes_path() -> std::io::Result<PathBuf> {
350    Ok(data_dir()?.join("blob-scopes.redb"))
351}
352
353/// Per-platform STATE dir for durable, per-node runtime state: `$XDG_STATE_HOME/mcpmesh`
354/// when that var is set, non-empty, and absolute; otherwise `$HOME/.local/state/mcpmesh`. Distinct
355/// from `data_dir()` (`~/.local/share`, XDG_DATA_HOME): the XDG basedir spec places *state* data
356/// (logs, history — the audit JSONL here) under `~/.local/state`, separate from portable app data.
357/// Mirrors the `data_dir()` derivation exactly, swapping the var and the `.local/state` segment. On
358/// Windows, `%LOCALAPPDATA%\mcpmesh\state` (mirrors `data_dir()`'s Windows branch, `state` segment).
359pub fn state_dir() -> std::io::Result<PathBuf> {
360    if let Some(root) = profile_root() {
361        return Ok(root.join("state"));
362    }
363    #[cfg(unix)]
364    return xdg_dir("XDG_STATE_HOME", &[".local", "state"]);
365    #[cfg(windows)]
366    return win_dir("XDG_STATE_HOME", "LOCALAPPDATA", &["state"]);
367}
368
369/// The append-only audit-log directory (`<state_dir>/audit/`). One monthly JSONL
370/// file per calendar month (`YYYY-MM.jsonl`) lives here; the writer creates the directory lazily on
371/// first append. Local-only — nothing here is ever transmitted.
372pub fn default_audit_dir() -> std::io::Result<PathBuf> {
373    Ok(state_dir()?.join("audit"))
374}
375
376/// The installed roster document (`<config_dir>/roster.json`).
377pub fn default_roster_path() -> std::io::Result<PathBuf> {
378    Ok(config_dir()?.join("roster.json"))
379}
380
381/// The per-node roster-freshness sidecar (`<config_dir>/roster.confirmed`). One epoch-seconds
382/// integer: the last instant this node validated the installed roster as current via an authenticated
383/// channel (a TLS URL poll ≥ installed, a gossip-delivered roster passing validation, or a manual
384/// install). Per-node LIVENESS state, NOT a roster-document field — keeps roster.json a pure
385/// re-serialization.
386pub fn default_roster_confirmed_path() -> std::io::Result<PathBuf> {
387    Ok(config_dir()?.join("roster.confirmed"))
388}
389
390/// The org-root key (`<config_dir>/org-root.key`) — held by the operator. Present ONLY on
391/// the operator's node (minted by `org create`); signs rosters.
392pub fn default_org_root_key_path() -> std::io::Result<PathBuf> {
393    Ok(config_dir()?.join("org-root.key"))
394}
395
396/// This person's user key (`<config_dir>/user.key`). Minted by `join`; binds a
397/// person's devices. Present on every enrolled person's device (never moves between machines).
398pub fn default_user_key_path() -> std::io::Result<PathBuf> {
399    Ok(config_dir()?.join("user.key"))
400}
401
402#[cfg(test)]
403mod path_tests {
404    use super::*;
405
406    // Env-scoping approach (declared delta from the plan's `temp_env` suggestion):
407    // rather than mutate process env in tests (which would need the `temp_env` dev-dep
408    // plus test serialization), the §13 rule lives in the pure `runtime_dir_from`; we
409    // unit-test it directly with explicit inputs. `default_endpoint()` is still
410    // exercised against the real env, asserting the shape that holds for any env.
411
412    // Feeds POSIX path literals (`/tmp`), which `Path::is_absolute` + `join` only treat as
413    // intended on unix — and the SUN_LEN socket-path concern is unix-only anyway (Windows uses a
414    // named pipe, whose isolation rides `windows_pipe_name`). Same gate as the runtime_dir tests.
415    #[cfg(unix)]
416    #[test]
417    fn profile_runtime_uses_the_short_socket_path_for_a_deep_root() {
418        // #32: a shallow profile root keeps the socket beside its state (<root>/run); a DEEP root
419        // whose <root>/run/mcpmesh.sock would blow SUN_LEN falls back to a short hashed tmp dir —
420        // deterministic in the root, so the daemon and its clients still rendezvous.
421        let shallow = Path::new("/tmp/p");
422        assert_eq!(
423            runtime_under_root(shallow, Path::new("/tmp")),
424            PathBuf::from("/tmp/p/run")
425        );
426        let deep = PathBuf::from(format!("/home/user/{}", "very-deep-segment/".repeat(12)));
427        let got = runtime_under_root(&deep, Path::new("/tmp"));
428        assert!(
429            got.join("mcpmesh.sock").as_os_str().len() <= MAX_SOCKET_PATH,
430            "deep-root socket must fit SUN_LEN, got {}",
431            got.display()
432        );
433        assert!(
434            got.to_string_lossy().starts_with("/tmp/mcpmesh-"),
435            "deep root falls back to a short hashed tmp dir, got {}",
436            got.display()
437        );
438        // Deterministic: same root → same short dir.
439        assert_eq!(got, runtime_under_root(&deep, Path::new("/tmp")));
440    }
441
442    // The next three tests feed the pure cores POSIX literals like `/run/user/1000`,
443    // which `Path::is_absolute` only treats as absolute on unix — and the xdg/runtime
444    // rules they exercise are wired only on the unix arm anyway, so they run there.
445    #[cfg(unix)]
446    #[test]
447    fn runtime_dir_prefers_xdg_when_set() {
448        assert_eq!(
449            runtime_dir_from(Some("/run/user/1000"), PathBuf::from("/tmp")).unwrap(),
450            PathBuf::from("/run/user/1000/mcpmesh")
451        );
452    }
453
454    #[cfg(unix)]
455    #[test]
456    fn runtime_dir_falls_back_to_tmp_when_xdg_absent_empty_or_relative() {
457        let tmp = PathBuf::from("/var/folders/xx");
458        let want = PathBuf::from("/var/folders/xx/mcpmesh");
459        assert_eq!(runtime_dir_from(None, tmp.clone()).unwrap(), want);
460        assert_eq!(runtime_dir_from(Some(""), tmp.clone()).unwrap(), want);
461        // A relative XDG value is rejected (§13: must be absolute) → tmp fallback.
462        assert_eq!(runtime_dir_from(Some("relative/dir"), tmp).unwrap(), want);
463    }
464
465    #[test]
466    fn empty_tmpdir_without_xdg_errors_not_relative() {
467        // TMPDIR="" with no XDG resolves the base to "" → a relative "mcpmesh" dir, which
468        // would drop the control socket in the process CWD. The guard must Err (§13),
469        // never return a relative path.
470        let err = runtime_dir_from(None, PathBuf::from("")).unwrap_err();
471        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
472    }
473
474    // Unix-only: the control endpoint is a filesystem socket path under the runtime dir.
475    #[cfg(unix)]
476    #[test]
477    fn default_endpoint_is_under_runtime_dir() {
478        // Holds for any ambient env: the socket is <runtime_dir>/mcpmesh.sock, runtime_dir
479        // always ends in `mcpmesh`, and the resolved path is absolute (§13).
480        let sock = default_endpoint().unwrap();
481        assert!(sock.ends_with("mcpmesh/mcpmesh.sock"));
482        assert!(sock.is_absolute());
483    }
484
485    // Windows twin: the control endpoint is a per-user named pipe, not a filesystem socket.
486    #[cfg(windows)]
487    #[test]
488    fn default_endpoint_is_a_per_user_pipe_name() {
489        let sock = default_endpoint().unwrap();
490        assert!(sock.to_string_lossy().starts_with(r"\\.\pipe\mcpmesh-"));
491    }
492
493    #[test]
494    fn audit_dir_is_state_dir_slash_audit() {
495        // Holds for any ambient env ON EITHER PLATFORM: the audit dir is <state_dir>/audit
496        // and absolute (spec §13; unix `~/.local/state/mcpmesh/audit`, windows
497        // `%LOCALAPPDATA%\mcpmesh\state\audit` — different tails, same parent relation).
498        let audit = default_audit_dir().unwrap();
499        assert_eq!(audit.file_name().unwrap(), "audit");
500        assert!(audit.is_absolute());
501        // The audit dir is a child of the state dir (durable, distinct from data_dir()).
502        assert_eq!(audit.parent().unwrap(), state_dir().unwrap());
503    }
504
505    #[test]
506    fn state_dir_prefers_xdg_state_home_when_absolute() {
507        // Mirror of the data_dir rule for XDG_STATE_HOME (spec §13). Exercised against the
508        // real env: absolute, with a `mcpmesh` path component on either platform (unix ends
509        // in `mcpmesh`; windows default is `%LOCALAPPDATA%\mcpmesh\state`).
510        let sd = state_dir().unwrap();
511        assert!(sd.components().any(|c| c.as_os_str() == "mcpmesh"));
512        assert!(sd.is_absolute());
513    }
514
515    #[cfg(unix)]
516    #[test]
517    fn xdg_dir_prefers_the_var_when_absolute_else_home_segments() {
518        // The var wins when set + non-empty + absolute (§13).
519        assert_eq!(
520            xdg_dir_from(
521                "XDG_DATA_HOME",
522                Some("/xdg/data"),
523                Some("/home/u"),
524                &[".local", "share"]
525            )
526            .unwrap(),
527            PathBuf::from("/xdg/data/mcpmesh")
528        );
529        // Absent / empty / relative var → $HOME/<segments>/mcpmesh.
530        for bad in [None, Some(""), Some("relative/dir")] {
531            assert_eq!(
532                xdg_dir_from("XDG_DATA_HOME", bad, Some("/home/u"), &[".local", "share"]).unwrap(),
533                PathBuf::from("/home/u/.local/share/mcpmesh")
534            );
535        }
536    }
537
538    #[test]
539    fn xdg_dir_without_home_errors_never_panics() {
540        // M5: the old code `expect("HOME not set")`-panicked here; the rule now matches
541        // runtime_dir's typed-error posture. Empty HOME is as unusable as unset HOME.
542        for home in [None, Some("")] {
543            let err = xdg_dir_from("XDG_CONFIG_HOME", None, home, &[".config"]).unwrap_err();
544            assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
545        }
546    }
547
548    #[test]
549    fn win_dir_prefers_xdg_override_else_base_env() {
550        // XDG override wins when absolute (test isolation on Windows too). The literal is
551        // platform-selected because `is_absolute` is platform-semantic: `/xdg/cfg` is not
552        // absolute on windows, `C:\xdg\cfg` is not absolute on unix.
553        let abs_override = if cfg!(windows) {
554            r"C:\xdg\cfg"
555        } else {
556            "/xdg/cfg"
557        };
558        assert_eq!(
559            win_dir_from(Some(abs_override), Some(r"C:\Users\u\AppData\Roaming"), &[]).unwrap(),
560            PathBuf::from(abs_override).join("mcpmesh")
561        );
562        // No override → <base>\mcpmesh\<segments…>.
563        assert_eq!(
564            win_dir_from(None, Some(r"C:\Users\u\AppData\Local"), &["data"]).unwrap(),
565            PathBuf::from(r"C:\Users\u\AppData\Local")
566                .join("mcpmesh")
567                .join("data")
568        );
569        // Missing/empty base env → typed NotFound error (parity with missing HOME).
570        for base in [None, Some("")] {
571            let err = win_dir_from(None, base, &[]).unwrap_err();
572            assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
573        }
574    }
575
576    #[test]
577    fn windows_pipe_name_is_per_user_and_env_isolatable() {
578        // Stable per-user name from USERDOMAIN + USERNAME, sanitized + lowercased.
579        assert_eq!(
580            windows_pipe_name_from(Some("DESKTOP-AB12"), Some("Jo Hn"), None).unwrap(),
581            PathBuf::from(r"\\.\pipe\mcpmesh-desktop-ab12-jo-hn")
582        );
583        // XDG_RUNTIME_DIR set (the family's test-isolation var) → a deterministic
584        // suffix so hermetic tests get distinct pipes.
585        let a = windows_pipe_name_from(Some("D"), Some("u"), Some(r"C:\tmp\t1")).unwrap();
586        let b = windows_pipe_name_from(Some("D"), Some("u"), Some(r"C:\tmp\t2")).unwrap();
587        assert_ne!(a, b);
588        assert!(a.to_string_lossy().starts_with(r"\\.\pipe\mcpmesh-d-u-"));
589        // Same input → same name (deterministic across processes: FNV, not DefaultHasher).
590        assert_eq!(
591            windows_pipe_name_from(Some("D"), Some("u"), Some(r"C:\tmp\t1")).unwrap(),
592            a
593        );
594        // Missing USERNAME → typed error, never a shared anonymous pipe name.
595        assert!(windows_pipe_name_from(None, None, None).is_err());
596    }
597}