OXYGENHUB
# ad2496f7e608 oxygen / crates / oxy-core / src / repo / doctor.rs
13.4 KiB · Rust · last changed oxy doctor checks the trust policy 18 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
            Err(e) => {
138
                rep.push(
139
                    "identity",
140
                    Health::Fail,
141
                    format!(
142
                        "identity {} is configured but cannot be read: {e}",
143
                        key.short(12)
144
                    ),
145
                    Some("restore it, or create a new one with `oxy identity create`".into()),
146
                );
147
                return;
148
            }
149
        };
150
        let secret = self.keys_dir.join(format!("{}.secret", key.to_hex()));
151
        if !secret.is_file() {
152
            rep.push(
153
                "identity",
154
                Health::Fail,
155
                format!("the secret key for {} is missing ({})", key.short(12), secret.display()),
156
                Some("copy the key file back from a backup; without it nothing can be signed with this identity".into()),
157
            );
158
            return;
159
        }
160
        let now = Hlc::now().wall_ms;
161
        match id.expires {
162
            Some(t) if t <= now => rep.push(
163
                "identity",
164
                Health::Fail,
165
                format!("{} <{}> expired", id.name, id.email),
166
                Some("renew it with `oxy identity renew`".into()),
167
            ),
168
            Some(t) if t - now < 30 * 86_400_000 => rep.push(
169
                "identity",
170
                Health::Warn,
171
                format!(
172
                    "{} <{}> expires in {} days",
173
                    id.name,
174
                    id.email,
175
                    (t - now) / 86_400_000
176
                ),
177
                Some("renew it with `oxy identity renew`".into()),
178
            ),
179
            _ => rep.push(
180
                "identity",
181
                Health::Ok,
182
                format!("{} <{}> ({})", id.name, id.email, key.short(12)),
183
                None,
184
            ),
185
        }
186
    }
187
188
    /// Paths of the current bookmark that another platform's file system
189
    /// cannot check out as they are (spec §7.7).
190
    fn doctor_portability(&self, rep: &mut DoctorReport) {
191
        let Ok(heads) = self.bookmark_heads() else {
192
            return;
193
        };
194
        let Ok(state) = self.load_state(&heads) else {
195
            return;
196
        };
197
        let view = state.tree().view();
198
        let paths: Vec<&str> = view.entries.keys().map(String::as_str).collect();
199
        let mut problems: Vec<String> =
200
            crate::portable::Platform::windows().problems(paths.iter().copied());
201
        problems.sort();
202
        problems.dedup();
203
        if problems.is_empty() {
204
            rep.push(
205
                "portability",
206
                Health::Ok,
207
                format!(
208
                    "every path of {} checks out on Linux, macOS and Windows",
209
                    self.config.bookmark
210
                ),
211
                None,
212
            );
213
            return;
214
        }
215
        let shown: Vec<&String> = problems.iter().take(5).collect();
216
        rep.push(
217
            "portability",
218
            Health::Warn,
219
            format!(
220
                "{} path problem{} on case-insensitive or Windows file systems: {}{}",
221
                problems.len(),
222
                if problems.len() == 1 { "" } else { "s" },
223
                shown.iter().map(|s| s.as_str()).collect::<Vec<_>>().join("; "),
224
                if problems.len() > 5 { "; …" } else { "" }
225
            ),
226
            Some("rename the paths so they differ by more than case and avoid Windows' reserved names and characters".into()),
227
        );
228
    }
229
230
    fn doctor_author(&self, rep: &mut DoctorReport) {
231
        let defaulted =
232
            self.config.author_email.ends_with("@localhost") || self.config.author_name.is_empty();
233
        if defaulted {
234
            rep.push(
235
                "author",
236
                Health::Warn,
237
                format!("changes are authored as {} <{}>, a default", self.config.author_name, self.config.author_email),
238
                Some("set yours with `oxy config --user author.name NAME` and `oxy config --user author.email EMAIL`".into()),
239
            );
240
        } else {
241
            rep.push(
242
                "author",
243
                Health::Ok,
244
                format!("{} <{}>", self.config.author_name, self.config.author_email),
245
                None,
246
            );
247
        }
248
    }
249
250
    /// The trust policy (spec §14.5): without one, nothing fetched is
251
    /// verified, which fetch says each time; doctor says it too, with who
252
    /// fixes it (oxygen#127).
