OXYGENHUB

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

182 of 247 lines run, 17 of 42 functions · run #259 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
3831×
    fn push(
3931×
        &mut self,
4031×
        area: impl Into<String>,
4131×
        health: Health,
4231×
        message: impl Into<String>,
4331×
        hint: Option<String>,
4431×
    ) {
4531×
        self.checks.push(Check {
4631×
            area: area.into(),
4731×
            health,
4831×
            message: message.into(),
4931×
            hint,
5031×
        });
5131×
    }
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.
595×
    pub fn doctor(&self, online: bool) -> DoctorReport {
605×
        let mut rep = DoctorReport::default();
615×
        self.doctor_repository(&mut rep);
625×
        self.doctor_identity(&mut rep);
635×
        self.doctor_author(&mut rep);
645×
        self.doctor_portability(&mut rep);
656×
        for name in self.config.remotes.keys() {
666×
            self.doctor_remote(&mut rep, name, online);
676×
        }
685×
        if self.config.remotes.is_empty() && !self.bare {
693×
            rep.push("remotes", Health::Ok, "none configured", None);
703×
        }
715×
        rep
725×
    }
73
745×
    fn doctor_repository(&self, rep: &mut DoctorReport) {
755×
        match self.head_op() {
765×
            Ok(_) => rep.push(
77
                "repository",
785×
                Health::Ok,
795×
                format!(
80
                    "{} at {}",
815×
                    if self.bare {
820×
                        "bare repository"
83
                    } else {
845×
                        "repository"
85
                    },
865×
                    self.root.display()
87
                ),
885×
                None,
89
            ),
900×
            Err(e) => rep.push(
91
                "repository",
920×
                Health::Fail,
930×
                format!("the operation log does not open: {e}"),
940×
                Some("run `oxy fsck` for details".into()),
95
            ),
96
        }
975×
        let mut locks = vec![self.oxy.join("ops").join("lock")];
985×
        if let Ok(rd) = fs::read_dir(self.oxy.join("workspaces")) {
995×
            locks.extend(rd.flatten().map(|e| e.path().join("lock")));
1000×
        }
10110×
        for lock in locks {
10210×
            let age = fs::metadata(&lock)
10310×
                .and_then(|m| m.modified())
10410×
                .ok()
10510×
                .and_then(|t| t.elapsed().ok());
10610×
            if let Some(age) = age.filter(|a| a.as_secs() > STALE_LOCK_SECS) {
1070×
                rep.push(
1080×
                    "repository",
1090×
                    Health::Warn,
1100×
                    format!(
1110×
                        "a lock left by a process that stopped {} s ago: {}",
1120×
                        age.as_secs(),
1130×
                        lock.display()
1140×
                    ),
1150×
                    Some(
1160×
                        "it is taken over automatically; delete it if no oxy command is running"
1170×
                            .into(),
1180×
                    ),
1190×
                );
12010×
            }
121
        }
1225×
    }
123
1245×
    fn doctor_identity(&self, rep: &mut DoctorReport) {
1255×
        let Some(key) = self.config.author_key else {
1262×
            rep.push(
127
                "identity",
1282×
                Health::Warn,
129
                "no signing identity: pushes to a repository with a trust policy will be refused",
1302×
                Some("create one with `oxy identity create`".into()),
131
            );
1322×
            return;
133
        };
1343×
        let id = match self.identity(&key) {
1352×
            Ok(id) => id,
136
            // Before the first commit the identity object is stored nowhere
137
            // yet; with its secret key at hand that is normal.
138
            Err(_)
1391×
                if self
1401×
                    .keys_dir
1411×
                    .join(format!("{}.secret", key.to_hex()))
1421×
                    .is_file() =>
143
            {
1441×
                rep.push(
145
                    "identity",
1461×
                    Health::Ok,
1471×
                    format!(
148
                        "identity {} has its secret key; it is stored in the repository with your first signed change",
1491×
                        key.short(12)
150
                    ),
1511×
                    None,
152
                );
1531×
                return;
154
            }
1550×
            Err(e) => {
1560×
                rep.push(
157
                    "identity",
1580×
                    Health::Fail,
1590×
                    format!(
160
                        "identity {} is configured but cannot be read: {e}",
1610×
                        key.short(12)
162
                    ),
1630×
                    Some("restore it, or create a new one with `oxy identity create`".into()),
164
                );
1650×
                return;
166
            }
167
        };
1682×
        let secret = self.keys_dir.join(format!("{}.secret", key.to_hex()));
1692×
        if !secret.is_file() {
1700×
            rep.push(
171
                "identity",
1720×
                Health::Fail,
1730×
                format!("the secret key for {} is missing ({})", key.short(12), secret.display()),
1740×
                Some("copy the key file back from a backup; without it nothing can be signed with this identity".into()),
175
            );
1760×
            return;
1772×
        }
1782×
        let now = Hlc::now().wall_ms;
1790×
        match id.expires {
1800×
            Some(t) if t <= now => rep.push(
181
                "identity",
1820×
                Health::Fail,
1830×
                format!("{} <{}> expired", id.name, id.email),
1840×
                Some("renew it with `oxy identity renew`".into()),
185
            ),
1860×
            Some(t) if t - now < 30 * 86_400_000 => rep.push(
187
                "identity",
1880×
                Health::Warn,
1890×
                format!(
190
                    "{} <{}> expires in {} days",
191
                    id.name,
192
                    id.email,
1930×
                    (t - now) / 86_400_000
194
                ),
1950×
                Some("renew it with `oxy identity renew`".into()),
196
            ),
1972×
            _ => rep.push(
198
                "identity",
1992×
                Health::Ok,
2002×
                format!("{} <{}> ({})", id.name, id.email, key.short(12)),
2012×
                None,
202
            ),
203
        }
2045×
    }
