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