OXYGENHUB
# e8d38e47e9d7 oxygen / crates / oxy-core / src / repo / doctor.rs
11.1 KiB · Rust · last changed Portability: exec bits, symlinks and path names on macOS and Windows 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
        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
            Err(e) => {
137
                rep.push(
138
                    "identity",
139
                    Health::Fail,
140
                    format!(
141
                        "identity {} is configured but cannot be read: {e}",
142
                        key.short(12)
143
                    ),
144
                    Some("restore it, or create a new one with `oxy identity create`".into()),
145
                );
146
                return;
147
            }
148
        };
149
        let secret = self.keys_dir.join(format!("{}.secret", key.to_hex()));
150
        if !secret.is_file() {
151
            rep.push(
152
                "identity",
153
                Health::Fail,
154
                format!("the secret key for {} is missing ({})", key.short(12), secret.display()),
155
                Some("copy the key file back from a backup; without it nothing can be signed with this identity".into()),
156
            );
157
            return;
158
        }
159
        let now = Hlc::now().wall_ms;
160
        match id.expires {
161
            Some(t) if t <= now => rep.push(
162
                "identity",
163
                Health::Fail,
164
                format!("{} <{}> expired", id.name, id.email),
165
                Some("renew it with `oxy identity renew`".into()),
166
            ),
167
            Some(t) if t - now < 30 * 86_400_000 => rep.push(
168
                "identity",
169
                Health::Warn,
170
                format!(
171
                    "{} <{}> expires in {} days",
172
                    id.name,
173
                    id.email,
174
                    (t - now) / 86_400_000
175
                ),
176
                Some("renew it with `oxy identity renew`".into()),
177
            ),
178
            _ => rep.push(
179
                "identity",
180
                Health::Ok,
181
                format!("{} <{}> ({})", id.name, id.email, key.short(12)),
182
                None,
183
            ),
184
        }
185
    }
186
187
    /// Paths of the current bookmark that another platform's file system
188
    /// cannot check out as they are (spec §7.7).
189
    fn doctor_portability(&self, rep: &mut DoctorReport) {
190
        let Ok(heads) = self.bookmark_heads() else {
191
            return;
192
        };
193
        let Ok(state) = self.load_state(&heads) else {
194
            return;
195
        };
196
        let view = state.tree().view();
197
        let paths: Vec<&str> = view.entries.keys().map(String::as_str).collect();
198
        let mut problems: Vec<String> =
199
            crate::portable::Platform::windows().problems(paths.iter().copied());
200
        problems.sort();
201
        problems.dedup();
202
        if problems.is_empty() {
203
            rep.push(
204
                "portability",
205
                Health::Ok,
206
                format!(
207
                    "every path of {} checks out on Linux, macOS and Windows",
208
                    self.config.bookmark
209
                ),
210
                None,
211
            );
212
            return;
213
        }
214
        let shown: Vec<&String> = problems.iter().take(5).collect();
215
        rep.push(
216
            "portability",
217
            Health::Warn,
218
            format!(
219
                "{} path problem{} on case-insensitive or Windows file systems: {}{}",
220
                problems.len(),
221
                if problems.len() == 1 { "" } else { "s" },
222
                shown.iter().map(|s| s.as_str()).collect::<Vec<_>>().join("; "),
223
                if problems.len() > 5 { "; …" } else { "" }
224
            ),
225
            Some("rename the paths so they differ by more than case and avoid Windows' reserved names and characters".into()),
226
        );
227
    }
228
229
    fn doctor_author(&self, rep: &mut DoctorReport) {
230
        let defaulted =
231
            self.config.author_email.ends_with("@localhost") || self.config.author_name.is_empty();
232
        if defaulted {
233
            rep.push(
234
                "author",
235
                Health::Warn,
236
                format!("changes are authored as {} <{}>, a default", self.config.author_name, self.config.author_email),
237
                Some("set yours with `oxy config --user author.name NAME` and `oxy config --user author.email EMAIL`".into()),
238
            );
239
        } else {
240
            rep.push(
241
                "author",
242
                Health::Ok,
243
                format!("{} <{}>", self.config.author_name, self.config.author_email),
244
                None,
245
            );
246
        }
247
    }
248
249
    fn doctor_remote(&self, rep: &mut DoctorReport, name: &str, online: bool) {
250
        let area = format!("remote {name}");
251
        let url = &self.config.remotes[name];
252
        let opts = self.remote_options(name);
253
        let http = url.starts_with("http://") || url.starts_with("https://");
254
        let hint = |h: &str| Some(h.replace("<remote>", name));
255
        if url.starts_with("http://") && !is_loopback(url) {
256
            rep.push(
257
                &area,
258
                Health::Warn,
259
                format!("{url} is plain HTTP: the token travels unencrypted"),
260
                hint("use the hub's https:// address"),
261
            );
262
        }
263
        if http && opts.token.is_none() {
264
            rep.push(
265
                &area,
266
                Health::Warn,
267
                "no token: fetching public repositories works, pushing will not",
268
                hint("set one with `oxy config remote.<remote>.token TOKEN`"),
269
            );
270
        }
271
        if let Some(ca) = &opts.ca {
272
            if !ca.is_file() {
273
                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`"));
274
                return;
275
            }
276
        }
277
        if !online {
278
            rep.push(&area, Health::Ok, format!("{url} (not contacted)"), None);
279
            return;
280
        }
281
        let reached = crate::protocol::open_remote(url, &opts).and_then(|r| r.repo_id());
282
        match reached {
283
            Ok(theirs) if theirs == crate::protocol::UNBOUND_ID => rep.push(
284
                &area,
285
                Health::Ok,
286
                format!("{url} is reachable and empty; the first push binds it"),
287
                None,
288
            ),
289
            Ok(theirs) if self.id().is_ok_and(|mine| mine == theirs) => rep.push(
290
                &area,
291
                Health::Ok,
292
                format!("{url} is reachable and is this repository"),
293
                None,
294
            ),
295
            Ok(_) => rep.push(
296
                &area,
297
                Health::Fail,
298
                format!("{url} is a different repository"),
299
                hint("check the URL; each repository has its own id"),
300
            ),
301
            Err(e) => {
302
                let msg = e.to_string();
303
                let first = msg.lines().next().unwrap_or(&msg).to_string();
304
                rep.push(
305
                    &area,
306
                    Health::Fail,
307
                    first,
308
                    crate::protocol::hint_for(&msg).map(|h| h.replace("<remote>", name)),
309
                );
310
            }
311
        }
312
    }
313
}
314
315
fn is_loopback(url: &str) -> bool {
316
    let host = url
317
        .split("://")
318
        .nth(1)
319
        .unwrap_or("")
320
        .split(['/', ':'])
321
        .next()
322
        .unwrap_or("");
323
    host == "localhost" || host == "127.0.0.1" || host == "[::1]"
324
}