OXYGENHUB
◆ v0.11.0 oxygen / crates / oxy-core / src / repo / doctor.rs
14.1 KiB · Rust · last changed oxy doctor checks the trust policy 16 days 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
        self.doctor_portability(&mut rep);
65
        self.doctor_trust(&mut rep);
66
        for name in self.config.remotes.keys() {
67
            self.doctor_remote(&mut rep, name, online);
68
        }
69
        if self.config.remotes.is_empty() && !self.bare {
70
            rep.push("remotes", Health::Ok, "none configured", None);
71
        }
72
        rep
73
    }
74
75
    fn doctor_repository(&self, rep: &mut DoctorReport) {
76
        match self.head_op() {
77
            Ok(_) => rep.push(
78
                "repository",
79
                Health::Ok,
80
                format!(
81
                    "{} at {}",
82
                    if self.bare {
83
                        "bare repository"
84
                    } else {
85
                        "repository"
86
                    },
87
                    self.root.display()
88
                ),
89
                None,
90
            ),
91
            Err(e) => rep.push(
92
                "repository",
93
                Health::Fail,
94
                format!("the operation log does not open: {e}"),
95
                Some("run `oxy fsck` for details".into()),
96
            ),
97
        }
98
        let mut locks = vec![self.oxy.join("ops").join("lock")];
99
        if let Ok(rd) = fs::read_dir(self.oxy.join("workspaces")) {
100
            locks.extend(rd.flatten().map(|e| e.path().join("lock")));
101
        }
102
        for lock in locks {
103
            let age = fs::metadata(&lock)
104
                .and_then(|m| m.modified())
105
                .ok()
106
                .and_then(|t| t.elapsed().ok());
107
            if let Some(age) = age.filter(|a| a.as_secs() > STALE_LOCK_SECS) {
108
                rep.push(
109
                    "repository",
110
                    Health::Warn,
111
                    format!(
112
                        "a lock left by a process that stopped {} s ago: {}",
113
                        age.as_secs(),
114
                        lock.display()
115
                    ),
116
                    Some(
117
                        "it is taken over automatically; delete it if no oxy command is running"
118
                            .into(),
119
                    ),
120
                );
121
            }
122
        }
123
    }
124
125
    fn doctor_identity(&self, rep: &mut DoctorReport) {
126
        let Some(key) = self.config.author_key else {
127
            rep.push(
128
                "identity",
129
                Health::Warn,
130
                "no signing identity: pushes to a repository with a trust policy will be refused",
131
                Some("create one with `oxy identity create`".into()),
132
            );
133
            return;
134
        };
135
        let id = match self.identity(&key) {
136
            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(_)
140
                if self
141
                    .keys_dir
142
                    .join(format!("{}.secret", key.to_hex()))
143
                    .is_file() =>
144
            {
145
                rep.push(
146
                    "identity",
147
                    Health::Ok,
148
                    format!(
149
                        "identity {} has its secret key; it is stored in the repository with your first signed change",
150
                        key.short(12)
151
                    ),
152
                    None,
153
                );
154
                return;
155
            }
156
            Err(e) => {
157
                rep.push(
158
                    "identity",
159
                    Health::Fail,
160
                    format!(
161
                        "identity {} is configured but cannot be read: {e}",
162
                        key.short(12)
163
                    ),
164
                    Some("restore it, or create a new one with `oxy identity create`".into()),
165
                );
166
                return;
167
            }
168
        };
169
        let secret = self.keys_dir.join(format!("{}.secret", key.to_hex()));
170
        if !secret.is_file() {
171
            rep.push(
172
                "identity",
173
                Health::Fail,
174
                format!("the secret key for {} is missing ({})", key.short(12), secret.display()),
175
                Some("copy the key file back from a backup; without it nothing can be signed with this identity".into()),
176
            );
177
            return;
178
        }
179
        let now = Hlc::now().wall_ms;
180
        match id.expires {
181
            Some(t) if t <= now => rep.push(
182
                "identity",
183
                Health::Fail,
184
                format!("{} <{}> expired", id.name, id.email),
185
                Some("renew it with `oxy identity renew`".into()),
186
            ),
187
            Some(t) if t - now < 30 * 86_400_000 => rep.push(
188
                "identity",
189
                Health::Warn,
190
                format!(
191
                    "{} <{}> expires in {} days",
192
                    id.name,
193
                    id.email,
194
                    (t - now) / 86_400_000
195
                ),
196
                Some("renew it with `oxy identity renew`".into()),
197
            ),
198
            _ => rep.push(
199
                "identity",
200
                Health::Ok,
201
                format!("{} <{}> ({})", id.name, id.email, key.short(12)),
202
                None,
203
            ),
204
        }
205
    }
