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, 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
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 => {
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
45/// Checks for broken symlinks in all binary shim directories.
46///
47/// Returns a list of paths to symlinks that point to non-existent locations.
48///
49/// # Errors
50///
51/// Returns an error if any of the binary shim directories cannot be read.
52pub 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
82/// Checks if Zoi's binary directory is in the system PATH.
83///
84/// Returns a warning message if the directory is missing from PATH.
85///
86/// # Errors
87///
88/// Returns an error if the user's home directory cannot be found.
89pub 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
109/// Checks if any repositories are significantly out of date.
110///
111/// Returns a warning message if the default registry hasn't been synced in over
112/// a week.
113///
114/// # Errors
115///
116/// Returns an error if the database root cannot be resolved or if the
117/// repository metadata cannot be read.
118pub 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
150/// Finds packages that are defined in multiple registries.
151///
152/// Returns a list of package IDs and the registries they are defined in.
153///
154/// # Errors
155///
156/// Returns an error if the database root cannot be resolved or if the registry
157/// directories cannot be read.
158pub 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
208/// Checks if PGP keys required by security policy are present in the keyring.
209///
210/// Returns a list of missing key fingerprints.
211///
212/// # Errors
213///
214/// Returns an error if the configuration cannot be read.
215pub 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
240/// Validates that all packages recorded in the lockfile are actually installed.
241///
242/// Returns a list of names for missing packages.
243///
244/// # Errors
245///
246/// Returns an error if the lockfile cannot be read.
247pub 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
295/// Finds orphaned packages (dependencies no longer required by any package).
296///
297/// Returns a list of names for orphaned packages.
298///
299/// # Errors
300///
301/// Returns an error if the installed packages cannot be retrieved.
302pub 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
338/// Finds "ghost" dependent links (links from packages that are no longer
339/// installed).
340///
341/// Returns a list of (path, `parent_id`) pairs for stale dependent links.
342///
343/// # Errors
344///
345/// Returns an error if the store directories or dependent files cannot be read.
346pub 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
407/// Removes stale dependent links identified by `check_ghost_dependents`.
408///
409/// # Errors
410///
411/// Returns an error if any of the ghost links cannot be removed.
412pub 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
419/// Result of checking for external tool dependencies.
420pub struct ToolCheckResult {
421    /// List of essential tools (e.g. git) that are missing.
422    pub essential_missing: Vec<String>,
423    /// List of recommended tools (e.g. bwrap) that are missing.
424    pub recommended_missing: Vec<String>
425}
426
427/// Checks if essential and recommended external tools are available in PATH.
428pub 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
453/// Checks for "Registry Drift" (local `.pkg.lua` differs from the database).
454///
455/// # Errors
456///
457/// Returns an error if installed packages cannot be retrieved or if
458/// a package source path cannot be determined.
459pub 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                &current_hash[..8]
494            ));
495        }
496    }
497
498    Ok(drifted)
499}
500
501/// Checks for "Lockfile Mismatch" (zoi.lock targets a different platform).
502///
503/// # Errors
504///
505/// Returns an error if the lockfile cannot be read or if the current platform
506/// cannot be determined.
507pub 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}