205
206
    /// Paths of the current bookmark that another platform's file system
207
    /// cannot check out as they are (spec §7.7).
2085×
    fn doctor_portability(&self, rep: &mut DoctorReport) {
2095×
        let Ok(heads) = self.bookmark_heads() else {
2100×
            return;
211
        };
2125×
        let Ok(state) = self.load_state_history(&heads) else {
2130×
            return;
214
        };
2155×
        let view = state.tree().view();
2165×
        let paths: Vec<&str> = view.entries.keys().map(String::as_str).collect();
2175×
        let mut problems: Vec<String> =
2185×
            crate::portable::Platform::windows().problems(paths.iter().copied());
2195×
        problems.sort();
2205×
        problems.dedup();
2215×
        if problems.is_empty() {
2224×
            rep.push(
223
                "portability",
2244×
                Health::Ok,
2254×
                format!(
226
                    "every path of {} checks out on Linux, macOS and Windows",
227
                    self.config.bookmark
228
                ),
2294×
                None,
230
            );
2314×
            return;
2321×
        }
2331×
        let shown: Vec<&String> = problems.iter().take(5).collect();
2341×
        rep.push(
235
            "portability",
2361×
            Health::Warn,
2371×
            format!(
238
                "{} path problem{} on case-insensitive or Windows file systems: {}{}",
2391×
                problems.len(),
2401×
                if problems.len() == 1 { "" } else { "s" },
2412×
                shown.iter().map(|s| s.as_str()).collect::<Vec<_>>().join("; "),
2421×
                if problems.len() > 5 { "; …" } else { "" }
243
            ),
2441×
            Some("rename the paths so they differ by more than case and avoid Windows' reserved names and characters".into()),
245
        );
2465×
    }
247
2485×
    fn doctor_author(&self, rep: &mut DoctorReport) {
2495×
        let defaulted =
2505×
            self.config.author_email.ends_with("@localhost") || self.config.author_name.is_empty();
2515×
        if defaulted {
2522×
            rep.push(
2532×
                "author",
2542×
                Health::Warn,
2552×
                format!("changes are authored as {} <{}>, a default", self.config.author_name, self.config.author_email),
2562×
                Some("set yours with `oxy config --user author.name NAME` and `oxy config --user author.email EMAIL`".into()),
2572×
            );
2583×
        } else {
2593×
            rep.push(
2603×
                "author",
2613×
                Health::Ok,
2623×
                format!("{} <{}>", self.config.author_name, self.config.author_email),
2633×
                None,
2643×
            );
2653×
        }
2665×
    }
267
2686×
    fn doctor_remote(&self, rep: &mut DoctorReport, name: &str, online: bool) {
2696×
        let area = format!("remote {name}");
2706×
        let url = &self.config.remotes[name];
2716×
        let opts = self.remote_options(name);
2726×
        let http = url.starts_with("http://") || url.starts_with("https://");
2736×
        let hint = |h: &str| Some(h.replace("<remote>", name));
2746×
        if url.starts_with("http://") && !is_loopback(url) {
2750×
            rep.push(
2760×
                &area,
2770×
                Health::Warn,
2780×
                format!("{url} is plain HTTP: the token travels unencrypted"),
2790×
                hint("use the hub's https:// address"),
2800×
            );
2816×
        }
2826×
        if http && opts.token.is_none() {
2832×
            rep.push(
2842×
                &area,
2852×
                Health::Warn,
2862×
                "no token: fetching public repositories works, pushing will not",
2872×
                hint("set one with `oxy config remote.<remote>.token TOKEN`"),
2882×
            );
2894×
        }
2906×
        if let Some(ca) = &opts.ca {
2912×
            if !ca.is_file() {
2922×
                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`"));
2932×
                return;
2940×
            }
2954×
        }
2964×
        if !online {
2972×
            rep.push(&area, Health::Ok, format!("{url} (not contacted)"), None);
2982×
            return;
2992×
        }
3002×
        let reached = crate::protocol::open_remote(url, &opts).and_then(|r| r.repo_id());
3010×
        match reached {
3021×
            Ok(theirs) if theirs == crate::protocol::UNBOUND_ID => rep.push(
3031×
                &area,
3041×
                Health::Ok,
3051×
                format!("{url} is reachable and empty; the first push binds it"),
3061×
                None,
307
            ),
3080×
            Ok(theirs) if self.id().is_ok_and(|mine| mine == theirs) => rep.push(
3090×
                &area,
3100×
                Health::Ok,
3110×
                format!("{url} is reachable and is this repository"),
3120×
                None,
313
            ),
3140×
            Ok(_) => rep.push(
3150×
                &area,
3160×
                Health::Fail,
3170×
                format!("{url} is a different repository"),
3180×
                hint("check the URL; each repository has its own id"),
319
            ),
3201×
            Err(e) => {
3211×
                let msg = e.to_string();
3221×
                let first = msg.lines().next().unwrap_or(&msg).to_string();
3231×
                rep.push(
3241×
                    &area,
3251×
                    Health::Fail,
3261×
                    first,
3271×
                    crate::protocol::hint_for(&msg).map(|h| h.replace("<remote>", name)),
328
                );
329
            }
330
        }
3316×
    }
332
}
333
3342×
fn is_loopback(url: &str) -> bool {
3352×
    let host = url
3362×
        .split("://")
3372×
        .nth(1)
3382×
        .unwrap_or("")
3392×
        .split(['/', ':'])
3402×
        .next()
3412×
        .unwrap_or("");
3422×
    host == "localhost" || host == "127.0.0.1" || host == "[::1]"
3432×
}