zoi_package/
doctor_system.rs1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::PathBuf;
4use std::time::SystemTime;
5
6use anyhow::Result;
15use rayon::prelude::*;
16use walkdir::WalkDir;
17use zoi_core::types::{InstallReason, Scope};
18use zoi_core::{config, pgp, recorder, sysroot, utils};
19use zoi_resolver::{local, resolve};
20
21fn get_bin_root(scope: Scope) -> Result<PathBuf> {
23 match scope {
24 Scope::User => utils::get_user_bin_dir(),
25 Scope::System => Ok(utils::get_system_bin_dir()),
26 Scope::Project => {
27 let current_dir = std::env::current_dir()?;
28 Ok(current_dir.join(".zoi").join("pkgs").join("bin"))
29 }
30 }
31}
32
33pub fn check_broken_symlinks() -> Result<Vec<PathBuf>> {
41 let scopes = [Scope::User, Scope::System, Scope::Project];
42
43 let broken_links: Vec<PathBuf> = scopes
44 .into_par_iter()
45 .map(|scope| {
46 let mut links = Vec::new();
47 if let Ok(root) = get_bin_root(scope)
48 && root.exists()
49 && let Ok(entries) = fs::read_dir(root)
50 {
51 for entry in entries.flatten() {
52 if let Ok(ft) = entry.file_type()
53 && ft.is_symlink()
54 {
55 let path = entry.path();
56 if !path.exists() {
57 links.push(path);
58 }
59 }
60 }
61 }
62 links
63 })
64 .flatten()
65 .collect();
66
67 Ok(broken_links)
68}
69
70pub fn check_path_configuration() -> Result<Option<String>> {
78 if let Ok(zoi_bin_dir) = utils::get_user_bin_dir() {
79 if !zoi_bin_dir.exists() {
80 return Ok(None);
81 }
82
83 if let Ok(path_var) = std::env::var("PATH")
84 && !std::env::split_paths(&path_var).any(|p| p == zoi_bin_dir)
85 {
86 return Ok(Some(format!(
87 "Zoi's user binary directory ({}) is not in your PATH.",
88 zoi_bin_dir.display()
89 )));
90 }
91 }
92 Ok(None)
93}
94
95pub fn check_outdated_repos() -> Result<Option<String>> {
105 let db_root = sysroot::apply_sysroot(resolve::get_db_root()?);
106 let config = config::read_config()?;
107
108 if let Some(default_reg) = config.default_registry
109 && !default_reg.handle.is_empty()
110 {
111 let repo_path = db_root.join(default_reg.handle);
112 let fetch_head = repo_path.join(".git/FETCH_HEAD");
113 if fetch_head.exists() {
114 let metadata = fs::metadata(fetch_head)?;
115 if let Ok(modified) = metadata.modified()
116 && let Ok(since_modified) =
117 SystemTime::now().duration_since(modified)
118 && since_modified.as_secs() > 60 * 60 * 24 * 7
119 {
120 let days = since_modified.as_secs() / (60 * 60 * 24);
121 return Ok(Some(format!(
122 "Default repository has not been synced in over a week \
123 (last sync: {days} days ago)."
124 )));
125 }
126 } else if repo_path.join(".git").exists() {
127 return Ok(Some(
128 "Default repository has never been synced.".to_string()
129 ));
130 }
131 }
132
133 Ok(None)
134}
135
136pub fn check_duplicate_packages() -> Result<Vec<(String, Vec<String>)>> {
145 let db_root = sysroot::apply_sysroot(resolve::get_db_root()?);
146 if !db_root.exists() {
147 return Ok(Vec::new());
148 }
149
150 let mut package_map: HashMap<String, Vec<String>> = HashMap::new();
151
152 if let Ok(entries) = fs::read_dir(&db_root) {
153 for entry in entries.flatten() {
154 let registry_handle =
155 entry.file_name().to_string_lossy().to_string();
156 if !entry.path().is_dir()
157 || registry_handle.starts_with('.')
158 || registry_handle == "git"
159 {
160 continue;
161 }
162
163 for pkg_entry in WalkDir::new(entry.path())
164 .into_iter()
165 .filter_map(Result::ok)
166 .filter(|e| {
167 e.file_name().to_string_lossy().ends_with(".pkg.lua")
168 })
169 {
170 let pkg_path = pkg_entry.path();
171 if let Ok(rel_path) = pkg_path.strip_prefix(entry.path()) {
172 let pkg_id = rel_path
173 .to_string_lossy()
174 .to_string()
175 .replace('\\', "/");
176 package_map
177 .entry(pkg_id)
178 .or_default()
179 .push(registry_handle.clone());
180 }
181 }
182 }
183 }
184
185 let mut duplicates: Vec<_> = package_map
186 .into_iter()
187 .filter(|(_, registries)| registries.len() > 1)
188 .collect();
189 duplicates.sort_by(|a, b| a.0.cmp(&b.0));
190
191 Ok(duplicates)
192}
193
194pub fn check_pgp_configuration() -> Result<Vec<String>> {
202 let config = config::read_config()?;
203 let mut missing_keys = Vec::new();
204
205 if let Some(enforcement) = config.policy.signature_enforcement
206 && enforcement.enable
207 {
208 for key in enforcement.trusted_keys {
209 match pgp::get_certs_by_name_or_fingerprint(std::slice::from_ref(
210 &key
211 )) {
212 Ok(certs) if certs.is_empty() => {
213 missing_keys.push(key);
214 }
215 Err(_) => {
216 missing_keys.push(key);
217 }
218 _ => {}
219 }
220 }
221 }
222
223 Ok(missing_keys)
224}
225
226pub fn validate_lockfile_integrity() -> Result<Vec<String>> {
234 let recorded_packages = recorder::get_recorded_packages()?;
235
236 let missing_packages: Vec<String> = recorded_packages
237 .into_par_iter()
238 .filter_map(|pkg_record| {
239 let manifest = local::is_package_installed(
240 &pkg_record.name,
241 pkg_record.sub_package.as_deref(),
242 Scope::User
243 )
244 .ok()
245 .flatten()
246 .or_else(|| {
247 local::is_package_installed(
248 &pkg_record.name,
249 pkg_record.sub_package.as_deref(),
250 Scope::System
251 )
252 .ok()
253 .flatten()
254 })
255 .or_else(|| {
256 local::is_package_installed(
257 &pkg_record.name,
258 pkg_record.sub_package.as_deref(),
259 Scope::Project
260 )
261 .ok()
262 .flatten()
263 });
264
265 if manifest.is_none() {
266 let name = if let Some(sub) = pkg_record.sub_package {
267 format!("{}:{}", pkg_record.name, sub)
268 } else {
269 pkg_record.name
270 };
271 Some(name)
272 } else {
273 None
274 }
275 })
276 .collect();
277
278 Ok(missing_packages)
279}
280
281pub fn check_orphaned_packages() -> Result<Vec<String>> {
289 let all_installed = local::get_installed_packages()?;
290
291 let orphaned: Vec<String> = all_installed
292 .into_par_iter()
293 .filter_map(|package| {
294 if !matches!(package.reason, InstallReason::Dependency { .. }) {
295 return None;
296 }
297
298 let package_dir = local::get_package_dir(
299 package.scope,
300 &package.registry_handle,
301 &package.repo,
302 &package.name
303 )
304 .ok()?;
305
306 let dependents = local::get_dependents(&package_dir).ok()?;
307
308 if dependents.is_empty() {
309 let name = if let Some(sub) = package.sub_package {
310 format!("{}:{}", package.name, sub)
311 } else {
312 package.name
313 };
314 Some(name)
315 } else {
316 None
317 }
318 })
319 .collect();
320
321 Ok(orphaned)
322}
323
324pub fn check_ghost_dependents() -> Result<Vec<(PathBuf, String)>> {
333 let scopes = [Scope::User, Scope::System, Scope::Project];
334 let mut ghost_links = Vec::new();
335
336 let all_installed = local::get_installed_packages()?;
337 let mut installed_ids = HashSet::new();
338 for manifest in all_installed {
339 let full_id = format!(
340 "#{}@{}/{}@{}",
341 manifest.registry_handle,
342 manifest.repo,
343 manifest.name,
344 manifest.version
345 );
346 installed_ids.insert(full_id);
347
348 if let Some(sub) = manifest.sub_package {
349 let full_id_sub = format!(
350 "#{}@{}/{}:{}@{}",
351 manifest.registry_handle,
352 manifest.repo,
353 manifest.name,
354 sub,
355 manifest.version
356 );
357 installed_ids.insert(full_id_sub);
358 }
359 }
360
361 for scope in scopes {
362 if let Ok(store_root) = local::get_store_base_dir(scope)
363 && store_root.exists()
364 {
365 for entry in fs::read_dir(store_root)? {
366 let path = entry?.path();
367 if path.is_dir() {
368 let dependents_dir = path.join("dependents");
369 if dependents_dir.exists() {
370 for dep_entry in fs::read_dir(dependents_dir)? {
371 let dep_path = dep_entry?.path();
372 if dep_path.is_file()
373 && let Some(file_name) = dep_path
374 .file_name()
375 .and_then(|s| s.to_str())
376 && let Ok(decoded) = hex::decode(file_name)
377 && let Ok(parent_id) =
378 String::from_utf8(decoded)
379 && !installed_ids.contains(&parent_id)
380 {
381 ghost_links.push((dep_path, parent_id));
382 }
383 }
384 }
385 }
386 }
387 }
388 }
389
390 Ok(ghost_links)
391}
392
393pub fn prune_ghost_dependents(ghost_links: &[(PathBuf, String)]) -> Result<()> {
399 for (path, _) in ghost_links {
400 fs::remove_file(path)?;
401 }
402 Ok(())
403}
404
405pub struct ToolCheckResult {
407 pub essential_missing: Vec<String>,
409 pub recommended_missing: Vec<String>
411}
412
413pub fn check_external_tools() -> ToolCheckResult {
415 let mut essential_missing = Vec::new();
416 let mut recommended_missing = Vec::new();
417
418 let essential = ["git", "gpg"];
419 let recommended = ["bwrap"];
420
421 for tool in essential {
422 if !utils::command_exists(tool) {
423 essential_missing.push(tool.to_string());
424 }
425 }
426
427 for tool in recommended {
428 if !utils::command_exists(tool) {
429 recommended_missing.push(tool.to_string());
430 }
431 }
432
433 ToolCheckResult {
434 essential_missing,
435 recommended_missing
436 }
437}
438
439pub fn check_registry_drift() -> Result<Vec<String>> {
446 let all_installed = local::get_installed_packages()?;
447 let mut drifted = Vec::new();
448
449 for manifest in all_installed {
450 let pkg_lua_path = local::get_package_source_path(&manifest)?;
451 if !pkg_lua_path.exists() {
452 continue;
453 }
454
455 let Ok(lua_content) = fs::read_to_string(&pkg_lua_path) else {
456 continue;
457 };
458 let current_hash = zoi_core::hash::calculate_string_hash(
459 &lua_content,
460 zoi_core::hash::HashAlgorithm::Sha256
461 );
462
463 if let Ok(Some(db_hash)) = zoi_db::get_package_hash_from_db(
464 &manifest.registry_handle,
465 &manifest.name,
466 manifest.sub_package.as_deref(),
467 &manifest.repo
468 ) && current_hash != db_hash
469 {
470 let name = if let Some(sub) = manifest.sub_package {
471 format!("{}:{}", manifest.name, sub)
472 } else {
473 manifest.name
474 };
475 drifted.push(format!(
476 "{} (Database: {}, Local: {})",
477 name,
478 &db_hash[..8],
479 ¤t_hash[..8]
480 ));
481 }
482 }
483
484 Ok(drifted)
485}
486
487pub fn check_lockfile_mismatch() -> Result<Option<String>> {
494 if !std::path::Path::new("zoi.lock").exists() {
495 return Ok(None);
496 }
497
498 let lockfile = zoi_project::lockfile::read_zoi_lock()?;
499 let current_platform = utils::get_platform()?;
500
501 if let Some(target_platform) = lockfile.platform
502 && target_platform != current_platform
503 {
504 return Ok(Some(format!(
505 "Lockfile targets '{target_platform}', but current host is \
506 '{current_platform}'."
507 )));
508 }
509
510 Ok(None)
511}