OXYGENHUB
# 62794ecc7f74 oxygen / crates / oxy-core / src / repo / doctor.rs
9.4 KiB · Rust · last changed oxy doctor, and failures that say what to do next 1 month ago source blame history raw
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 {
30
    pub fn worst(&self) -> Health {
31
        self.checks
32
            .iter()
33
            .map(|c| c.health)
34
            .max()
35
            .unwrap_or(Health::Ok)
36
    }
37
38
    fn push(
39
        &mut self,
40
        area: impl Into<String>,
41
        health: Health,
42
        message: impl Into<String>,
43
        hint: Option<String>,
44
    ) {
45
        self.checks.push(Check {
46
            area: area.into(),
47
            health,
48
            message: message.into(),
49
            hint,
50
        });
51
    }
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.
59
    pub fn doctor(&self, online: bool) -> DoctorReport {
60
        let mut rep = DoctorReport::default();
61
        self.doctor_repository(&mut rep);
62
        self.doctor_identity(&mut rep);
63
        self.doctor_author(&mut rep);
64
        for name in self.config.remotes.keys() {
65
            self.doctor_remote(&mut rep, name, online);
66
        }
67
        if self.config.remotes.is_empty() && !self.bare {
68
            rep.push("remotes", Health::Ok, "none configured", None);
69
        }
70
        rep
71
    }
72
73
    fn doctor_repository(&self, rep: &mut DoctorReport) {
74
        match self.head_op() {
75
            Ok(_) => rep.push(
76
                "repository",
77
                Health::Ok,
78
                format!(
79
                    "{} at {}",
80
                    if self.bare {
81
                        "bare repository"
82
                    } else {
83
                        "repository"
84
                    },
85
                    self.root.display()
86
                ),
87
                None,
88
            ),
89
            Err(e) => rep.push(
90
                "repository",
91
                Health::Fail,
92
                format!("the operation log does not open: {e}"),
93
                Some("run `oxy fsck` for details".into()),
94
            ),
95
        }
96
        let mut locks = vec![self.oxy.join("ops").join("lock")];
97
        if let Ok(rd) = fs::read_dir(self.oxy.join("workspaces")) {
98
            locks.extend(rd.flatten().map(|e| e.path().join("lock")));
99
        }
100
        for lock in locks {
101
            let age = fs::metadata(&lock)
102
                .and_then(|m| m.modified())
103
                .ok()
104
                .and_then(|t| t.elapsed().ok());
105
            if let Some(age) = age.filter(|a| a.as_secs() > STALE_LOCK_SECS) {
106
                rep.push(
107
                    "repository",
108
                    Health::Warn,
109
                    format!(
110
                        "a lock left by a process that stopped {} s ago: {}",
111
                        age.as_secs(),
112
                        lock.display()
113
                    ),
114
                    Some(
115
                        "it is taken over automatically; delete it if no oxy command is running"
116
                            .into(),
117
                    ),
118
                );
119
            }
120
        }
121
    }
122
123
    fn doctor_identity(&self, rep: &mut DoctorReport) {
124
        let Some(key) = self.config.author_key else {
125
            rep.push(
126
                "identity",
127
                Health::Warn,
128
                "no signing identity: pushes to a repository with a trust policy will be refused",
129
                Some("create one with `oxy identity create`".into()),
130
            );
131
            return;
132
        };
133
        let id = match self.identity(&key) {
134
            Ok(id) => id,
135
            Err(e) => {
136
                rep.push(
137
                    "identity",
138
                    Health::Fail,
139
                    format!(
140
                        "identity {} is configured but cannot be read: {e}",
141
                        key.short(12)
142
                    ),
143
                    Some("restore it, or create a new one with `oxy identity create`".into()),
144
                );
145
                return;
146
            }
147
        };
148
        let secret = self.keys_dir.join(format!("{}.secret", key.to_hex()));
149
        if !secret.is_file() {
150
            rep.push(
151
                "identity",
152
                Health::Fail,
153
                format!("the secret key for {} is missing ({})", key.short(12), secret.display()),
154
                Some("copy the key file back from a backup; without it nothing can be signed with this identity".into()),
155
            );
156
            return;
157
        }
158
        let now = Hlc::now().wall_ms;
159
        match id.expires {
160
            Some(t) if t <= now => rep.push(
161
                "identity",
162
                Health::Fail,
163
                format!("{} <{}> expired", id.name, id.email),
164
                Some("renew it with `oxy identity renew`".into()),
165
            ),
166
            Some(t) if t - now < 30 * 86_400_000 => rep.push(
167
                "identity",
168
                Health::Warn,
169
                format!(
170
                    "{} <{}> expires in {} days",
171
                    id.name,
172
                    id.email,
173
                    (t - now) / 86_400_000
174
                ),
175
                Some("renew it with `oxy identity renew`".into()),
176
            ),
177
            _ => rep.push(
178
                "identity",
179
                Health::Ok,
180
                format!("{} <{}> ({})", id.name, id.email, key.short(12)),
181
                None,
182
            ),
183
        }
184
    }
185
186
    fn doctor_author(&self, rep: &mut DoctorReport) {
187
        let defaulted =
188
            self.config.author_email.ends_with("@localhost") || self.config.author_name.is_empty();
189
        if defaulted {
190
            rep.push(
191
                "author",
192
                Health::Warn,
193
                format!("changes are authored as {} <{}>, a default", self.config.author_name, self.config.author_email),
194
                Some("set yours with `oxy config --user author.name NAME` and `oxy config --user author.email EMAIL`".into()),
195
            );
196
        } else {
197
            rep.push(
198
                "author",
199
                Health::Ok,
200
                format!("{} <{}>", self.config.author_name, self.config.author_email),
201
                None,
202
            );
203
        }
204
    }
205
206
    fn doctor_remote(&self, rep: &mut DoctorReport, name: &str, online: bool) {
207
        let area = format!("remote {name}");
208
        let url = &self.config.remotes[name];
209
        let opts = self.remote_options(name);
210
        let http = url.starts_with("http://") || url.starts_with("https://");
211
        let hint = |h: &str| Some(h.replace("<remote>", name));
212
        if url.starts_with("http://") && !is_loopback(url) {
213
            rep.push(
214
                &area,
215
                Health::Warn,
216
                format!("{url} is plain HTTP: the token travels unencrypted"),
217
                hint("use the hub's https:// address"),
218
            );
219
        }
220
        if http && opts.token.is_none() {
221
            rep.push(
222
                &area,
223
                Health::Warn,
224
                "no token: fetching public repositories works, pushing will not",
225
                hint("set one with `oxy config remote.<remote>.token TOKEN`"),
226
            );
227
        }
228
        if let Some(ca) = &opts.ca {
229
            if !ca.is_file() {
230
                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`"));
231
                return;
232
            }
233
        }
234
        if !online {
235
            rep.push(&area, Health::Ok, format!("{url} (not contacted)"), None);
236
            return;
237
        }
238
        let reached = crate::protocol::open_remote(url, &opts).and_then(|r| r.repo_id());
239
        match reached {
240
            Ok(theirs) if theirs == crate::protocol::UNBOUND_ID => rep.push(
241
                &area,
242
                Health::Ok,
243
                format!("{url} is reachable and empty; the first push binds it"),
244
                None,
245
            ),
246
            Ok(theirs) if self.id().is_ok_and(|mine| mine == theirs) => rep.push(
247
                &area,
248
                Health::Ok,
249
                format!("{url} is reachable and is this repository"),
250
                None,
251
            ),
252
            Ok(_) => rep.push(
253
                &area,
254
                Health::Fail,
255
                format!("{url} is a different repository"),
256
                hint("check the URL; each repository has its own id"),
257
            ),
258
            Err(e) => {
259
                let msg = e.to_string();
260
                let first = msg.lines().next().unwrap_or(&msg).to_string();
261
                rep.push(
262
                    &area,
263
                    Health::Fail,
264
                    first,
265
                    crate::protocol::hint_for(&msg).map(|h| h.replace("<remote>", name)),
266
                );
267
            }
268
        }
269
    }
270
}
271
272
fn is_loopback(url: &str) -> bool {
273
    let host = url
274
        .split("://")
275
        .nth(1)
276
        .unwrap_or("")
277
        .split(['/', ':'])
278
        .next()
279
        .unwrap_or("");
280
    host == "localhost" || host == "127.0.0.1" || host == "[::1]"
281
}