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