253
    fn doctor_trust(&self, rep: &mut DoctorReport) {
254
        let policy = match self.current_policy() {
255
            Ok(p) => p,
256
            Err(e) => {
257
                rep.push(
258
                    "trust",
259
                    Health::Fail,
260
                    format!("the trust policy does not load: {e}"),
261
                    Some("run `oxy fsck` for details".into()),
262
                );
263
                return;
264
            }
265
        };
266
        let Some((r, sp)) = policy else {
267
            rep.push(
268
                "trust",
269
                Health::Warn,
270
                "no trust policy: changes and bookmark moves fetched here are not verified",
271
                Some(
272
                    "the repository's owner sets one: `oxy policy init`, then `oxy policy grant FILE` for each \
273
                     contributor and `oxy policy grant-subject --issuer HUB_KEY agent:NAME@ACCOUNT` for keyless \
274
                     agents, and `oxy push`"
275
                        .into(),
276
                ),
277
            );
278
            return;
279
        };
280
        let p = &sp.policy;
281
        let now = Hlc::now().wall_ms;
282
        let what = format!(
283
            "policy #{} serial {}: {} root{}, {} rule{}",
284
            &r.to_hex()[..12],
285
            p.serial,
286
            p.roots.len(),
287
            if p.roots.len() == 1 { "" } else { "s" },
288
            p.rules.len(),
289
            if p.rules.len() == 1 { "" } else { "s" },
290
        );
291
        let renew = Some(
292
            "a root of the policy publishes a new serial (any `oxy policy` change does)".into(),
293
        );
294
        if p.is_expired(now) {
295
            rep.push("trust", Health::Fail, format!("{what}, expired"), renew);
296
        } else if p.expires - now < 30 * 86_400_000 {
297
            rep.push(
298
                "trust",
299
                Health::Warn,
300
                format!("{what}, expires in {} days", (p.expires - now) / 86_400_000),
301
                renew,
302
            );
303
        } else {
304
            rep.push(
305
                "trust",
306
                Health::Ok,
307
                format!("{what}, expires in {} days", (p.expires - now) / 86_400_000),
308
                None,
309
            );
310
        }
311
    }
312
313
    fn doctor_remote(&self, rep: &mut DoctorReport, name: &str, online: bool) {
314
        let area = format!("remote {name}");
315
        let url = &self.config.remotes[name];
316
        let opts = self.remote_options(name);
317
        let http = url.starts_with("http://") || url.starts_with("https://");
318
        let hint = |h: &str| Some(h.replace("<remote>", name));
319
        if url.starts_with("http://") && !is_loopback(url) {
320
            rep.push(
321
                &area,
322
                Health::Warn,
323
                format!("{url} is plain HTTP: the token travels unencrypted"),
324
                hint("use the hub's https:// address"),
325
            );
326
        }
327
        if http && opts.token.is_none() {
328
            rep.push(
329
                &area,
330
                Health::Warn,
331
                "no token: fetching public repositories works, pushing will not",
332
                hint("set one with `oxy config remote.<remote>.token TOKEN`"),
333
            );
334
        }
335
        if let Some(ca) = &opts.ca {
336
            if !ca.is_file() {
337
                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`"));
338
                return;
339
            }
340
        }
341
        if !online {
342
            rep.push(&area, Health::Ok, format!("{url} (not contacted)"), None);
343
            return;
344
        }
345
        let reached = crate::protocol::open_remote(url, &opts).and_then(|r| r.repo_id());
346
        match reached {
347
            Ok(theirs) if theirs == crate::protocol::UNBOUND_ID => rep.push(
348
                &area,
349
                Health::Ok,
350
                format!("{url} is reachable and empty; the first push binds it"),
351
                None,
352
            ),
353
            Ok(theirs) if self.id().is_ok_and(|mine| mine == theirs) => rep.push(
354
                &area,
355
                Health::Ok,
356
                format!("{url} is reachable and is this repository"),
357
                None,
358
            ),
359
            Ok(_) => rep.push(
360
                &area,
361
                Health::Fail,
362
                format!("{url} is a different repository"),
363
                hint("check the URL; each repository has its own id"),
364
            ),
365
            Err(e) => {
366
                let msg = e.to_string();
367
                let first = msg.lines().next().unwrap_or(&msg).to_string();
368
                rep.push(
369
                    &area,
370
                    Health::Fail,
371
                    first,
372
                    crate::protocol::hint_for(&msg).map(|h| h.replace("<remote>", name)),
373
                );
374
            }
375
        }
376
    }
377
}
378
379
fn is_loopback(url: &str) -> bool {
380
    let host = url
381
        .split("://")
382
        .nth(1)
383
        .unwrap_or("")
384
        .split(['/', ':'])
385
        .next()
386
        .unwrap_or("");
387
    host == "localhost" || host == "127.0.0.1" || host == "[::1]"
388
}