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}