Skip to main content

zoi_package/
doctor_system.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::PathBuf;
4use std::time::SystemTime;
5
6/// Implements proactive "System Health Checks" for the Zoi environment.
7///
8/// These checks (run via `zoi doctor`) help users identify and fix:
9/// - Orphaned Packages: Dependencies no longer required by any package.
10/// - Broken Symlinks: Stale binary shims in the PATH.
11/// - Integrity Issues: Mismatches between the store and the lockfile.
12/// - Path Configuration: Missing Zoi binary directories in the host PATH.
13/// - Registry Staleness: Repositories that haven't been synced recently.
14use 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
21/// Returns the root directory for binary shims for a given scope.
22fn 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
41/// Checks for broken symlinks in all binary shim directories.
42///
43/// Returns a list of paths to symlinks that point to non-existent locations.
44///
45/// # Errors
46///
47/// Returns an error if any of the binary shim directories cannot be read.
48pub 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
78/// Checks if Zoi's binary directory is in the system PATH.
79///
80/// Returns a warning message if the directory is missing from PATH.
81///
82/// # Errors
83///
84/// Returns an error if the user's home directory cannot be found.
85pub 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
103/// Checks if any repositories are significantly out of date.
104///
105/// Returns a warning message if the default registry hasn't been synced in over
106/// a week.
107///
108/// # Errors
109///
110/// Returns an error if the database root cannot be resolved or if the
111/// repository metadata cannot be read.
112pub 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
144/// Finds packages that are defined in multiple registries.
145///
146/// Returns a list of package IDs and the registries they are defined in.
147///
148/// # Errors
149///
150/// Returns an error if the database root cannot be resolved or if the registry
151/// directories cannot be read.
152pub 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
202/// Checks if PGP keys required by security policy are present in the keyring.
203///
204/// Returns a list of missing key fingerprints.
205///
206/// # Errors
207///
208/// Returns an error if the configuration cannot be read.
209pub 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
234/// Validates that all packages recorded in the lockfile are actually installed.
235///
236/// Returns a list of names for missing packages.
237///
238/// # Errors
239///
240/// Returns an error if the lockfile cannot be read.
241pub 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
289/// Finds orphaned packages (dependencies no longer required by any package).
290///
291/// Returns a list of names for orphaned packages.
292///
293/// # Errors
294///
295/// Returns an error if the installed packages cannot be retrieved.
296pub 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
332/// Finds "ghost" dependent links (links from packages that are no longer
333/// installed).
334///
335/// Returns a list of (path, `parent_id`) pairs for stale dependent links.
336///
337/// # Errors
338///
339/// Returns an error if the store directories or dependent files cannot be read.
340pub 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
401/// Removes stale dependent links identified by `check_ghost_dependents`.
402///
403/// # Errors
404///
405/// Returns an error if any of the ghost links cannot be removed.
406pub 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
413/// Result of checking for external tool dependencies.
414pub struct ToolCheckResult {
415    /// List of essential tools (e.g. git) that are missing.
416    pub essential_missing: Vec<String>,
417    /// List of recommended tools (e.g. bwrap) that are missing.
418    pub recommended_missing: Vec<String>
419}
420
421/// Checks if essential and recommended external tools are available in PATH.
422pub 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
447/// Checks for "Registry Drift" (local `.pkg.lua` differs from the database).
448///
449/// # Errors
450///
451/// Returns an error if installed packages cannot be retrieved or if
452/// a package source path cannot be determined.
453pub 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                &current_hash[..8]
488            ));
489        }
490    }
491
492    Ok(drifted)
493}
494
495/// Checks for "Lockfile Mismatch" (zoi.lock targets a different platform).
496///
497/// # Errors
498///
499/// Returns an error if the lockfile cannot be read or if the current platform
500/// cannot be determined.
501pub 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}