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 => {
26 if cfg!(target_os = "windows") {
27 Ok(sysroot::apply_sysroot(PathBuf::from(
28 "C:\\ProgramData\\zoi\\pkgs\\bin"
29 )))
30 } else {
31 Ok(sysroot::apply_sysroot(PathBuf::from("/usr/local/bin")))
32 }
33 }
34 Scope::Project => {
35 let current_dir = std::env::current_dir()?;
36 Ok(current_dir.join(".zoi").join("pkgs").join("bin"))
37 }
38 }
39}
40
41pub fn check_broken_symlinks() -> Result<Vec<PathBuf>> {
49 let scopes = [Scope::User, Scope::System, Scope::Project];
50
51 let broken_links: Vec<PathBuf> = scopes
52 .into_par_iter()
53 .map(|scope| {
54 let mut links = Vec::new();
55 if let Ok(root) = get_bin_root(scope)
56 && root.exists()
57 && let Ok(entries) = fs::read_dir(root)
58 {
59 for entry in entries.flatten() {
60 if let Ok(ft) = entry.file_type()
61 && ft.is_symlink()
62 {
63 let path = entry.path();
64 if !path.exists() {
65 links.push(path);
66 }
67 }
68 }
69 }
70 links
71 })
72 .flatten()
73 .collect();
74
75 Ok(broken_links)
76}
77
78pub fn check_path_configuration() -> Result<Option<String>> {
86 if let Ok(zoi_bin_dir) = utils::get_user_bin_dir() {
87 if !zoi_bin_dir.exists() {
88 return Ok(None);
89 }
90
91 if let Ok(path_var) = std::env::var("PATH")
92 && !std::env::split_paths(&path_var).any(|p| p == zoi_bin_dir)
93 {
94 return Ok(Some(format!(
95 "Zoi's user binary directory ({}) is not in your PATH.",
96 zoi_bin_dir.display()
97 )));
98 }
99 }
100 Ok(None)
101}
102
103pub fn check_outdated_repos() -> Result<Option<String>> {
113 let db_root = sysroot::apply_sysroot(resolve::get_db_root()?);
114 let config = config::read_config()?;
115
116 if let Some(default_reg) = config.default_registry
117 && !default_reg.handle.is_empty()
118 {
119 let repo_path = db_root.join(default_reg.handle);
120 let fetch_head = repo_path.join(".git/FETCH_HEAD");
121 if fetch_head.exists() {
122 let metadata = fs::metadata(fetch_head)?;
123 if let Ok(modified) = metadata.modified()
124 && let Ok(since_modified) =
125 SystemTime::now().duration_since(modified)
126 && since_modified.as_secs() > 60 * 60 * 24 * 7
127 {
128 let days = since_modified.as_secs() / (60 * 60 * 24);
129 return Ok(Some(format!(
130 "Default repository has not been synced in over a week \
131 (last sync: {days} days ago)."
132 )));
133 }
134 } else if repo_path.join(".git").exists() {
135 return Ok(Some(
136 "Default repository has never been synced.".to_string()
137 ));
138 }
139 }
140
141 Ok(None)
142}
143
144pub fn check_duplicate_packages() -> Result<Vec<(String, Vec<String>)>> {
153 let db_root = sysroot::apply_sysroot(resolve::get_db_root()?);
154 if !db_root.exists() {
155 return Ok(Vec::new());
156 }
157
158 let mut package_map: HashMap<String, Vec<String>> = HashMap::new();
159
160 if let Ok(entries) = fs::read_dir(&db_root) {
161 for entry in entries.flatten() {
162 let registry_handle =
163 entry.file_name().to_string_lossy().to_string();
164 if !entry.path().is_dir()
165 || registry_handle.starts_with('.')
166 || registry_handle == "git"
167 {
168 continue;
169 }
170
171 for pkg_entry in WalkDir::new(entry.path())
172 .into_iter()
173 .filter_map(Result::ok)
174 .filter(|e| {
175 e.file_name().to_string_lossy().ends_with(".pkg.lua")
176 })
177 {
178 let pkg_path = pkg_entry.path();
179 if let Ok(rel_path) = pkg_path.strip_prefix(entry.path()) {
180 let pkg_id = rel_path
181 .to_string_lossy()
182 .to_string()
183 .replace('\\', "/");
184 package_map
185 .entry(pkg_id)
186 .or_default()
187 .push(registry_handle.clone());
188 }
189 }
190 }
191 }
192
193 let mut duplicates: Vec<_> = package_map
194 .into_iter()
195 .filter(|(_, registries)| registries.len() > 1)
196 .collect();
197 duplicates.sort_by(|a, b| a.0.cmp(&b.0));
198
199 Ok(duplicates)
200}
201
202pub fn check_pgp_configuration() -> Result<Vec<String>> {
210 let config = config::read_config()?;
211 let mut missing_keys = Vec::new();
212
213 if let Some(enforcement) = config.policy.signature_enforcement
214 && enforcement.enable
215 {
216 for key in enforcement.trusted_keys {
217 match pgp::get_certs_by_name_or_fingerprint(std::slice::from_ref(
218 &key
219 )) {
220 Ok(certs) if certs.is_empty() => {
221 missing_keys.push(key);
222 }
223 Err(_) => {
224 missing_keys.push(key);
225 }
226 _ => {}
227 }
228 }
229 }
230
231 Ok(missing_keys)
232}
233
234pub fn validate_lockfile_integrity() -> Result<Vec<String>> {
242 let recorded_packages = recorder::get_recorded_packages()?;
243
244 let missing_packages: Vec<String> = recorded_packages
245 .into_par_iter()
246 .filter_map(|pkg_record| {
247 let manifest = local::is_package_installed(
248 &pkg_record.name,
249 pkg_record.sub_package.as_deref(),
250 Scope::User
251 )
252 .ok()
253 .flatten()
254 .or_else(|| {
255 local::is_package_installed(
256 &pkg_record.name,
257 pkg_record.sub_package.as_deref(),
258 Scope::System
259 )
260 .ok()
261 .flatten()
262 })
263 .or_else(|| {
264 local::is_package_installed(
265 &pkg_record.name,
266 pkg_record.sub_package.as_deref(),
267 Scope::Project
268 )
269 .ok()
270 .flatten()
271 });
272
273 if manifest.is_none() {
274 let name = if let Some(sub) = pkg_record.sub_package {
275 format!("{}:{}", pkg_record.name, sub)
276 } else {
277 pkg_record.name
278 };
279 Some(name)
280 } else {
281 None
282 }
283 })
284 .collect();
285
286 Ok(missing_packages)
287}
288
289pub fn check_orphaned_packages() -> Result<Vec<String>> {
297 let all_installed = local::get_installed_packages()?;
298
299 let orphaned: Vec<String> = all_installed
300 .into_par_iter()
301 .filter_map(|package| {
302 if !matches!(package.reason, InstallReason::Dependency { .. }) {
303 return None;
304 }
305
306 let package_dir = local::get_package_dir(
307 package.scope,
308 &package.registry_handle,
309 &package.repo,
310 &package.name
311 )
312 .ok()?;
313
314 let dependents = local::get_dependents(&package_dir).ok()?;
315
316 if dependents.is_empty() {
317 let name = if let Some(sub) = package.sub_package {
318 format!("{}:{}", package.name, sub)
319 } else {
320 package.name
321 };
322 Some(name)
323 } else {
324 None
325 }
326 })
327 .collect();
328
329 Ok(orphaned)
330}
331
332pub fn check_ghost_dependents() -> Result<Vec<(PathBuf, String)>> {
341 let scopes = [Scope::User, Scope::System, Scope::Project];
342 let mut ghost_links = Vec::new();
343
344 let all_installed = local::get_installed_packages()?;
345 let mut installed_ids = HashSet::new();
346 for manifest in all_installed {
347 let full_id = format!(
348 "#{}@{}/{}@{}",
349 manifest.registry_handle,
350 manifest.repo,
351 manifest.name,
352 manifest.version
353 );
354 installed_ids.insert(full_id);
355
356 if let Some(sub) = manifest.sub_package {
357 let full_id_sub = format!(
358 "#{}@{}/{}:{}@{}",
359 manifest.registry_handle,
360 manifest.repo,
361 manifest.name,
362 sub,
363 manifest.version
364 );
365 installed_ids.insert(full_id_sub);
366 }
367 }
368
369 for scope in scopes {
370 if let Ok(store_root) = local::get_store_base_dir(scope)
371 && store_root.exists()
372 {
373 for entry in fs::read_dir(store_root)? {
374 let path = entry?.path();
375 if path.is_dir() {
376 let dependents_dir = path.join("dependents");
377 if dependents_dir.exists() {
378 for dep_entry in fs::read_dir(dependents_dir)? {
379 let dep_path = dep_entry?.path();
380 if dep_path.is_file()
381 && let Some(file_name) = dep_path
382 .file_name()
383 .and_then(|s| s.to_str())
384 && let Ok(decoded) = hex::decode(file_name)
385 && let Ok(parent_id) =
386 String::from_utf8(decoded)
387 && !installed_ids.contains(&parent_id)
388 {
389 ghost_links.push((dep_path, parent_id));
390 }
391 }
392 }
393 }
394 }
395 }
396 }
397
398 Ok(ghost_links)
399}
400
401pub fn prune_ghost_dependents(ghost_links: &[(PathBuf, String)]) -> Result<()> {
407 for (path, _) in ghost_links {
408 fs::remove_file(path)?;
409 }
410 Ok(())
411}
412
413pub struct ToolCheckResult {
415 pub essential_missing: Vec<String>,
417 pub recommended_missing: Vec<String>
419}
420
421pub fn check_external_tools() -> ToolCheckResult {
423 let mut essential_missing = Vec::new();
424 let mut recommended_missing = Vec::new();
425
426 let essential = ["git", "gpg"];
427 let recommended = ["bwrap"];
428
429 for tool in essential {
430 if !utils::command_exists(tool) {
431 essential_missing.push(tool.to_string());
432 }
433 }
434
435 for tool in recommended {
436 if !utils::command_exists(tool) {
437 recommended_missing.push(tool.to_string());
438 }
439 }
440
441 ToolCheckResult {
442 essential_missing,
443 recommended_missing
444 }
445}
446
447pub fn check_registry_drift() -> Result<Vec<String>> {
454 let all_installed = local::get_installed_packages()?;
455 let mut drifted = Vec::new();
456
457 for manifest in all_installed {
458 let pkg_lua_path = local::get_package_source_path(&manifest)?;
459 if !pkg_lua_path.exists() {
460 continue;
461 }
462
463 let Ok(lua_content) = fs::read_to_string(&pkg_lua_path) else {
464 continue;
465 };
466 let current_hash = zoi_core::hash::calculate_string_hash(
467 &lua_content,
468 zoi_core::hash::HashAlgorithm::Sha256
469 );
470
471 if let Ok(Some(db_hash)) = zoi_db::get_package_hash_from_db(
472 &manifest.registry_handle,
473 &manifest.name,
474 manifest.sub_package.as_deref(),
475 &manifest.repo
476 ) && current_hash != db_hash
477 {
478 let name = if let Some(sub) = manifest.sub_package {
479 format!("{}:{}", manifest.name, sub)
480 } else {
481 manifest.name
482 };
483 drifted.push(format!(
484 "{} (Database: {}, Local: {})",
485 name,
486 &db_hash[..8],
487 ¤t_hash[..8]
488 ));
489 }
490 }
491
492 Ok(drifted)
493}
494
495pub fn check_lockfile_mismatch() -> Result<Option<String>> {
502 if !std::path::Path::new("zoi.lock").exists() {
503 return Ok(None);
504 }
505
506 let lockfile = zoi_project::lockfile::read_zoi_lock()?;
507 let current_platform = utils::get_platform()?;
508
509 if let Some(target_platform) = lockfile.platform
510 && target_platform != current_platform
511 {
512 return Ok(Some(format!(
513 "Lockfile targets '{target_platform}', but current host is \
514 '{current_platform}'."
515 )));
516 }
517
518 Ok(None)
519}