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 => 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
33/// Checks for broken symlinks in all binary shim directories.
34///
35/// Returns a list of paths to symlinks that point to non-existent locations.
36///
37/// # Errors
38///
39/// Returns an error if any of the binary shim directories cannot be read.
40pub 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
70/// Checks if Zoi's binary directory is in the system PATH.
71///
72/// Returns a warning message if the directory is missing from PATH.
73///
74/// # Errors
75///
76/// Returns an error if the user's home directory cannot be found.
77pub 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
95/// Checks if any repositories are significantly out of date.
96///
97/// Returns a warning message if the default registry hasn't been synced in over
98/// a week.
99///
100/// # Errors
101///
102/// Returns an error if the database root cannot be resolved or if the
103/// repository metadata cannot be read.
104pub 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
136/// Finds packages that are defined in multiple registries.
137///
138/// Returns a list of package IDs and the registries they are defined in.
139///
140/// # Errors
141///
142/// Returns an error if the database root cannot be resolved or if the registry
143/// directories cannot be read.
144pub 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
194/// Checks if PGP keys required by security policy are present in the keyring.
195///
196/// Returns a list of missing key fingerprints.
197///
198/// # Errors
199///
200/// Returns an error if the configuration cannot be read.
201pub 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
226/// Validates that all packages recorded in the lockfile are actually installed.
227///
228/// Returns a list of names for missing packages.
229///
230/// # Errors
231///
232/// Returns an error if the lockfile cannot be read.
233pub 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
281/// Finds orphaned packages (dependencies no longer required by any package).
282///
283/// Returns a list of names for orphaned packages.
284///
285/// # Errors
286///
287/// Returns an error if the installed packages cannot be retrieved.
288pub 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
324/// Finds "ghost" dependent links (links from packages that are no longer
325/// installed).
326///
327/// Returns a list of (path, `parent_id`) pairs for stale dependent links.
328///
329/// # Errors
330///
331/// Returns an error if the store directories or dependent files cannot be read.
332pub 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
393/// Removes stale dependent links identified by `check_ghost_dependents`.
394///
395/// # Errors
396///
397/// Returns an error if any of the ghost links cannot be removed.
398pub 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
405/// Result of checking for external tool dependencies.
406pub struct ToolCheckResult {
407    /// List of essential tools (e.g. git) that are missing.
408    pub essential_missing: Vec<String>,
409    /// List of recommended tools (e.g. bwrap) that are missing.
410    pub recommended_missing: Vec<String>
411}
412
413/// Checks if essential and recommended external tools are available in PATH.
414pub 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
439/// Checks for "Registry Drift" (local `.pkg.lua` differs from the database).
440///
441/// # Errors
442///
443/// Returns an error if installed packages cannot be retrieved or if
444/// a package source path cannot be determined.
445pub 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                &current_hash[..8]
480            ));
481        }
482    }
483
484    Ok(drifted)
485}
486
487/// Checks for "Lockfile Mismatch" (zoi.lock targets a different platform).
488///
489/// # Errors
490///
491/// Returns an error if the lockfile cannot be read or if the current platform
492/// cannot be determined.
493pub 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}