206
207
    /// Paths of the current bookmark that another platform's file system
208
    /// cannot check out as they are (spec §7.7).
209
    fn doctor_portability(&self, rep: &mut DoctorReport) {
210
        let Ok(heads) = self.bookmark_heads() else {
211
            return;
212
        };
213
        let Ok(mut state) = self.load_state_history(&heads) else {
214
            return;
215
        };
216
        let Ok(view) = self.tree_view(&mut state) else {
217
            return;
218
        };
219
        let paths: Vec<&str> = view.entries.keys().map(String::as_str).collect();
220
        let mut problems: Vec<String> =
221
            crate::portable::Platform::windows().problems(paths.iter().copied());
222
        problems.sort();
223
        problems.dedup();
224
        if problems.is_empty() {
225
            rep.push(
226
                "portability",
227
                Health::Ok,
228
                format!(
229
                    "every path of {} checks out on Linux, macOS and Windows",
230
                    self.config.bookmark
231
                ),
232
                None,
233
            );
234
            return;
235
        }
236
        let shown: Vec<&String> = problems.iter().take(5).collect();
237
        rep.push(
238
            "portability",
239
            Health::Warn,
240
            format!(
241
                "{} path problem{} on case-insensitive or Windows file systems: {}{}",
242
                problems.len(),
243
                if problems.len() == 1 { "" } else { "s" },
244
                shown.iter().map(|s| s.as_str()).collect::<Vec<_>>().join("; "),
245
                if problems.len() > 5 { "; …" } else { "" }
246
            ),
247
            Some("rename the paths so they differ by more than case and avoid Windows' reserved names and characters".into()),
248
        );
249
    }
250
251
    fn doctor_author(&self, rep: &mut DoctorReport) {
252
        let defaulted =
253
            self.config.author_email.ends_with("@localhost") || self.config.author_name.is_empty();
254
        if defaulted {
255
            rep.push(
256
                "author",
257
                Health::Warn,
258
                format!("changes are authored as {} <{}>, a default", self.config.author_name, self.config.author_email),
259
                Some("set yours with `oxy config --user author.name NAME` and `oxy config --user author.email EMAIL`".into()),
260
            );
261
        } else {
262
            rep.push(
263
                "author",
264
                Health::Ok,
265
                format!("{} <{}>", self.config.author_name, self.config.author_email),
266
                None,
267
            );
268
        }
269
    }
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).
274
    fn doctor_trust(&self, rep: &mut DoctorReport) {
275
        let policy = match self.current_policy() {
276
            Ok(p) => p,
277
            Err(e) => {
278
                rep.push(
279
                    "trust",
280
                    Health::Fail,
281
                    format!("the trust policy does not load: {e}"),
282
                    Some("run `oxy fsck` for details".into()),
283
                );
284
                return;
285
            }
286
        };
287
        let Some((r, sp)) = policy else {
288
            rep.push(
289
                "trust",
290
                Health::Warn,
291
                "no trust policy: changes and bookmark moves fetched here are not verified",
292
                Some(
293
                    "the repository's owner sets one: `oxy policy init`, then `oxy policy grant FILE` for each \
294
                     contributor and `oxy policy grant-subject --issuer HUB_KEY agent:NAME@ACCOUNT` for keyless \
295
                     agents, and `oxy push`"
296
                        .into(),
297
                ),
298
            );
299
            return;
300
        };
301
        let p = &sp.policy;
302
        let now = Hlc::now().wall_ms;
303
        let what = format!(
304
            "policy #{} serial {}: {} root{}, {} rule{}",
305
            &r.to_hex()[..12],
306
            p.serial,
307
            p.roots.len(),
308
            if p.roots.len() == 1 { "" } else { "s" },
309
            p.rules.len(),
310
            if p.rules.len() == 1 { "" } else { "s" },
311
        );
312
        let renew = Some(
313
            "a root of the policy publishes a new serial (any `oxy policy` change does)".into(),
314
        );
315
        if p.is_expired(now) {
316
            rep.push("trust", Health::Fail, format!("{what}, expired"), renew);
317
        } else if p.expires - now < 30 * 86_400_000 {
318
            rep.push(
319
                "trust",
320
                Health::Warn,
321
                format!("{what}, expires in {} days", (p.expires - now) / 86_400_000),
322
                renew,
323
            );
324
        } else {
325
            rep.push(
326
                "trust",
327
                Health::Ok,
328
                format!("{what}, expires in {} days", (p.expires - now) / 86_400_000),
329
                None,
330
            );
331
        }
332
    }
