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