OXYGENHUB

Coverage / crates/oxy-core/src/repo/doctor.rs 73.4%

218 of 297 lines run, 18 of 44 functions · run #382 on main · run missed

1
//! `oxy doctor` (spec §19.1): check what commonly goes wrong around a
2
//! repository, the identity, the configuration and the remotes, and say
3
//! what to do about each problem. It changes nothing.
4
5
use super::*;
6
7
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
8
pub enum Health {
9
    Ok,
10
    Warn,
11
    Fail,
12
}
13
14
#[derive(Clone, Debug, PartialEq, Eq)]
15
pub struct Check {
16
    /// What the check is about: `repository`, `identity`, `author`, `remote NAME`.
17
    pub area: String,
18
    pub health: Health,
19
    pub message: String,
20
    /// The next step, for a warning or failure.
21
    pub hint: Option<String>,
22
}
23
24
#[derive(Clone, Debug, Default, PartialEq, Eq)]
25
pub struct DoctorReport {
26
    pub checks: Vec<Check>,
27
}
28
29
impl DoctorReport {
301×
    pub fn worst(&self) -> Health {
311×
        self.checks
321×
            .iter()
331×
            .map(|c| c.health)
341×
            .max()
351×
            .unwrap_or(Health::Ok)
361×
    }
37
3842×
    fn push(
3942×
        &mut self,
4042×
        area: impl Into<String>,
4142×
        health: Health,
4242×
        message: impl Into<String>,
4342×
        hint: Option<String>,
4442×
    ) {
4542×
        self.checks.push(Check {
4642×
            area: area.into(),
4742×
            health,
4842×
            message: message.into(),
4942×
            hint,
5042×
        });
5142×
    }
52
}
53
54
/// A lock file older than this is left over from a process that died.
55
const STALE_LOCK_SECS: u64 = 60;
56
57
impl Repo {
58
    /// Run every check; `online` also contacts each remote.
596×
    pub fn doctor(&self, online: bool) -> DoctorReport {
606×
        let mut rep = DoctorReport::default();
616×
        self.doctor_repository(&mut rep);
626×
        self.doctor_identity(&mut rep);
636×
        self.doctor_author(&mut rep);
646×
        self.doctor_portability(&mut rep);
656×
        self.doctor_trust(&mut rep);
666×
        for name in self.config.remotes.keys() {
676×
            self.doctor_remote(&mut rep, name, online);
686×
        }
696×
        if self.config.remotes.is_empty() && !self.bare {
704×
            rep.push("remotes", Health::Ok, "none configured", None);
714×
        }
726×
        rep
736×
    }
74
756×
    fn doctor_repository(&self, rep: &mut DoctorReport) {
766×
        match self.head_op() {
776×
            Ok(_) => rep.push(
78
                "repository",
796×
                Health::Ok,
806×
                format!(
81
                    "{} at {}",
826×
                    if self.bare {
830×
                        "bare repository"
84
                    } else {
856×
                        "repository"
86
                    },
876×
                    self.root.display()
88
                ),
896×
                None,
90
            ),
910×
            Err(e) => rep.push(
92
                "repository",
930×
                Health::Fail,
940×
                format!("the operation log does not open: {e}"),
950×
                Some("run `oxy fsck` for details".into()),
96
            ),
97
        }
986×
        let mut locks = vec![self.oxy.join("ops").join("lock")];
996×
        if let Ok(rd) = fs::read_dir(self.oxy.join("workspaces")) {
1006×
            locks.extend(rd.flatten().map(|e| e.path().join("lock")));
1010×
        }
10212×
        for lock in locks {
10312×
            let age = fs::metadata(&lock)
10412×
                .and_then(|m| m.modified())
10512×
                .ok()
10612×
                .and_then(|t| t.elapsed().ok());
10712×
            if let Some(age) = age.filter(|a| a.as_secs() > STALE_LOCK_SECS) {
1080×
                rep.push(
1090×
                    "repository",
1100×
                    Health::Warn,
1110×
                    format!(
1120×
                        "a lock left by a process that stopped {} s ago: {}",
1130×
                        age.as_secs(),
1140×
                        lock.display()
1150×
                    ),
1160×
                    Some(
1170×
                        "it is taken over automatically; delete it if no oxy command is running"
1180×
                            .into(),
1190×
                    ),
1200×
                );
12112×
            }
122
        }
1236×
    }
124
1256×
    fn doctor_identity(&self, rep: &mut DoctorReport) {
1266×
        let Some(key) = self.config.author_key else {
1272×
            rep.push(
128
                "identity",
1292×
                Health::Warn,
130
                "no signing identity: pushes to a repository with a trust policy will be refused",
1312×
                Some("create one with `oxy identity create`".into()),
132
            );
1332×
            return;
134
        };
1354×
        let id = match self.identity(&key) {
1363×
            Ok(id) => id,
137
            // Before the first commit the identity object is stored nowhere
138
            // yet; with its secret key at hand that is normal.
139
            Err(_)
1401×
                if self
1411×
                    .keys_dir
1421×
                    .join(format!("{}.secret", key.to_hex()))
1431×
                    .is_file() =>
144
            {
1451×
                rep.push(
146
                    "identity",
1471×
                    Health::Ok,
1481×
                    format!(
149
                        "identity {} has its secret key; it is stored in the repository with your first signed change",
1501×
                        key.short(12)
151
                    ),
1521×
                    None,
153
                );
1541×
                return;
155
            }
1560×
            Err(e) => {
1570×
                rep.push(
158
                    "identity",
1590×
                    Health::Fail,
1600×
                    format!(
161
                        "identity {} is configured but cannot be read: {e}",
1620×
                        key.short(12)
163
                    ),
1640×
                    Some("restore it, or create a new one with `oxy identity create`".into()),
165
                );
1660×
                return;
167
            }
168
        };
1693×
        let secret = self.keys_dir.join(format!("{}.secret", key.to_hex()));
1703×
        if !secret.is_file() {
1710×
            rep.push(
172
                "identity",
1730×
                Health::Fail,
1740×
                format!("the secret key for {} is missing ({})", key.short(12), secret.display()),
1750×
                Some("copy the key file back from a backup; without it nothing can be signed with this identity".into()),
176
            );
1770×
            return;
1783×
        }
1793×
        let now = Hlc::now().wall_ms;
1800×
        match id.expires {
1810×
            Some(t) if t <= now => rep.push(
182
                "identity",
1830×
                Health::Fail,
1840×
                format!("{} <{}> expired", id.name, id.email),
1850×
                Some("renew it with `oxy identity renew`".into()),
186
            ),
1870×
            Some(t) if t - now < 30 * 86_400_000 => rep.push(
188
                "identity",
1890×
                Health::Warn,
1900×
                format!(
191
                    "{} <{}> expires in {} days",
192
                    id.name,
193
                    id.email,
1940×
                    (t - now) / 86_400_000
195
                ),
1960×
                Some("renew it with `oxy identity renew`".into()),
197
            ),
1983×
            _ => rep.push(
199
                "identity",
2003×
                Health::Ok,
2013×
                format!("{} <{}> ({})", id.name, id.email, key.short(12)),
2023×
                None,
203
            ),
204
        }
2056×
    }
206
207
    /// Paths of the current bookmark that another platform's file system
208
    /// cannot check out as they are (spec §7.7).
2096×
    fn doctor_portability(&self, rep: &mut DoctorReport) {
2106×
        let Ok(heads) = self.bookmark_heads() else {
2110×
            return;
212
        };
2136×
        let Ok(mut state) = self.load_state_history(&heads) else {
2140×
            return;
215
        };
2166×
        let Ok(view) = self.tree_view(&mut state) else {
2170×
            return;
218
        };
2196×
        let paths: Vec<&str> = view.entries.keys().map(String::as_str).collect();
2206×
        let mut problems: Vec<String> =
2216×
            crate::portable::Platform::windows().problems(paths.iter().copied());
2226×
        problems.sort();
2236×
        problems.dedup();
2246×
        if problems.is_empty() {
2255×
            rep.push(
226
                "portability",
2275×
                Health::Ok,
2285×
                format!(
229
                    "every path of {} checks out on Linux, macOS and Windows",
230
                    self.config.bookmark
231
                ),
2325×
                None,
233
            );
2345×
            return;
2351×
        }
2361×
        let shown: Vec<&String> = problems.iter().take(5).collect();
2371×
        rep.push(
238
            "portability",
2391×
            Health::Warn,
2401×
            format!(
241
                "{} path problem{} on case-insensitive or Windows file systems: {}{}",
2421×
                problems.len(),
2431×
                if problems.len() == 1 { "" } else { "s" },
2442×
                shown.iter().map(|s| s.as_str()).collect::<Vec<_>>().join("; "),
2451×
                if problems.len() > 5 { "; …" } else { "" }
246
            ),
2471×
            Some("rename the paths so they differ by more than case and avoid Windows' reserved names and characters".into()),
248
        );
2496×
    }
250
2516×
    fn doctor_author(&self, rep: &mut DoctorReport) {
2526×
        let defaulted =
2536×
            self.config.author_email.ends_with("@localhost") || self.config.author_name.is_empty();
2546×
        if defaulted {
2552×
            rep.push(
2562×
                "author",
2572×
                Health::Warn,
2582×
                format!("changes are authored as {} <{}>, a default", self.config.author_name, self.config.author_email),
2592×
                Some("set yours with `oxy config --user author.name NAME` and `oxy config --user author.email EMAIL`".into()),
2602×
            );
2614×
        } else {
2624×
            rep.push(
2634×
                "author",
2644×
                Health::Ok,
2654×
                format!("{} <{}>", self.config.author_name, self.config.author_email),
2664×
                None,
2674×
            );
2684×
        }
2696×
    }
270
271
    /// The trust policy (spec §14.5): without one, nothing fetched is
272
    /// verified, which fetch says each time; doctor says it too, with who
273
    /// fixes it (oxygen#127).
2746×
    fn doctor_trust(&self, rep: &mut DoctorReport) {
2756×
        let policy = match self.current_policy() {
2766×
            Ok(p) => p,
2770×
            Err(e) => {
2780×
                rep.push(
279
                    "trust",
2800×
                    Health::Fail,
2810×
                    format!("the trust policy does not load: {e}"),
2820×
                    Some("run `oxy fsck` for details".into()),
283
                );
2840×
                return;
285
            }
286
        };
2876×
        let Some((r, sp)) = policy else {
2883×
            rep.push(
289
                "trust",
2903×
                Health::Warn,
291
                "no trust policy: changes and bookmark moves fetched here are not verified",
2923×
                Some(
2933×
                    "the repository's owner sets one: `oxy policy init`, then `oxy policy grant FILE` for each \
2943×
                     contributor and `oxy policy grant-subject --issuer HUB_KEY agent:NAME@ACCOUNT` for keyless \
2953×
                     agents, and `oxy push`"
2963×
                        .into(),
2973×
                ),
298
            );
2993×
            return;
300
        };
3013×
        let p = &sp.policy;
3023×
        let now = Hlc::now().wall_ms;
3033×
        let what = format!(
304
            "policy #{} serial {}: {} root{}, {} rule{}",
3053×
            &r.to_hex()[..12],
306
            p.serial,
3073×
            p.roots.len(),
3083×
            if p.roots.len() == 1 { "" } else { "s" },
3093×
            p.rules.len(),
3103×
            if p.rules.len() == 1 { "" } else { "s" },
311
        );
3123×
        let renew = Some(
3133×
            "a root of the policy publishes a new serial (any `oxy policy` change does)".into(),
3143×
        );
3153×
        if p.is_expired(now) {
3160×
            rep.push("trust", Health::Fail, format!("{what}, expired"), renew);
3173×
        } else if p.expires - now < 30 * 86_400_000 {
3180×
            rep.push(
3190×
                "trust",
3200×
                Health::Warn,
3210×
                format!("{what}, expires in {} days", (p.expires - now) / 86_400_000),
3220×
                renew,
3230×
            );
3243×
        } else {
3253×
            rep.push(
3263×
                "trust",
3273×
                Health::Ok,
3283×
                format!("{what}, expires in {} days", (p.expires - now) / 86_400_000),
3293×
                None,
3303×
            );
3313×
        }
3326×
    }
333
3346×
    fn doctor_remote(&self, rep: &mut DoctorReport, name: &str, online: bool) {
3356×
        let area = format!("remote {name}");
3366×
        let url = &self.config.remotes[name];
3376×
        let opts = self.remote_options(name);
3386×
        let http = url.starts_with("http://") || url.starts_with("https://");
3396×
        let hint = |h: &str| Some(h.replace("<remote>", name));
3406×
        if url.starts_with("http://") && !is_loopback(url) {
3410×
            rep.push(
3420×
                &area,
3430×
                Health::Warn,
3440×
                format!("{url} is plain HTTP: the token travels unencrypted"),
3450×
                hint("use the hub's https:// address"),
3460×
            );
3476×
        }
3486×
        if http && opts.token.is_none() {
3492×
            rep.push(
3502×
                &area,
3512×
                Health::Warn,
3522×
                "no token: fetching public repositories works, pushing will not",
3532×
                hint("set one with `oxy config remote.<remote>.token TOKEN`"),
3542×
            );
3554×
        }
3566×
        if let Some(ca) = &opts.ca {
3572×
            if !ca.is_file() {
3582×
                rep.push(&area, Health::Fail, format!("the CA file {} does not exist", ca.display()), hint("download the hub's certificate from /.well-known/oxyhub-ca.pem and run `oxy config remote.<remote>.ca FILE`"));
3592×
                return;
3600×
            }
3614×
        }
3624×
        if !online {
3632×
            rep.push(&area, Health::Ok, format!("{url} (not contacted)"), None);
3642×
            return;
3652×
        }
3662×
        let reached = crate::protocol::open_remote(url, &opts).and_then(|r| r.repo_id());
3670×
        match reached {
3681×
            Ok(theirs) if theirs == crate::protocol::UNBOUND_ID => rep.push(
3691×
                &area,
3701×
                Health::Ok,
3711×
                format!("{url} is reachable and empty; the first push binds it"),
3721×
                None,
373
            ),
3740×
            Ok(theirs) if self.id().is_ok_and(|mine| mine == theirs) => rep.push(
3750×
                &area,
3760×
                Health::Ok,
3770×
                format!("{url} is reachable and is this repository"),
3780×
                None,
379
            ),
3800×
            Ok(_) => rep.push(
3810×
                &area,
3820×
                Health::Fail,
3830×
                format!("{url} is a different repository"),
3840×
                hint("check the URL; each repository has its own id"),
385
            ),
3861×
            Err(e) => {
3871×
                let msg = e.to_string();
3881×
                let first = msg.lines().next().unwrap_or(&msg).to_string();
3891×
                rep.push(
3901×
                    &area,
3911×
                    Health::Fail,
3921×
                    first,
3931×
                    crate::protocol::hint_for(&msg).map(|h| h.replace("<remote>", name)),
394
                );
395
            }
396
        }
3976×
    }
398
}
399
4002×
fn is_loopback(url: &str) -> bool {
4012×
    let host = url
4022×
        .split("://")
4032×
        .nth(1)
4042×
        .unwrap_or("")
4052×
        .split(['/', ':'])
4062×
        .next()
4072×
        .unwrap_or("");
4082×
    host == "localhost" || host == "127.0.0.1" || host == "[::1]"
4092×
}