| 1 |
|
| 2 |
|
| 3 |
|
| 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 |
|
| 17 |
pub area: String, |
| 18 |
pub health: Health, |
| 19 |
pub message: String, |
| 20 |
|
| 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 |
|
| 55 |
const STALE_LOCK_SECS: u64 = 60; |
| 56 |
|
| 57 |
impl Repo { |
| 58 |
|
| 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 |
|
| 138 |
|
| 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 |
|
| 208 |
|
| 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 |
|
| 272 |
|
| 273 |
|
| 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 |
} |