#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Level {
Info,
Ok,
Warn,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Verdict {
pub level: Level,
pub message: String,
}
impl Level {
pub fn as_str(self) -> &'static str {
match self {
Level::Info => "info",
Level::Ok => "ok",
Level::Warn => "warn",
Level::Error => "error",
}
}
}
impl Verdict {
pub fn info(message: impl Into<String>) -> Self {
Self {
level: Level::Info,
message: message.into(),
}
}
pub fn ok(message: impl Into<String>) -> Self {
Self {
level: Level::Ok,
message: message.into(),
}
}
pub fn warn(message: impl Into<String>) -> Self {
Self {
level: Level::Warn,
message: message.into(),
}
}
pub fn error(message: impl Into<String>) -> Self {
Self {
level: Level::Error,
message: message.into(),
}
}
}
pub fn check_network(net: &crate::config::NetworkCfg) -> Verdict {
if net.relay_only {
if !cfg!(feature = "unstable-relay-only") {
return Verdict::warn(
"[network] relay_only = true is IGNORED — this binary lacks the `unstable-relay-only` cargo feature. Traffic takes whatever path iroh selects, which on a LAN with IPv6 is usually DIRECT. Do NOT treat this run as having exercised the relay.",
);
}
if net.relay_mode == "disabled" {
return Verdict::warn(
"[network] relay_only = true with a hermetic relay_mode — hermetic mode opens NO \
relay path, so there is nothing to select and traffic stays direct. relay_only \
cannot be honoured here.",
);
}
}
match crate::daemon::net_plan(net) {
Err(e) => Verdict::error(format!(
"[network] invalid — the daemon will refuse to start: {e}"
)),
Ok(crate::daemon::NetPlan::Hermetic) => {
if net.discovery_mode != "default" || !net.discovery_urls.is_empty() {
Verdict::warn(
"relay_mode = \"disabled\" is hermetic (no relay, no discovery) — the \
discovery_mode/discovery_urls knobs are ignored",
)
} else {
Verdict::ok("networking disabled (hermetic) — no relay, no discovery")
}
}
Ok(crate::daemon::NetPlan::Mesh { .. }) => {
let relay_self = net.relay_mode == "custom";
let disc_self = net.discovery_mode == "custom";
if relay_self ^ disc_self {
let (on, off) = if relay_self {
("relay", "discovery")
} else {
("discovery", "relay")
};
Verdict::warn(format!(
"self-hosting {on} but not {off} — an incomplete metadata mitigation (\u{a7}10.3); \
self-host both or neither"
))
} else if relay_self && disc_self {
Verdict::ok("self-hosting relay and discovery")
} else {
Verdict::ok("using default relay and discovery")
}
}
}
}
pub fn check_identity_conflict(epoch: Option<i64>, now: i64) -> Option<Verdict> {
let seen = epoch?;
let mins = (now - seen).max(0) / 60;
let ago = match mins {
0 => "less than a minute".to_string(),
1 => "1 minute".to_string(),
m if m < 60 => format!("{m} minutes"),
m if m < 120 => "1 hour".to_string(),
m if m < 1440 => format!("{} hours", m / 60),
m => format!("{} days", m / 1440),
};
Some(Verdict::warn(format!(
"another endpoint was seen presenting this node's identity {ago} ago — two nodes booted \
from COPIES of one mesh root cannot both use the relay, and this node's peers will \
appear unreachable while that lasts. Stop the duplicate, or give it its own identity. \
(Advisory: the report comes from a relay and is not authenticated.)"
)))
}
pub fn check_relays(
configured: &[String],
live: Option<&[mcpmesh_local_api::RelayInfo]>,
) -> Verdict {
let Some(live) = live else {
return Verdict::info(format!(
"{} relay(s) configured — live connection state unavailable (no running mesh daemon \
to ask)",
configured.len()
));
};
let norm = crate::daemon::normalize_relay_url;
let connected: std::collections::BTreeSet<String> = live
.iter()
.filter(|r| r.connected)
.map(|r| norm(&r.url).unwrap_or_else(|| r.url.clone()))
.collect();
let dead: Vec<&str> = configured
.iter()
.filter(|u| !norm(u).is_some_and(|n| connected.contains(&n)))
.map(String::as_str)
.collect();
if dead.is_empty() {
return Verdict::ok(format!(
"all {} configured relay(s) connected",
configured.len()
));
}
if dead.len() == configured.len() {
return Verdict::warn(format!(
"NO configured relay is connected ({}) — this node has no relay path right now, so \
WAN peers are unreachable until one answers. LAN/direct paths are unaffected.",
dead.join(", ")
));
}
Verdict::warn(format!(
"{} of {} configured relay(s) not connected ({}) — traffic still flows over the healthy \
ones, but every boot pays a bounded penalty waiting on the dead entry. Remove it, or fix \
it, to get that time back (#125).",
dead.len(),
configured.len(),
dead.join(", ")
))
}
pub fn check_roster_url(org_root_pinned: bool, roster_url: Option<&str>) -> Verdict {
match (org_root_pinned, roster_url) {
(false, _) => Verdict::ok("pairing mode — no roster URL needed"),
(true, Some(_)) => Verdict::ok("roster URL configured"),
(true, None) => Verdict::warn(
"roster mode but no [roster].url — this node degrades after max_staleness with no way to \
re-confirm currency; set [roster].url",
),
}
}
pub fn check_allow_principals(org_root_pinned: bool, bare: &[(String, String)]) -> Verdict {
if bare.is_empty() {
return Verdict::ok("allow entries are stable principals");
}
if org_root_pinned {
return Verdict::info(
"bare allow entries are treated as roster names; any that were pre-0.8.0 pairing-nickname grants no longer admit — re-pair those peers",
);
}
let listed: Vec<String> = bare
.iter()
.map(|(svc, entry)| format!("{svc}:\"{entry}\""))
.collect();
Verdict::warn(format!(
"nickname-keyed grants no longer admit anyone (0.8.0): {} — re-pair the peer \
(grants are now written as stable principals) or replace the entry with the \
peer's b64u:/eid: principal",
listed.join(", ")
))
}
pub fn check_key_perms(present: bool, mode: u32) -> Verdict {
if !present {
return Verdict::ok("absent (not yet created)");
}
if mode & 0o022 != 0 {
return Verdict::error(format!(
"group/world-writable (mode {mode:04o}) — an attacker could replace this key; run `chmod 600`"
));
}
if mode & 0o044 != 0 {
return Verdict::warn(format!(
"group/world-readable (mode {mode:04o}) — a private key should be 0600; run `chmod 600`"
));
}
Verdict::ok(format!("0600 (mode {mode:04o})"))
}
pub fn check_runtime_dir(present: bool, mode: u32, uid: u32, our_uid: u32) -> Verdict {
if !present {
return Verdict::ok("not present (daemon not started)");
}
if uid != our_uid {
return Verdict::error(format!(
"owned by uid {uid}, not you (uid {our_uid}) — the daemon refuses to bind here; investigate"
));
}
if mode & 0o077 != 0 {
return Verdict::error(format!(
"mode {mode:04o} is group/world-accessible — the control-socket dir must be 0700; run `chmod 700`"
));
}
Verdict::ok(format!("0700, owned by you (mode {mode:04o})"))
}
fn friendly_age(secs: i64) -> String {
let s = secs.max(0);
if s >= 86_400 {
format!("{}d", s / 86_400)
} else if s >= 3_600 {
format!("{}h", s / 3_600)
} else if s >= 60 {
format!("{}m", s / 60)
} else {
format!("{s}s")
}
}
pub fn check_roster_freshness(
roster_mode: bool,
daemon_state: Option<&str>,
staleness_secs: Option<i64>,
max_staleness_secs: i64,
) -> Verdict {
if !roster_mode {
return Verdict::ok("pairing mode — no roster");
}
match daemon_state {
Some("stopped") => Verdict::error(
"roster serving STOPPED — stale/expired past grace; re-confirm currency \
(URL poll / gossip / manual install)",
),
Some("degraded") => {
Verdict::warn("roster DEGRADED — serving in the grace window; re-confirm currency soon")
}
Some("pending") => Verdict::warn(
"joined but not yet approved — no roster installed; ask your operator to approve your join code",
),
Some("approved") => match staleness_secs {
Some(age) if age > max_staleness_secs => Verdict::warn(format!(
"roster approved but last confirmed {} ago (> max_staleness {}) — a refresh is due",
friendly_age(age),
friendly_age(max_staleness_secs)
)),
Some(age) => Verdict::ok(format!(
"roster approved — last confirmed {} ago",
friendly_age(age)
)),
None => Verdict::ok("roster approved"),
},
Some(other) => Verdict::warn(format!("roster state '{other}' — check `mcpmesh status`")),
None => Verdict::warn(
"roster mode but the daemon is not running — install/freshness state unknown; \
start it to check",
),
}
}
pub fn check_config(parse_ok: bool, roster_mode: bool, has_org_id: bool) -> Verdict {
if !parse_ok {
return Verdict::error(
"config.toml does not parse — fix the syntax (run `mcpmesh status` to see the error)",
);
}
if roster_mode && !has_org_id {
return Verdict::warn(
"roster mode (org root pinned) but [identity].org_id is missing — the pin is incomplete; \
re-run `mcpmesh join` (joiner) or `mcpmesh org create` (operator)",
);
}
Verdict::ok("config parses; required fields present")
}
pub fn check_daemon(reachable: bool) -> Verdict {
if reachable {
Verdict::ok("daemon running and reachable")
} else {
Verdict::warn(
"daemon not running — start it with any porcelain verb (e.g. `mcpmesh status`)",
)
}
}
pub fn worst_level(findings: &[(&str, Verdict)]) -> Level {
if findings.iter().any(|(_, v)| v.level == Level::Error) {
Level::Error
} else if findings.iter().any(|(_, v)| v.level == Level::Warn) {
Level::Warn
} else {
Level::Ok
}
}
pub fn render_report(findings: &[(&str, Verdict)]) -> Vec<String> {
let mut lines: Vec<String> = findings
.iter()
.map(|(label, v)| {
let tag = match v.level {
Level::Info => "INFO",
Level::Ok => "OK ",
Level::Warn => "WARN",
Level::Error => "ERR ",
};
format!("[{tag}] {label}: {}", v.message)
})
.collect();
let warns = findings
.iter()
.filter(|(_, v)| v.level == Level::Warn)
.count();
let errors = findings
.iter()
.filter(|(_, v)| v.level == Level::Error)
.count();
lines.push(String::new());
lines.push(match (errors, warns) {
(0, 0) => "doctor: all checks passed".to_string(),
(0, w) => format!("doctor: {w} warning(s) — see above"),
(e, w) => format!("doctor: {e} error(s), {w} warning(s) — see above"),
});
lines
}
use crate::util::epoch_now_i64 as epoch_now;
#[cfg(unix)]
fn stat_mode(path: &std::path::Path) -> (bool, u32) {
use std::os::unix::fs::PermissionsExt;
match std::fs::symlink_metadata(path) {
Ok(m) => (true, m.permissions().mode() & 0o777),
Err(_) => (false, 0),
}
}
#[cfg(unix)]
fn stat_dir(path: &std::path::Path) -> (bool, u32, u32) {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
match std::fs::symlink_metadata(path) {
Ok(m) => (true, m.permissions().mode() & 0o777, m.uid()),
Err(_) => (false, 0, 0),
}
}
#[cfg(windows)]
fn stat_present(path: &std::path::Path) -> bool {
std::fs::symlink_metadata(path).is_ok()
}
struct DoctorInputs {
parse_ok: bool,
org_root_pinned: bool,
has_org_id: bool,
network: crate::config::NetworkCfg,
roster_url: Option<String>,
bare_allow_entries: Vec<(String, String)>,
max_staleness_secs: i64,
device_key: (bool, u32),
user_key: (bool, u32),
org_root_key: (bool, u32),
runtime_dir: (bool, u32, u32),
our_uid: u32,
perm_lints_apply: bool,
staleness_secs: Option<i64>,
daemon_reachable: bool,
daemon_roster_state: Option<String>,
live_relays: Option<Vec<mcpmesh_local_api::RelayInfo>>,
identity_conflict_epoch: Option<i64>,
}
fn probe_daemon() -> (
bool,
Option<String>,
Option<Vec<mcpmesh_local_api::RelayInfo>>,
Option<i64>,
) {
let Ok(socket) = mcpmesh_trust::paths::default_endpoint() else {
return (false, None, None, None);
};
let Ok(rt) = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
else {
return (false, None, None, None);
};
rt.block_on(async move {
match crate::client::connect_control(&socket).await {
Ok(mut client) => match client.status().await {
Ok(s) => {
let net = s.self_network;
(
true,
s.roster.map(|r| r.state),
net.as_ref().map(|n| n.relays.clone()),
net.and_then(|n| n.identity_conflict_epoch),
)
}
Err(_) => (true, None, None, None),
},
Err(_) => (false, None, None, None),
}
})
}
fn gather() -> DoctorInputs {
use mcpmesh_trust::paths;
#[cfg(unix)]
let our_uid = rustix::process::geteuid().as_raw();
#[cfg(windows)]
let our_uid = 0u32;
let cfg_result = paths::default_config_path()
.ok()
.and_then(|p| crate::config::Config::load(&p).ok());
let parse_ok = cfg_result.is_some();
let cfg = cfg_result.unwrap_or_default();
let device_key_path = cfg
.identity
.device_key
.clone()
.or_else(|| paths::default_device_key_path().ok())
.unwrap_or_default();
let user_key_path = cfg
.identity
.user_key
.clone()
.or_else(|| paths::default_user_key_path().ok())
.unwrap_or_default();
#[cfg(unix)]
let device_key = stat_mode(&device_key_path);
#[cfg(windows)]
let device_key = (stat_present(&device_key_path), 0u32);
#[cfg(unix)]
let user_key = stat_mode(&user_key_path);
#[cfg(windows)]
let user_key = (stat_present(&user_key_path), 0u32);
let org_root_key_path = paths::default_org_root_key_path().unwrap_or_default();
#[cfg(unix)]
let org_root_key = stat_mode(&org_root_key_path);
#[cfg(windows)]
let org_root_key = (stat_present(&org_root_key_path), 0u32);
let runtime_dir = match paths::runtime_dir() {
Ok(d) => {
#[cfg(unix)]
{
stat_dir(&d)
}
#[cfg(windows)]
{
(stat_present(&d), 0u32, 0u32)
}
}
Err(_) => (false, 0, 0),
};
let staleness_secs = crate::roster::freshness::FreshnessStore::new(
paths::default_roster_confirmed_path().unwrap_or_default(),
)
.load()
.ok()
.flatten()
.map(|lc| epoch_now() - lc);
let (daemon_reachable, daemon_roster_state, live_relays, identity_conflict_epoch) =
probe_daemon();
DoctorInputs {
parse_ok,
org_root_pinned: cfg.identity.org_root_pk.is_some(),
bare_allow_entries: cfg
.services
.iter()
.flat_map(|(name, svc)| {
svc.allow
.iter()
.filter(|a| !a.starts_with("b64u:") && !a.starts_with("eid:"))
.map(|a| (name.clone(), a.clone()))
.collect::<Vec<_>>()
})
.collect(),
has_org_id: cfg.identity.org_id.is_some(),
network: cfg.network.clone(),
roster_url: cfg.roster.url.clone(),
max_staleness_secs: cfg.roster.max_staleness_seconds(),
device_key,
user_key,
org_root_key,
runtime_dir,
our_uid,
perm_lints_apply: cfg!(unix),
staleness_secs,
daemon_reachable,
daemon_roster_state,
live_relays,
identity_conflict_epoch,
}
}
fn findings(inp: &DoctorInputs) -> Vec<(&'static str, Verdict)> {
let mut out = vec![
(
"config",
check_config(inp.parse_ok, inp.org_root_pinned, inp.has_org_id),
),
("daemon", check_daemon(inp.daemon_reachable)),
("self-hosting", check_network(&inp.network)),
(
"roster-url",
check_roster_url(inp.org_root_pinned, inp.roster_url.as_deref()),
),
(
"allow vocabulary",
check_allow_principals(inp.org_root_pinned, &inp.bare_allow_entries),
),
(
"roster-freshness",
check_roster_freshness(
inp.org_root_pinned,
inp.daemon_roster_state.as_deref(),
inp.staleness_secs,
inp.max_staleness_secs,
),
),
];
if inp.network.relay_mode == "custom" && !inp.network.relay_urls.is_empty() {
out.push((
"relays",
check_relays(&inp.network.relay_urls, inp.live_relays.as_deref()),
));
}
if let Some(v) = check_identity_conflict(inp.identity_conflict_epoch, epoch_now()) {
out.push(("identity", v));
}
if inp.perm_lints_apply {
out.push((
"device.key",
check_key_perms(inp.device_key.0, inp.device_key.1),
));
out.push(("user.key", check_key_perms(inp.user_key.0, inp.user_key.1)));
out.push((
"org-root.key",
check_key_perms(inp.org_root_key.0, inp.org_root_key.1),
));
out.push((
"runtime-dir",
check_runtime_dir(
inp.runtime_dir.0,
inp.runtime_dir.1,
inp.runtime_dir.2,
inp.our_uid,
),
));
} else {
out.push((
"file-permission lints",
Verdict::info("n/a on Windows (user-profile ACLs protect %APPDATA%/%LOCALAPPDATA%)"),
));
}
out
}
pub fn run_doctor(json: bool) -> anyhow::Result<()> {
let inputs = gather();
let findings = findings(&inputs);
if json {
println!("{}", crate::json::doctor_json(&findings));
} else {
for line in render_report(&findings) {
println!("{line}");
}
}
if worst_level(&findings) == Level::Error {
std::process::exit(1);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn net(
relay: &str,
relay_urls: &[&str],
disc: &str,
disc_urls: &[&str],
) -> crate::config::NetworkCfg {
crate::config::NetworkCfg {
relay_mode: relay.into(),
relay_urls: relay_urls.iter().map(|s| s.to_string()).collect(),
discovery_mode: disc.into(),
discovery_urls: disc_urls.iter().map(|s| s.to_string()).collect(),
relay_only: false,
}
}
fn base_inputs() -> DoctorInputs {
DoctorInputs {
parse_ok: true,
bare_allow_entries: Vec::new(),
org_root_pinned: false,
has_org_id: false,
network: net("default", &[], "default", &[]),
roster_url: None,
max_staleness_secs: 86_400,
device_key: (true, 0o600),
user_key: (true, 0o600),
org_root_key: (true, 0o600),
runtime_dir: (true, 0o700, 0),
our_uid: 0,
perm_lints_apply: false,
staleness_secs: None,
daemon_reachable: true,
daemon_roster_state: None,
live_relays: None,
identity_conflict_epoch: None,
}
}
fn relay(url: &str, connected: bool) -> mcpmesh_local_api::RelayInfo {
mcpmesh_local_api::RelayInfo {
url: url.into(),
connected,
}
}
#[test]
fn relay_check_names_a_dead_relay() {
let urls = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let v = check_relays(&urls(&["https://a.example"]), None);
assert_eq!(v.level, Level::Info, "{v:?}");
assert!(
v.message.contains("unavailable"),
"and must not imply the relays are fine: {}",
v.message
);
let v = check_relays(
&urls(&["https://a.example", "https://b.example"]),
Some(&[
relay("https://a.example", true),
relay("https://b.example", true),
]),
);
assert_eq!(v.level, Level::Ok, "{v:?}");
let v = check_relays(
&urls(&["https://dead.example", "https://ok.example"]),
Some(&[
relay("https://dead.example", false),
relay("https://ok.example", true),
]),
);
assert_eq!(v.level, Level::Warn, "{v:?}");
assert!(
v.message.contains("https://dead.example"),
"the dead relay must be NAMED, not counted: {}",
v.message
);
assert!(
!v.message.contains("https://ok.example"),
"and the healthy one must not be blamed: {}",
v.message
);
let v = check_relays(
&urls(&["https://ghost.example", "https://ok.example"]),
Some(&[relay("https://ok.example", true)]),
);
assert_eq!(v.level, Level::Warn, "{v:?}");
assert!(v.message.contains("https://ghost.example"), "{}", v.message);
let v = check_relays(
&urls(&["https://a.example"]),
Some(&[relay("https://a.example", false)]),
);
assert_eq!(v.level, Level::Warn, "{v:?}");
assert!(
v.message.contains("NO configured relay"),
"no-path is not the same finding as one-of-several down: {}",
v.message
);
let v = check_relays(
&urls(&["https://relay.acme.com", "https://relay.acme.com:8443"]),
Some(&[
relay("https://relay.acme.com", false),
relay("https://relay.acme.com:8443", true),
]),
);
assert_eq!(
v.level,
Level::Warn,
"a dead relay must not be masked by a healthy one it prefixes: {v:?}"
);
assert!(
v.message.contains("https://relay.acme.com,")
|| v.message.ends_with("relay.acme.com)")
|| v.message.contains("(https://relay.acme.com)"),
"and the DEAD one is the one named: {}",
v.message
);
let v = check_relays(
&urls(&["https://Relay.Example.COM/"]),
Some(&[relay("https://relay.example.com", true)]),
);
assert_eq!(
v.level,
Level::Ok,
"a re-spelling of the same relay must not read as a second, dead one: {v:?}"
);
}
#[test]
fn the_identity_check_speaks_only_when_it_has_seen_something() {
assert!(
check_identity_conflict(None, 1_753_900_000).is_none(),
"no observation must produce NO finding — never a green uniqueness claim"
);
let v = check_identity_conflict(Some(1_753_900_000), 1_753_900_120)
.expect("an observation must produce a finding");
assert_eq!(v.level, Level::Warn, "{v:?}");
assert!(
v.message.contains("2 minutes"),
"the age tells an operator whether this is live or historical: {}",
v.message
);
assert!(
v.message.contains("not authenticated"),
"the claim is relay-attested; saying so is what stops it being trusted as proof: {}",
v.message
);
let mut inp = base_inputs();
assert!(
!findings(&inp).iter().any(|(l, _)| *l == "identity"),
"an unobserved node gets no identity line at all"
);
inp.identity_conflict_epoch = Some(1_753_900_000);
assert!(
findings(&inp).iter().any(|(l, _)| *l == "identity"),
"an observed conflict must reach the report"
);
}
#[test]
fn the_relay_finding_is_custom_mode_only() {
let labels = |n: crate::config::NetworkCfg| {
let mut inp = base_inputs();
inp.network = n;
findings(&inp)
.into_iter()
.map(|(l, _)| l)
.collect::<Vec<_>>()
};
let leftovers = &["https://stale.example"];
assert!(
!labels(net("default", leftovers, "default", &[])).contains(&"relays"),
"leftover relay_urls under relay_mode=default are IGNORED by the daemon — a finding \
about them is a confidently wrong line"
);
assert!(
!labels(net("disabled", leftovers, "default", &[])).contains(&"relays"),
"hermetic mode opens no relay path at all"
);
assert!(
labels(net("custom", leftovers, "default", &[])).contains(&"relays"),
"and custom mode — where the URLs are actually used — must report"
);
assert!(
!labels(net("custom", &[], "default", &[])).contains(&"relays"),
"custom with no URLs cannot boot a mesh; nothing to name"
);
}
#[test]
fn doctor_warns_when_relay_only_cannot_be_honoured() {
#[cfg(not(feature = "unstable-relay-only"))]
{
let v = check_network(&cfg_with_relay_only("default", true));
assert_eq!(
v.level,
Level::Warn,
"an ignored relay_only must warn: {v:?}"
);
assert!(
v.message.contains("IGNORED"),
"and must say so plainly: {}",
v.message
);
}
let v = check_network(&cfg_with_relay_only("disabled", true));
assert_eq!(
v.level,
Level::Warn,
"relay_only + hermetic must warn: {v:?}"
);
let plain = check_network(&cfg_with_relay_only("disabled", false));
assert!(
!plain.message.contains("relay_only"),
"no relay_only noise when unset: {}",
plain.message
);
}
fn cfg_with_relay_only(relay_mode: &str, relay_only: bool) -> crate::config::NetworkCfg {
crate::config::NetworkCfg {
relay_mode: relay_mode.into(),
relay_urls: vec![],
discovery_mode: "default".into(),
discovery_urls: vec![],
relay_only,
}
}
#[test]
fn network_check_reports_only_real_states() {
const RELAY: &[&str] = &["https://relay.acme.com"];
const DISC: &[&str] = &["https://dns.acme.com/pkarr"];
assert_eq!(
check_network(&net("default", &[], "default", &[])).level,
Level::Ok
);
assert_eq!(
check_network(&net("custom", RELAY, "custom", DISC)).level,
Level::Ok
);
let relay_only = check_network(&net("custom", RELAY, "default", &[]));
assert_eq!(relay_only.level, Level::Warn);
assert!(relay_only.message.contains("relay") && relay_only.message.contains("discovery"));
assert_eq!(
check_network(&net("default", &[], "custom", DISC)).level,
Level::Warn
);
assert_eq!(
check_network(&net("disabled", &[], "default", &[])).level,
Level::Ok
);
assert_eq!(
check_network(&net("disabled", &[], "custom", DISC)).level,
Level::Warn
);
assert_eq!(
check_network(&net("custom", &[], "default", &[])).level,
Level::Error
);
assert_eq!(
check_network(&net("default", &[], "custom", &[])).level,
Level::Error
);
assert_eq!(
check_network(&net("default", &[], "local", &[])).level,
Level::Error
);
assert_eq!(
check_network(&net("relayless", &[], "default", &[])).level,
Level::Error
);
}
#[test]
fn roster_url_warns_only_for_a_urlless_roster_node() {
assert_eq!(check_roster_url(false, None).level, Level::Ok);
let bare = vec![("kb".to_string(), "bob".to_string())];
assert_eq!(check_allow_principals(false, &[]).level, Level::Ok);
assert_eq!(check_allow_principals(true, &bare).level, Level::Info);
let v = check_allow_principals(false, &bare);
assert_eq!(v.level, Level::Warn);
assert!(
v.message.contains("re-pair") && v.message.contains("kb:\"bob\""),
"actionable + names the entry: {}",
v.message
);
assert_eq!(check_roster_url(false, Some("https://x")).level, Level::Ok);
assert_eq!(
check_roster_url(true, Some("https://intranet/roster.json")).level,
Level::Ok
);
let w = check_roster_url(true, None);
assert_eq!(w.level, Level::Warn);
assert!(w.message.contains("max_staleness") && w.message.contains("[roster].url"));
}
#[test]
fn key_perms_tiers_absent_ok_readable_warn_writable_error() {
assert_eq!(check_key_perms(false, 0).level, Level::Ok);
assert_eq!(check_key_perms(true, 0o600).level, Level::Ok);
let r = check_key_perms(true, 0o640);
assert_eq!(r.level, Level::Warn);
assert!(r.message.contains("readable") && r.message.contains("0640"));
assert_eq!(check_key_perms(true, 0o604).level, Level::Warn);
let w = check_key_perms(true, 0o660);
assert_eq!(w.level, Level::Error);
assert!(w.message.contains("writable") && w.message.contains("0660"));
assert_eq!(check_key_perms(true, 0o666).level, Level::Error);
}
#[test]
fn runtime_dir_absent_ok_foreign_owner_error_loose_mode_error() {
assert_eq!(check_runtime_dir(false, 0, 0, 1000).level, Level::Ok);
assert_eq!(check_runtime_dir(true, 0o700, 1000, 1000).level, Level::Ok);
let foreign = check_runtime_dir(true, 0o700, 0, 1000);
assert_eq!(foreign.level, Level::Error);
assert!(foreign.message.contains("uid"));
let loose = check_runtime_dir(true, 0o755, 1000, 1000);
assert_eq!(loose.level, Level::Error);
assert!(loose.message.contains("0755"));
}
#[test]
fn friendly_age_compacts_seconds_to_the_largest_unit() {
assert_eq!(friendly_age(45), "45s");
assert_eq!(friendly_age(30 * 60), "30m");
assert_eq!(friendly_age(90 * 60), "1h");
assert_eq!(friendly_age(36 * 3600), "1d");
assert_eq!(friendly_age(3 * 86_400), "3d");
assert_eq!(friendly_age(-5), "0s");
}
#[test]
fn roster_freshness_maps_state_words_and_staleness() {
let max = 24 * 3600;
assert_eq!(
check_roster_freshness(false, Some("approved"), None, max).level,
Level::Ok
);
assert_eq!(
check_roster_freshness(true, Some("stopped"), None, max).level,
Level::Error
);
assert_eq!(
check_roster_freshness(true, Some("degraded"), None, max).level,
Level::Warn
);
assert_eq!(
check_roster_freshness(true, Some("pending"), None, max).level,
Level::Warn
);
let fresh = check_roster_freshness(true, Some("approved"), Some(3600), max);
assert_eq!(fresh.level, Level::Ok);
assert!(fresh.message.contains("1h"));
assert_eq!(
check_roster_freshness(true, Some("approved"), Some(max + 3600), max).level,
Level::Warn
);
assert_eq!(
check_roster_freshness(true, None, None, max).level,
Level::Warn
);
}
#[test]
fn config_check_flags_parse_failure_and_missing_org_id() {
assert_eq!(check_config(false, false, false).level, Level::Error);
assert_eq!(check_config(true, false, false).level, Level::Ok);
assert_eq!(check_config(true, true, true).level, Level::Ok);
assert_eq!(check_config(true, true, false).level, Level::Warn);
}
#[test]
fn daemon_check_reports_running_or_not() {
assert_eq!(check_daemon(true).level, Level::Ok);
let down = check_daemon(false);
assert_eq!(down.level, Level::Warn);
assert!(down.message.contains("not running"));
}
#[test]
fn worst_level_is_the_max_severity() {
let ok = [("a", Verdict::ok("x")), ("b", Verdict::ok("y"))];
assert_eq!(worst_level(&ok), Level::Ok);
let warn = [("a", Verdict::ok("x")), ("b", Verdict::warn("y"))];
assert_eq!(worst_level(&warn), Level::Warn);
let err = [("a", Verdict::warn("x")), ("b", Verdict::error("y"))];
assert_eq!(worst_level(&err), Level::Error);
assert_eq!(worst_level(&[]), Level::Ok);
}
#[test]
fn render_report_tags_each_line_and_summarizes() {
let findings = vec![
(
"config",
Verdict::ok("config parses; required fields present"),
),
(
"device.key",
Verdict::error("group/world-writable (mode 0666) — run `chmod 600`"),
),
(
"self-hosting",
Verdict::warn("self-hosting relay but not discovery"),
),
];
let lines = render_report(&findings);
assert!(lines[0].starts_with("[OK ] config:"));
assert!(lines[1].starts_with("[ERR ] device.key:"));
assert!(lines[2].starts_with("[WARN] self-hosting:"));
let summary = lines.last().unwrap();
assert!(
summary.contains("1 error") && summary.contains("1 warning"),
"{summary}"
);
}
#[test]
fn report_leaks_no_transport_vocabulary() {
let sample_id = mcpmesh_net::iroh::SecretKey::from_bytes(&[9u8; 32])
.public()
.to_string();
let findings = vec![
("config", check_config(true, true, true)),
("daemon", check_daemon(false)),
(
"self-hosting",
check_network(&net(
"custom",
&["https://relay.example.com"],
"default",
&[],
)),
),
("roster-url", check_roster_url(true, None)),
(
"roster-freshness",
check_roster_freshness(true, Some("degraded"), Some(90_000), 86_400),
),
("device.key", check_key_perms(true, 0o644)),
("user.key", check_key_perms(true, 0o666)),
("org-root.key", check_key_perms(false, 0)),
("runtime-dir", check_runtime_dir(true, 0o755, 1000, 1000)),
];
let rendered = render_report(&findings).join("\n");
assert!(
!rendered.contains(&sample_id),
"doctor leaked an EndpointId: {rendered}"
);
for term in [
"b64u:",
"EndpointId",
"ALPN",
"ticket",
"mcpmesh/mcp/1",
"mcpmesh/blob/1",
"pubkey",
"blake3:",
] {
assert!(
!rendered.contains(term),
"doctor leaked '{term}': {rendered}"
);
}
}
#[test]
fn windows_shaped_inputs_replace_perm_findings_with_one_info_line() {
let inp = DoctorInputs {
parse_ok: true,
bare_allow_entries: Vec::new(),
org_root_pinned: false,
has_org_id: false,
network: net("default", &[], "default", &[]),
roster_url: None,
max_staleness_secs: 86_400,
device_key: (true, 0o666),
user_key: (true, 0o666),
org_root_key: (true, 0o666),
runtime_dir: (true, 0o777, 1000),
our_uid: 0,
perm_lints_apply: false,
staleness_secs: None,
daemon_reachable: true,
daemon_roster_state: None,
live_relays: None,
identity_conflict_epoch: None,
};
let out = findings(&inp);
for label in ["device.key", "user.key", "org-root.key", "runtime-dir"] {
assert!(
!out.iter().any(|(l, _)| *l == label),
"windows report must not carry the '{label}' permission finding"
);
}
let info: Vec<_> = out.iter().filter(|(_, v)| v.level == Level::Info).collect();
assert_eq!(info.len(), 1, "expected exactly one Info line: {out:?}");
let (label, verdict) = info[0];
assert_eq!(*label, "file-permission lints");
assert!(
verdict.message.contains("n/a on Windows"),
"unexpected Info message: {}",
verdict.message
);
assert_eq!(worst_level(&out), Level::Ok);
}
}