Skip to main content

mcpmesh_node/
config.rs

1//! The `config.toml` model. Every table and key here is real, implemented surface —
2//! docs/config.md is the operator-facing reference for all of it.
3use figment::{
4    Figment,
5    providers::{Format, Toml},
6};
7use serde::Deserialize;
8use std::collections::BTreeMap;
9use std::path::PathBuf;
10
11#[derive(Debug, Default, Deserialize)]
12#[serde(default)]
13pub struct Config {
14    pub identity: IdentityCfg,
15    pub network: NetworkCfg,
16    pub limits: LimitsCfg,
17    /// Roster-mode `[roster]` tunables: the degraded-expiry grace window, the roster URL +
18    /// poll interval, and the freshness bound — one `RosterState` machine consumes them all.
19    pub roster: RosterCfg,
20    /// `[blobs]` tunables. Today: the app-blob garbage-collection interval (#80).
21    pub blobs: BlobsCfg,
22    /// `[services.<name>]` registry — each entry is a served MCP server plus its allow
23    /// list. Peers do NOT live in config; they live in the daemon's state store, so
24    /// there is no `[peers]` table here.
25    pub services: std::collections::BTreeMap<String, ServiceCfg>,
26}
27
28/// A `[services.<name>]` entry: exactly one backend kind (`run` xor `socket`) plus the
29/// nicknames/groups admitted to it. The xor is validated at access time via
30/// [`ServiceCfg::backend_result`] rather than at parse time, so a malformed entry is a
31/// per-service error, not a whole-config load failure.
32#[derive(Debug, Default, Deserialize)]
33#[serde(default)]
34pub struct ServiceCfg {
35    /// `run`: spawn this command per session (a stdio MCP server).
36    pub run: Option<Vec<String>>,
37    /// `socket`: dial this local UDS (an already-running MCP server).
38    pub socket: Option<String>,
39    /// STABLE principals admitted to this service (b64u:/eid:/roster names, #38 — never display nicknames).
40    pub allow: Vec<String>,
41    /// Per-service env vars for a `run` backend (#51). The `MCPMESH_PEER_*` identity vars win
42    /// over these. Ignored for a `socket` backend. Default empty.
43    pub env: BTreeMap<String, String>,
44    /// Working directory for a `run` backend (#51). Default: inherit the daemon's cwd.
45    pub cwd: Option<String>,
46    /// Per-service proxied-request rate (#63), falling back to `[limits].rate_limit_per_min`.
47    ///
48    /// Before #63 every service a peer could reach drew from ONE shared bucket, so an agent
49    /// hammering a browser or filesystem service starved the embedder's own low-rate control
50    /// traffic to a different service on the same node. Buckets are now per `(service, endpoint)`.
51    ///
52    /// **This can only LOWER the rate.** `[limits].rate_limit_per_min` is a hard ceiling; a larger
53    /// value here is clamped, not honoured. That is what keeps the limit from being raised by a
54    /// config edit or a `register_service` call.
55    pub rate_limit_per_min: Option<u32>,
56}
57
58/// The resolved backend kind of a [`ServiceCfg`], borrowing the config as slices (no
59/// clone). `&[String]`/`&str` rather than `&Vec`/`&String` — idiomatic and gives the
60/// daemon's backend builders the most flexible borrow.
61#[derive(Debug)]
62pub enum Backend<'a> {
63    Run(&'a [String]),
64    Socket(&'a str),
65}
66
67impl ServiceCfg {
68    /// Resolve the backend, enforcing exactly-one-of `run`/`socket`. Both or neither is an
69    /// error — surfaced to the operator, never a silent default.
70    #[allow(dead_code)] // consumed by the daemon service wiring
71    pub fn backend_result(&self) -> Result<Backend<'_>, String> {
72        match (&self.run, &self.socket) {
73            (Some(cmd), None) => Ok(Backend::Run(cmd.as_slice())),
74            (None, Some(p)) => Ok(Backend::Socket(p.as_str())),
75            (Some(_), Some(_)) => Err("service has both run and socket".into()),
76            (None, None) => Err("service has neither run nor socket".into()),
77        }
78    }
79}
80
81#[derive(Debug, Default, Deserialize)]
82#[serde(default)]
83pub struct IdentityCfg {
84    pub device_key: Option<PathBuf>, // None → paths::default_device_key_path()
85    /// This device's suggested name for itself, carried in a minted pairing invite.
86    /// `None` → the daemon defaults to a short fingerprint of the endpoint id.
87    /// Additive (`#[serde(default)]` at the struct level).
88    pub nickname: Option<String>,
89    /// Roster mode: the org id this node joined (pinned at install/join).
90    pub org_id: Option<String>,
91    /// Roster mode: the pinned org-root public key, `b64u:`. The single trust anchor
92    /// roster signatures verify against. Pinned on first roster install / `join`.
93    pub org_root_pk: Option<String>,
94    /// Roster mode: this node's stable user_id in the org. Pinned at `join` (proposed)
95    /// and reconciled to the roster's authoritative value once installed.
96    pub user_id: Option<String>,
97    /// #85 ask 3: admit another DEVICE of a person this node already pairs with, when it presents a
98    /// binding signed by that person's user key — no fresh SAS ceremony.
99    ///
100    /// **OFF by default**, and #85 asks for it to be seamless. It changes what a pairing MEANS:
101    /// today a pairing admits a device, and with this on it admits a person and their future
102    /// devices. #38 arguably made that true of grants already — they are keyed on stable
103    /// principals, so a person's devices share authorization — but "arguably implied" is not a
104    /// reason to widen admission on somebody's node during an upgrade.
105    ///
106    /// **It does not resurrect a device you removed.** `peer_remove` deletes the row; it does not
107    /// stop the PERSON from attesting a device afterwards, because you still pair with them. If you
108    /// removed a device because it was compromised, REVOKE it — attestation checks the revocation
109    /// list first, which is why #85 ask 4 shipped before this.
110    ///
111    /// **It also keeps the pair ALPN's front door open.** That ALPN fast-closes when no invite is
112    /// live; an attestation needs no invite, so with this on the (rate-limited, binding-verified)
113    /// ceremony is reachable continuously. That is the cost of the feature, not a side effect.
114    #[serde(default)]
115    pub admit_attested_devices: bool,
116    /// Roster mode: path to this person's user key. Minted by `join`; binds this
117    /// person's devices. `None` → paths::default_user_key_path() when needed.
118    pub user_key: Option<PathBuf>,
119}
120
121/// `[network]`. The knobs are exactly what `daemon::net_plan` implements —
122/// no aspirational surface:
123/// - `relay_mode = "default" | "custom" | "disabled"`. `"custom"` requires `relay_urls`
124///   (self-hosted iroh relays); `"disabled"` is the HERMETIC mode — no relay AND no
125///   discovery (localhost/tests).
126/// - `discovery_mode = "default" | "custom"`. `"custom"` requires `discovery_urls` —
127///   self-hosted pkarr relay URLs (e.g. an iroh-dns-server), used for BOTH publishing and
128///   resolving peer addresses in place of n0's DNS/pkarr. Ignored (off) when
129///   `relay_mode = "disabled"`.
130///
131/// Unknown modes or a `custom` without URLs are startup ERRORS (`net_plan`), never a silent
132/// fallback — a metadata-privacy knob must not quietly revert to public infrastructure.
133#[derive(Debug, Clone, Deserialize)]
134#[serde(default)]
135pub struct NetworkCfg {
136    pub relay_mode: String,
137    /// Self-hosted relay URLs, required when `relay_mode = "custom"`.
138    pub relay_urls: Vec<String>,
139    pub discovery_mode: String,
140    /// Self-hosted pkarr relay URLs, required when `discovery_mode = "custom"`.
141    pub discovery_urls: Vec<String>,
142    /// TESTING ONLY (#116): force application data over the RELAY even when a direct path exists.
143    ///
144    /// Requires the `unstable-relay-only` cargo feature. Without it this field still PARSES — a
145    /// config must stay portable between a test build and a production one — but is ignored with a
146    /// `warn!`. It is never a startup error: a testing switch must not brick a node, and it must
147    /// never be ignored SILENTLY, because believing you tested the relay when you did not is the
148    /// exact failure #116 reports.
149    ///
150    /// Selects the relay path; it does NOT prevent hole-punching (that is socket-level behaviour a
151    /// `PathSelector` cannot reach). A direct path may still form — it simply never carries data,
152    /// and `status` reports `relay` because #64 derives the path from `is_selected()`.
153    pub relay_only: bool,
154    /// `[network].presence_mode` (#89) — who gets a reachability pong on `mcpmesh/ping/1`.
155    ///
156    /// - `"paired"` (default): any paired peer, today's behaviour.
157    /// - `"granted"`: only a caller currently holding at least one service grant. This is what
158    ///   makes an embedder's per-peer sharing switch control presence too — revoking the last
159    ///   service takes presence with it, live, with no restart and no new verb.
160    /// - `"off"`: never pong.
161    ///
162    /// The arm is gated by PAIRING alone otherwise, so `service_allow_revoke` has no effect on it:
163    /// a peer whose every service was revoked still learns you are online right now, your RTT, your
164    /// `stack_version` and your app metadata, on demand and forever. The only lever was a full
165    /// unpair — a relationship-destroying action to express a privacy preference (#89).
166    ///
167    /// A refusal under `"off"`/`"granted"` matches the trust gate's, so this arm does not
168    /// distinguish "not paired" from "hidden" from "no grants".
169    ///
170    /// **This is NOT "appear offline".** It withholds the pong payload (`stack_version`, app
171    /// metadata, the caller's services) and makes our own probe report you unreachable. It does not
172    /// hide that the node is running: a QUIC application close implies a completed handshake,
173    /// `mcpmesh/pair/1` answers any stranger by design, and a paired peer still gets a served
174    /// `mcpmesh/mcp/1` session. Do not describe it to users as invisibility (#89 gate).
175    ///
176    /// Read at BOOT — changing the mode needs a restart. The per-peer effect under `"granted"` is
177    /// live, because grants are.
178    pub presence_mode: String,
179    /// QUIC idle timeout in seconds (#56) — how long a connection survives with NO traffic and no
180    /// keepalive before the transport closes it. `None` = iroh's default, **30s** on iroh 1.0.3.
181    ///
182    /// This is not "how long an idle session lives". iroh keepalives every 5s by default, so a held
183    /// session survives indefinitely while the process runs; this is what detects a peer that
184    /// VANISHED.
185    ///
186    /// **It is NEGOTIATED, not imposed.** QUIC takes the MINIMUM of the two peers' advertised
187    /// values (RFC 9000 §10.1), so raising this on one node achieves nothing against a peer still
188    /// on the default — the connection still times out at 30s. Raising it is only meaningful when
189    /// every node is configured together; lowering it works one-sidedly.
190    ///
191    /// `0` means "no timeout" from THIS side, which likewise yields the peer's value; against a
192    /// default peer that is still 30s. Only if both sides say `0` does a vanished peer go
193    /// undetected at the transport layer.
194    #[serde(default)]
195    pub idle_timeout_secs: Option<u64>,
196    /// QUIC keepalive interval in seconds (#56) — how often the transport PINGs an otherwise idle
197    /// connection. `None` = iroh's default, **5s** on iroh 1.0.3.
198    ///
199    /// Sets BOTH the connection-level and the per-path keepalive — setting only the former would
200    /// leave every path pinging at iroh's 5s regardless.
201    ///
202    /// A transport keepalive carries no method-bearing frame, so it does NOT consume a
203    /// `[limits].rate_limit_per_min` token — unlike an application-level heartbeat, which does.
204    ///
205    /// Must be less than the EFFECTIVE idle timeout — `idle_timeout_secs` if set, otherwise iroh's
206    /// 30s — or boot fails. Note that effective timeout is the negotiated minimum, so a value that
207    /// passes this check locally can still be too slow for a peer with a shorter one.
208    ///
209    /// **This can only LOWER the ping rate.** iroh caps the per-path keepalive at 5s and silently
210    /// discards anything larger, so a value above 5 would leave every path pinging at 5s anyway —
211    /// boot refuses it rather than pretend it took effect. There is no supported way to reduce
212    /// keepalive traffic on a metered link with iroh 1.0.3.
213    #[serde(default)]
214    pub keep_alive_secs: Option<u64>,
215    /// `[network].local_discovery` (#68) — find peers on the LAN with **no internet at all**.
216    ///
217    /// - `"off"` (default): no multicast sent, none listened for.
218    /// - `"on"`: resolve peers on the link AND announce this node to it.
219    /// - `"resolve"`: learn where peers are without publishing this node's identity or addresses.
220    ///   NOT silent: resolving over mDNS means asking, and the library asks on a fixed cadence, so
221    ///   this mode multicasts a `_mcpmesh._udp.local` query roughly once a second for as long as
222    ///   the node runs. Every device on the link can see that something here runs mcpmesh and is
223    ///   up. If that matters, the mode is `"off"`.
224    ///
225    /// Peer resolution otherwise needs external infrastructure — the pkarr publisher a relay
226    /// provides, or an address someone already handed over — so two machines on the same LAN with
227    /// no uplink cannot find each other though the path between them is fine. That is the scenario
228    /// where "peer to peer" earns its keep, and the commoner weak version too: a LAN where the
229    /// internet is merely flaky, so peers that could talk directly fail to resolve because
230    /// resolution goes out first.
231    ///
232    /// **OFF by default, and #68 asked for on.** The two disclosures are not comparable. pkarr
233    /// publishes a signed record someone must already know your endpoint id to look up. mDNS
234    /// announces your endpoint id and addresses to EVERY device on the link, unprompted and
235    /// repeatedly, to machines that had no idea you existed — and that id is the one peers pin, so
236    /// it correlates you across networks. On a home LAN that is the point; on a café, hotel or
237    /// conference network it is a broadcast to strangers. A node cannot un-send a multicast packet:
238    /// turning this on is one line and reversible, turning it on for someone silently is not.
239    /// `"resolve"` is there for whoever wants the benefit without publishing their identity — with
240    /// the query caveat above, which is the honest limit of that mode.
241    ///
242    /// What `"on"` puts on the link is broader than "addresses" suggests: the LAN address, the
243    /// PUBLIC WAN IPv4, and global IPv6 addresses. A café LAN learns your home/ISP address.
244    ///
245    /// Read at BOOT. Like `relay_mode`/`presence_mode`, an unknown value is a startup ERROR.
246    #[serde(default = "default_local_discovery")]
247    pub local_discovery: String,
248}
249
250fn default_local_discovery() -> String {
251    "off".into()
252}
253impl Default for NetworkCfg {
254    fn default() -> Self {
255        Self {
256            relay_mode: "default".into(),
257            relay_urls: Vec::new(),
258            discovery_mode: "default".into(),
259            discovery_urls: Vec::new(),
260            relay_only: false,
261            presence_mode: "paired".into(),
262            idle_timeout_secs: None,
263            keep_alive_secs: None,
264            local_discovery: default_local_discovery(),
265        }
266    }
267}
268
269/// `[limits]`. NOTE — the frame cap is deliberately NOT here: the 16 MiB `max_frame`
270/// default is a fixed CONSTANT at each wire (`mcpmesh_net::endpoint` for the mesh,
271/// `ipc::MAX_FRAME_BYTES` for the control socket, `backends::MAX_FRAME_BYTES` for local MCP
272/// servers), not a config tunable. A `max_frame` config field existed historically but was never
273/// threaded into any `FrameReader` (dead surface); threading it into the mesh path would widen
274/// `mcpmesh-net`'s public API for no demonstrated need, so the field was removed instead (serde
275/// ignores an unknown `max_frame` key in existing configs).
276#[derive(Debug, Deserialize)]
277#[serde(default)]
278pub struct LimitsCfg {
279    pub rate_limit_per_min: u32,
280    pub max_inflight: u32,
281    pub max_sessions: u32,
282    /// Per-authenticated-endpoint app-blob BYTE budget, bytes per minute (#84a).
283    ///
284    /// **0 = unlimited, and that is the default**, so an existing deployment is unchanged on
285    /// upgrade. The pre-existing blob limiter counts CONNECTIONS, which cannot see one granted
286    /// peer re-pulling a 4 GB blob on each of 60 connections a minute; this bounds the bytes.
287    ///
288    /// A peer that exceeds it gets its transfer ABORTED (retryable), not paced — pacing holds the
289    /// request open and turns a bandwidth problem into an unbounded-concurrency one.
290    ///
291    /// **Use 0 or at least 32768** (two chunks); a value in `1..32768` is FLOORED to 32768.
292    ///
293    /// Admission reserves one chunk before any bytes and the transfer then meters its own chunks,
294    /// so a sub-floor budget does not fail closed — it silently caps every servable blob at
295    /// roughly `budget - 16384` bytes and truncates anything larger. Measured: 20480 serves a
296    /// 4 KiB blob and nothing bigger. Two earlier drafts of this comment got that wrong, first
297    /// recommending the bricking value and then claiming it failed closed.
298    ///
299    /// Requires a restart: the limiter and the provider's event mask are both built once at boot.
300    pub blob_bytes_per_min: u64,
301    /// Audit-log retention window in calendar months (#88). **0 = keep forever, and that is the
302    /// default** — flipping today's keep-everything behavior to auto-deletion is a product call,
303    /// deliberately not made here. When N > 0, boot deletes monthly audit files older than the
304    /// last N months (the current month counts as month 1). Boot-time only: a long-running
305    /// daemon prunes on its next start; the `audit_prune` verb covers live needs.
306    pub audit_retain_months: u32,
307}
308impl Default for LimitsCfg {
309    fn default() -> Self {
310        Self {
311            rate_limit_per_min: 120,
312            max_inflight: 16,
313            max_sessions: 4,
314            blob_bytes_per_min: 0, // unlimited: opt-in, no behaviour change on upgrade
315            audit_retain_months: 0, // keep forever: opt-in, no behaviour change on upgrade
316        }
317    }
318}
319
320/// The default degraded-expiry grace window (`[roster].grace_period` default "72h").
321/// A stale roster keeps serving for this window past `expires_at` (with a warning) before it
322/// stops granting roster identity. Kept here so [`RosterCfg::default`] and the parse fallback
323/// share one source; the gate mirrors it as `roster::gate::DEFAULT_GRACE_SECS`.
324const DEFAULT_GRACE_SECS: i64 = 72 * 3600;
325
326/// The default freshness bound (`[roster].max_staleness`, default "24h" = 86400s). A roster
327/// this node has not re-confirmed current within this window degrades on the SAME `RosterState`
328/// machine as expiry (warnings within `grace`, then serving stops) — bounding adversarial staleness at
329/// `max_staleness + grace` independent of `expires_at`. Shared by [`RosterCfg::default`] + the parse
330/// fallback.
331const DEFAULT_MAX_STALENESS_SECS: i64 = 24 * 3600;
332
333/// The `[roster]` config table. `grace_period` is the degraded-expiry grace window — how
334/// long a roster past `expires_at` keeps serving (degraded, warning) before it stops. Additive
335/// (`#[serde(default)]`): a config with no `[roster]` table gets the 72h default.
336#[derive(Debug, Deserialize)]
337#[serde(default)]
338pub struct RosterCfg {
339    /// Degraded-expiry grace window: `"72h"` / `"24h"` / plain seconds (default "72h").
340    pub grace_period: String,
341    /// The pinned roster URL for the HTTPS poll. Operator-managed static hosting; also how a
342    /// joiner bootstraps its FIRST roster. `None` → no URL poll (manual installs only).
343    /// Additive (`#[serde(default)]`): a config with no `url` key gets `None`.
344    pub url: Option<String>,
345    /// How often to poll `url` (default "1h"). Total-parse like `grace_period` — an
346    /// unparseable value falls back to the hourly default rather than disabling the poll.
347    pub poll_interval: String,
348    /// The freshness bound (default "24h"): how long this node may go without re-confirming
349    /// the installed roster current (via a TLS URL poll ≥ installed, a gossip install, or a
350    /// manual install) before it degrades on the SAME `RosterState` machine as expiry. Total-parse
351    /// like `grace_period` (an unparseable value falls back to the 24h default — a typo never disables
352    /// the bound). Additive (`#[serde(default)]`): a config with no `max_staleness` key gets 24h.
353    pub max_staleness: String,
354}
355impl Default for RosterCfg {
356    fn default() -> Self {
357        Self {
358            grace_period: "72h".into(),
359            url: None,
360            poll_interval: "1h".into(),
361            max_staleness: "24h".into(),
362        }
363    }
364}
365
366impl RosterCfg {
367    /// The grace window in SECONDS. An absent or unparseable `grace_period` falls back to the 72h
368    /// default rather than erroring — an operator typo must never disable degraded serving, and a
369    /// grace window is advisory, not a security bound (revocation is enforced regardless of
370    /// degraded state).
371    ///
372    /// Two paths degrade on the ONE `RosterState` machine (`RosterView::state`, Approved →
373    /// DegradedGrace → DegradedStopped): expiry (`expires_at` + THIS grace window) and freshness
374    /// (`last_confirmed` + `max_staleness`). Once DegradedStopped, the gate stops granting roster
375    /// identity (fail-closed — revocation is still enforced); within grace, serving continues
376    /// with a warning (`daemon::warn_if_degraded_grace`).
377    pub fn grace_seconds(&self) -> i64 {
378        parse_duration(&self.grace_period).unwrap_or(DEFAULT_GRACE_SECS)
379    }
380
381    /// The URL poll interval in SECONDS (default 3600). Like [`grace_seconds`](Self::grace_seconds)
382    /// it is TOTAL — an absent/unparseable value falls back to the hourly default rather than
383    /// erroring, so an operator typo slows the poll to hourly instead of disabling freshness.
384    pub fn poll_interval_seconds(&self) -> i64 {
385        parse_duration(&self.poll_interval).unwrap_or(3600)
386    }
387
388    /// The freshness bound in SECONDS (default 86400 = 24h). Like [`grace_seconds`](Self::grace_seconds)
389    /// it is TOTAL — an absent/unparseable value falls back to the 24h default rather than erroring, so
390    /// an operator typo tightens/loosens to 24h instead of disabling the freshness bound.
391    pub fn max_staleness_seconds(&self) -> i64 {
392        parse_duration(&self.max_staleness).unwrap_or(DEFAULT_MAX_STALENESS_SECS)
393    }
394}
395
396/// The shortest `[blobs].gc_interval` that is honoured. Below this, collection stays OFF.
397///
398/// A sweep walks every blob in the store and deletes what the scope table does not name; running
399/// it every few seconds is all cost and no benefit, and `"1s"` is far more likely to be a mistake
400/// than an intent.
401pub const MIN_GC_INTERVAL_SECS: i64 = 60;
402
403/// The `[blobs]` table.
404#[derive(Debug, Default, Deserialize)]
405#[serde(default)]
406pub struct BlobsCfg {
407    /// How often to garbage-collect the app-blob store (#80), e.g. `"1h"`. **Absent means no
408    /// collection at all** — `<data_dir>/blobs/` grows monotonically, which is the behavior of
409    /// every release up to 0.42.0.
410    ///
411    /// Opt-in because a sweep deletes bytes the node holds but no scope names, which includes
412    /// every blob this node has FETCHED and not republished. Those are reclaimable — the fetch
413    /// already wrote the caller's `dest_path` and the store copy is a cache — but it means a
414    /// `blob_republish` of a hash fetched more than one interval ago fails. Silent background
415    /// deletion is the wrong default for a local-first tool, and that interaction makes it wrong
416    /// twice.
417    pub gc_interval: Option<String>,
418}
419
420impl BlobsCfg {
421    /// The GC interval in SECONDS, or `None` for "do not collect".
422    ///
423    /// **Deliberately NOT total, unlike every other duration accessor here.**
424    /// [`RosterCfg::grace_seconds`] and friends fall back to their default on a typo because a typo
425    /// must never disable a safety property. This one runs the other way: a value that fell back to
426    /// *some* interval would let `gc_interval = "1hh"` start deleting data the operator never
427    /// authorized. So an unparseable value — or one below [`MIN_GC_INTERVAL_SECS`] — leaves
428    /// collection OFF and warns.
429    ///
430    /// A below-floor value is refused rather than clamped UP: a clamped value reads back through
431    /// `status.storage.blobs_gc.interval_secs` as honoured, and is not.
432    pub fn gc_interval_seconds(&self) -> Option<u64> {
433        let raw = self.gc_interval.as_deref()?;
434        match parse_duration(raw) {
435            Ok(secs) if secs >= MIN_GC_INTERVAL_SECS => Some(secs as u64),
436            Ok(secs) => {
437                tracing::warn!(
438                    value = raw,
439                    secs,
440                    min = MIN_GC_INTERVAL_SECS,
441                    "[blobs].gc_interval is below the minimum; blob garbage collection is OFF"
442                );
443                None
444            }
445            Err(e) => {
446                tracing::warn!(
447                    value = raw,
448                    %e,
449                    "[blobs].gc_interval is unparseable; blob garbage collection is OFF"
450                );
451                None
452            }
453        }
454    }
455}
456
457/// Parse a duration string to SECONDS: a `d`/`h`/`m`/`s` suffix (days/hours/minutes/seconds) or a
458/// bare number (seconds). Trim + suffix-strip + checked multiply; rejects a
459/// negative/overflowing/garbage value as `Err` (the caller supplies the
460/// default). `u64` parse then a checked `i64` conversion: a negative grace is meaningless, so `-1`
461/// fails the `u64` parse and falls back to the default rather than becoming a negative window.
462// Reached only by the accessors above and the `org create --expires` porcelain
463// (`enrollcmd`, the operator-managed validity window — now across the crate seam, hence
464// `pub`; still `#[doc(hidden)]` at the module level). Pure parser — no state.
465pub fn parse_duration(s: &str) -> Result<i64, String> {
466    let s = s.trim();
467    let (num, mult) = if let Some(n) = s.strip_suffix('d') {
468        (n, 24 * 3600)
469    } else if let Some(n) = s.strip_suffix('h') {
470        (n, 3600)
471    } else if let Some(n) = s.strip_suffix('m') {
472        (n, 60)
473    } else if let Some(n) = s.strip_suffix('s') {
474        (n, 1)
475    } else {
476        (s, 1)
477    };
478    num.trim()
479        .parse::<u64>()
480        .ok()
481        .and_then(|v| v.checked_mul(mult))
482        .and_then(|v| i64::try_from(v).ok())
483        .ok_or_else(|| format!("unparseable duration: {s}"))
484}
485
486// figment::Error is ~208 bytes; boxing it would churn the API for a cold path.
487#[allow(clippy::result_large_err)]
488impl Config {
489    #[allow(dead_code)] // exercised by unit tests; config-string entry point for later tooling
490    pub fn from_toml_str(s: &str) -> Result<Self, figment::Error> {
491        Figment::new().merge(Toml::string(s)).extract()
492    }
493
494    /// Missing file → defaults (first run); malformed file → Err.
495    /// Callers must surface the Err — swallowing it silently reverts user choices.
496    pub fn load(path: &std::path::Path) -> Result<Self, figment::Error> {
497        Figment::new().merge(Toml::file(path)).extract()
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn empty_file_yields_spec_defaults() {
507        let c = Config::from_toml_str("").unwrap();
508        assert_eq!(c.network.relay_mode, "default");
509        assert_eq!(c.network.discovery_mode, "default");
510        assert_eq!(c.limits.rate_limit_per_min, 120);
511        assert_eq!(c.limits.max_inflight, 16);
512        assert_eq!(c.limits.max_sessions, 4);
513    }
514
515    #[test]
516    fn values_override_defaults() {
517        let c = Config::from_toml_str(
518            "[network]\nrelay_mode = \"disabled\"\n[limits]\nrate_limit_per_min = 60\n",
519        )
520        .unwrap();
521        assert_eq!(c.network.relay_mode, "disabled");
522        assert_eq!(c.limits.rate_limit_per_min, 60);
523        assert_eq!(c.limits.max_inflight, 16);
524    }
525
526    /// A legacy config carrying the removed `max_frame` key still loads (serde ignores unknown
527    /// fields) — the frame cap is a fixed constant now, not a tunable (see the `LimitsCfg` doc).
528    #[test]
529    fn legacy_max_frame_key_is_ignored_not_an_error() {
530        let c =
531            Config::from_toml_str("[limits]\nmax_frame = \"1MiB\"\nmax_sessions = 2\n").unwrap();
532        assert_eq!(c.limits.max_sessions, 2);
533    }
534
535    /// The self-hosting knobs parse: `custom` modes with their URL lists. (Validation —
536    /// custom-without-urls, unknown modes — lives in `daemon::net_plan`, tested there.)
537    #[test]
538    fn network_relay_and_discovery_urls_parse() {
539        let c = Config::from_toml_str(
540            "[network]\nrelay_mode = \"custom\"\nrelay_urls = [\"https://relay.acme.com\"]\n\
541             discovery_mode = \"custom\"\ndiscovery_urls = [\"https://dns.acme.com/pkarr\"]\n",
542        )
543        .unwrap();
544        assert_eq!(c.network.relay_mode, "custom");
545        assert_eq!(
546            c.network.relay_urls,
547            vec!["https://relay.acme.com".to_string()]
548        );
549        assert_eq!(c.network.discovery_mode, "custom");
550        assert_eq!(
551            c.network.discovery_urls,
552            vec!["https://dns.acme.com/pkarr".to_string()]
553        );
554        // Absent → empty lists (the defaults need no URLs).
555        let c = Config::from_toml_str("").unwrap();
556        assert!(c.network.relay_urls.is_empty() && c.network.discovery_urls.is_empty());
557    }
558
559    #[test]
560    fn missing_file_loads_defaults() {
561        let dir = tempfile::tempdir().unwrap();
562        let c = Config::load(&dir.path().join("nope.toml")).unwrap();
563        assert_eq!(c.network.relay_mode, "default");
564    }
565
566    #[test]
567    fn roster_url_and_poll_interval_parse_with_defaults() {
568        // No [roster] table → url None, poll 1h default.
569        let c = Config::from_toml_str("").unwrap();
570        assert!(c.roster.url.is_none());
571        assert_eq!(c.roster.poll_interval_seconds(), 3600);
572        // A configured url + poll interval.
573        let c = Config::from_toml_str(
574            "[roster]\nurl = \"https://intranet.acme.com/roster.json\"\npoll_interval = \"30m\"\n",
575        )
576        .unwrap();
577        assert_eq!(
578            c.roster.url.as_deref(),
579            Some("https://intranet.acme.com/roster.json")
580        );
581        assert_eq!(c.roster.poll_interval_seconds(), 30 * 60);
582        // An unparseable poll_interval falls back to the hourly default (never disables the poll).
583        let c = Config::from_toml_str("[roster]\npoll_interval = \"never\"\n").unwrap();
584        assert_eq!(c.roster.poll_interval_seconds(), 3600);
585        // The url is additive: setting only grace_period keeps url None + the default poll.
586        let c = Config::from_toml_str("[roster]\ngrace_period = \"24h\"\n").unwrap();
587        assert!(c.roster.url.is_none());
588        assert_eq!(c.roster.poll_interval_seconds(), 3600);
589    }
590
591    #[test]
592    fn roster_max_staleness_defaults_to_24h_and_parses() {
593        // No [roster] table → the 24h freshness bound (the default).
594        let c = Config::from_toml_str("").unwrap();
595        assert_eq!(c.roster.max_staleness_seconds(), 24 * 3600);
596        // A configured value parses (units, like grace_period).
597        let c = Config::from_toml_str("[roster]\nmax_staleness = \"6h\"\n").unwrap();
598        assert_eq!(c.roster.max_staleness_seconds(), 6 * 3600);
599        // An unparseable value falls back to the 24h default (never disables the freshness bound).
600        let c = Config::from_toml_str("[roster]\nmax_staleness = \"forever\"\n").unwrap();
601        assert_eq!(c.roster.max_staleness_seconds(), 24 * 3600);
602        // Additive: setting only grace_period keeps the 24h max_staleness default.
603        let c = Config::from_toml_str("[roster]\ngrace_period = \"48h\"\n").unwrap();
604        assert_eq!(c.roster.max_staleness_seconds(), 24 * 3600);
605    }
606
607    #[test]
608    fn roster_grace_defaults_to_72h_and_parses_units() {
609        // Absent `[roster]` → the 72h default.
610        let c = Config::from_toml_str("").unwrap();
611        assert_eq!(c.roster.grace_seconds(), 72 * 3600);
612        // Hours / days / minutes / seconds / bare-seconds all resolve to seconds.
613        for (body, want) in [
614            ("[roster]\ngrace_period = \"24h\"\n", 24 * 3600),
615            ("[roster]\ngrace_period = \"72h\"\n", 72 * 3600),
616            ("[roster]\ngrace_period = \"1d\"\n", 24 * 3600),
617            ("[roster]\ngrace_period = \"30m\"\n", 30 * 60),
618            ("[roster]\ngrace_period = \"90s\"\n", 90),
619            ("[roster]\ngrace_period = \"3600\"\n", 3600), // bare seconds
620        ] {
621            assert_eq!(
622                Config::from_toml_str(body).unwrap().roster.grace_seconds(),
623                want,
624                "{body}"
625            );
626        }
627    }
628
629    #[test]
630    fn roster_grace_unparseable_or_negative_falls_back_to_default() {
631        // A garbage / negative / overflowing grace never disables degraded serving — it defaults.
632        for body in [
633            "[roster]\ngrace_period = \"seventy-two hours\"\n",
634            "[roster]\ngrace_period = \"-5h\"\n",
635            "[roster]\ngrace_period = \"18446744073709551615d\"\n", // overflows the checked_mul
636            "[roster]\ngrace_period = \"\"\n",
637        ] {
638            assert_eq!(
639                Config::from_toml_str(body).unwrap().roster.grace_seconds(),
640                72 * 3600,
641                "{body}"
642            );
643        }
644    }
645
646    #[test]
647    fn services_parse_run_and_socket() {
648        let c = Config::from_toml_str(concat!(
649            "[services.notes]\nrun = [\"npx\", \"server\"]\nallow = [\"bob\"]\n",
650            "[services.kb]\nsocket = \"/run/kb.sock\"\nallow = [\"team-eng\"]\n",
651        ))
652        .unwrap();
653        let notes = c.services.get("notes").unwrap();
654        assert!(
655            matches!(notes.backend_result(), Ok(Backend::Run(cmd)) if cmd == &["npx".to_string(), "server".to_string()][..])
656        );
657        assert_eq!(notes.allow, vec!["bob".to_string()]);
658        assert!(
659            matches!(c.services.get("kb").unwrap().backend_result(), Ok(Backend::Socket(p)) if p == "/run/kb.sock")
660        );
661    }
662
663    #[test]
664    fn service_with_both_run_and_socket_is_an_error() {
665        let e = Config::from_toml_str("[services.x]\nrun=[\"a\"]\nsocket=\"/s\"\nallow=[]\n");
666        // exactly one backend kind is required — validate at access time.
667        assert!(
668            e.unwrap()
669                .services
670                .get("x")
671                .unwrap()
672                .backend_result()
673                .is_err()
674        );
675    }
676
677    #[test]
678    fn identity_reads_user_id_and_user_key() {
679        let toml = "[identity]\n\
680            org_id = \"acme\"\n\
681            org_root_pk = \"b64u:AAAA\"\n\
682            user_id = \"alice\"\n\
683            user_key = \"/home/alice/.config/mcpmesh/user.key\"\n";
684        let cfg: Config = toml::from_str(toml).unwrap();
685        assert_eq!(cfg.identity.user_id.as_deref(), Some("alice"));
686        assert_eq!(
687            cfg.identity.user_key.as_deref(),
688            Some(std::path::Path::new("/home/alice/.config/mcpmesh/user.key"))
689        );
690        // Absent → None (pure-pairing / operator-only node).
691        let bare: Config = toml::from_str("[identity]\n").unwrap();
692        assert!(bare.identity.user_id.is_none() && bare.identity.user_key.is_none());
693    }
694
695    /// #80: `[blobs].gc_interval` must FAIL SAFE. A knob that deletes bytes gets the opposite
696    /// convention from every other duration here.
697    ///
698    /// `grace_period`/`poll_interval`/`max_staleness` fall back to their default on a typo, because
699    /// a typo must never disable a safety property. Here a fallback to *some* interval would let
700    /// `"1hh"` start deleting data the operator never authorized, so the fallback is OFF.
701    #[test]
702    fn a_bad_gc_interval_leaves_collection_off_rather_than_guessing_one() {
703        let off: Config = toml::from_str("[identity]\n").unwrap();
704        assert_eq!(
705            off.blobs.gc_interval_seconds(),
706            None,
707            "absent means no collection at all — the behavior of every release up to 0.42.0"
708        );
709
710        let on: Config = toml::from_str("[blobs]\ngc_interval = \"2h\"\n").unwrap();
711        assert_eq!(on.blobs.gc_interval_seconds(), Some(7200));
712
713        for bad in ["1hh", "", "soon", "-1", "0.5h"] {
714            let c: Config = toml::from_str(&format!("[blobs]\ngc_interval = \"{bad}\"\n")).unwrap();
715            assert_eq!(
716                c.blobs.gc_interval_seconds(),
717                None,
718                "an unparseable interval ({bad:?}) must leave collection OFF, never fall back to \
719                 a default that deletes data"
720            );
721        }
722    }
723
724    /// Below the floor is REFUSED, not clamped up.
725    ///
726    /// A clamped value reads back through `status.storage.blobs_gc.interval_secs` as honoured and
727    /// is not. The boundary is pinned from both sides so a `>` / `>=` slip is visible.
728    #[test]
729    fn a_sub_minimum_gc_interval_is_refused_rather_than_clamped() {
730        let at: Config = toml::from_str(&format!(
731            "[blobs]\ngc_interval = \"{MIN_GC_INTERVAL_SECS}s\"\n"
732        ))
733        .unwrap();
734        assert_eq!(
735            at.blobs.gc_interval_seconds(),
736            Some(MIN_GC_INTERVAL_SECS as u64),
737            "exactly the floor is honoured"
738        );
739        for under in [MIN_GC_INTERVAL_SECS - 1, 1, 0] {
740            let c: Config =
741                toml::from_str(&format!("[blobs]\ngc_interval = \"{under}s\"\n")).unwrap();
742            assert_eq!(
743                c.blobs.gc_interval_seconds(),
744                None,
745                "{under}s is below the floor and must leave collection OFF, not be raised to it"
746            );
747        }
748    }
749}