333
334
    fn doctor_remote(&self, rep: &mut DoctorReport, name: &str, online: bool) {
335
        let area = format!("remote {name}");
336
        let url = &self.config.remotes[name];
337
        let opts = self.remote_options(name);
338
        let http = url.starts_with("http://") || url.starts_with("https://");
339
        let hint = |h: &str| Some(h.replace("<remote>", name));
340
        if url.starts_with("http://") && !is_loopback(url) {
341
            rep.push(
342
                &area,
343
                Health::Warn,
344
                format!("{url} is plain HTTP: the token travels unencrypted"),
345
                hint("use the hub's https:// address"),
346
            );
347
        }
348
        if http && opts.token.is_none() {
349
            rep.push(
350
                &area,
351
                Health::Warn,
352
                "no token: fetching public repositories works, pushing will not",
353
                hint("set one with `oxy config remote.<remote>.token TOKEN`"),
354
            );
355
        }
356
        if let Some(ca) = &opts.ca {
357
            if !ca.is_file() {
358
                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`"));
359
                return;
360
            }
361
        }
362
        if !online {
363
            rep.push(&area, Health::Ok, format!("{url} (not contacted)"), None);
364
            return;
365
        }
366
        let reached = crate::protocol::open_remote(url, &opts).and_then(|r| r.repo_id());
367
        match reached {
368
            Ok(theirs) if theirs == crate::protocol::UNBOUND_ID => rep.push(
369
                &area,
370
                Health::Ok,
371
                format!("{url} is reachable and empty; the first push binds it"),
372
                None,
373
            ),
374
            Ok(theirs) if self.id().is_ok_and(|mine| mine == theirs) => rep.push(
375
                &area,
376
                Health::Ok,
377
                format!("{url} is reachable and is this repository"),
378
                None,
379
            ),
380
            Ok(_) => rep.push(
381
                &area,
382
                Health::Fail,
383
                format!("{url} is a different repository"),
384
                hint("check the URL; each repository has its own id"),
385
            ),
386
            Err(e) => {
387
                let msg = e.to_string();
388
                let first = msg.lines().next().unwrap_or(&msg).to_string();
389
                rep.push(
390
                    &area,
391
                    Health::Fail,
392
                    first,
393
                    crate::protocol::hint_for(&msg).map(|h| h.replace("<remote>", name)),
394
                );
395
            }
396
        }
397
    }
398
}
399
400
fn is_loopback(url: &str) -> bool {
401
    let host = url
402
        .split("://")
403
        .nth(1)
404
        .unwrap_or("")
405
        .split(['/', ':'])
406
        .next()
407
        .unwrap_or("");
408
    host == "localhost" || host == "127.0.0.1" || host == "[::1]"
409
}