zoi_package/
doctor_system.rs1use anyhow::{Result, anyhow};
10use rayon::prelude::*;
11use std::collections::{HashMap, HashSet};
12use std::fs;
13use std::path::PathBuf;
14use std::time::SystemTime;
15use walkdir::WalkDir;
16use zoi_core::config;
17use zoi_core::pgp;
18use zoi_core::recorder;
19use zoi_core::sysroot;
20use zoi_core::types::{InstallReason, Scope};
21use zoi_core::utils;
22use zoi_resolver::local;
23use zoi_resolver::resolve;
24
25fn get_bin_root(scope: Scope) -> Result<PathBuf> {
26 match scope {
27 Scope::User => {
28 let home_dir =
29 utils::get_user_home().ok_or_else(|| anyhow!("Could not find home directory."))?;
30 Ok(sysroot::apply_sysroot(home_dir.join(".zoi/pkgs/bin")))
31 }
32 Scope::System => {
33 if cfg!(target_os = "windows") {
34 Ok(sysroot::apply_sysroot(PathBuf::from(
35 "C:\\ProgramData\\zoi\\pkgs\\bin",
36 )))
37 } else {
38 Ok(sysroot::apply_sysroot(PathBuf::from("/usr/local/bin")))
39 }
40 }
41 Scope::Project => {
42 let current_dir = std::env::current_dir()?;
43 Ok(current_dir.join(".zoi").join("pkgs").join("bin"))
44 }
45 }
46}
47
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
78pub fn check_path_configuration() -> Result<Option<String>> {
79 if let Some(home) = utils::get_user_home() {
80 let zoi_bin_dir = sysroot::apply_sysroot(home.join(".zoi").join("pkgs").join("bin"));
81 if !zoi_bin_dir.exists() {
82 return Ok(None);
83 }
84
85 if let Ok(path_var) = std::env::var("PATH")
86 && !std::env::split_paths(&path_var).any(|p| p == zoi_bin_dir)
87 {
88 return Ok(Some(format!(
89 "Zoi's user binary directory ({}) is not in your PATH.",
90 zoi_bin_dir.display()
91 )));
92 }
93 }
94 Ok(None)
95}
96
97pub fn check_outdated_repos() -> Result<Option<String>> {
98 let db_root = sysroot::apply_sysroot(resolve::get_db_root()?);
99 let config = config::read_config()?;
100
101 if let Some(default_reg) = config.default_registry
102 && !default_reg.handle.is_empty()
103 {
104 let repo_path = db_root.join(default_reg.handle);
105 let fetch_head = repo_path.join(".git/FETCH_HEAD");
106 if fetch_head.exists() {
107 let metadata = fs::metadata(fetch_head)?;
108 if let Ok(modified) = metadata.modified()
109 && let Ok(since_modified) = SystemTime::now().duration_since(modified)
110 && since_modified.as_secs() > 60 * 60 * 24 * 7
111 {
112 return Ok(Some(format!(
113 "Default repository has not been synced in over a week (last sync: {} days ago).",
114 since_modified.as_secs() / (60 * 60 * 24)
115 )));
116 }
117 } else if repo_path.join(".git").exists() {
118 return Ok(Some(
119 "Default repository has never been synced.".to_string(),
120 ));
121 }
122 }
123
124 Ok(None)
125}
126
127pub fn check_duplicate_packages() -> Result<Vec<(String, Vec<String>)>> {
128 let db_root = sysroot::apply_sysroot(resolve::get_db_root()?);
129 if !db_root.exists() {
130 return Ok(Vec::new());
131 }
132
133 let mut package_map: HashMap<String, Vec<String>> = HashMap::new();
134
135 if let Ok(entries) = fs::read_dir(&db_root) {
136 for entry in entries.flatten() {
137 let registry_handle = entry.file_name().to_string_lossy().to_string();
138 if !entry.path().is_dir()
139 || registry_handle.starts_with('.')
140 || registry_handle == "git"
141 {
142 continue;
143 }
144
145 for pkg_entry in WalkDir::new(entry.path())
146 .into_iter()
147 .filter_map(|e| e.ok())
148 .filter(|e| e.file_name().to_string_lossy().ends_with(".pkg.lua"))
149 {
150 let pkg_path = pkg_entry.path();
151 if let Ok(rel_path) = pkg_path.strip_prefix(entry.path()) {
152 let pkg_id = rel_path.to_string_lossy().to_string().replace('\\', "/");
153 package_map
154 .entry(pkg_id)
155 .or_default()
156 .push(registry_handle.clone());
157 }
158 }
159 }
160 }
161
162 let mut duplicates: Vec<_> = package_map
163 .into_iter()
164 .filter(|(_, registries)| registries.len() > 1)
165 .collect();
166 duplicates.sort_by(|a, b| a.0.cmp(&b.0));
167
168 Ok(duplicates)
169}
170
171pub fn check_pgp_configuration() -> Result<Vec<String>> {
172 let config = config::read_config()?;
173 let mut missing_keys = Vec::new();
174
175 if let Some(enforcement) = config.policy.signature_enforcement
176 && enforcement.enable
177 {
178 for key in enforcement.trusted_keys {
179 match pgp::get_certs_by_name_or_fingerprint(std::slice::from_ref(&key)) {
180 Ok(certs) if certs.is_empty() => {
181 missing_keys.push(key);
182 }
183 Err(_) => {
184 missing_keys.push(key);
185 }
186 _ => {}
187 }
188 }
189 }
190
191 Ok(missing_keys)
192}
193
194pub fn validate_lockfile_integrity() -> Result<Vec<String>> {
195 let recorded_packages = recorder::get_recorded_packages()?;
196
197 let missing_packages: Vec<String> = recorded_packages
198 .into_par_iter()
199 .filter_map(|pkg_record| {
200 let manifest = local::is_package_installed(
201 &pkg_record.name,
202 pkg_record.sub_package.as_deref(),
203 Scope::User,
204 )
205 .ok()
206 .flatten()
207 .or_else(|| {
208 local::is_package_installed(
209 &pkg_record.name,
210 pkg_record.sub_package.as_deref(),
211 Scope::System,
212 )
213 .ok()
214 .flatten()
215 })
216 .or_else(|| {
217 local::is_package_installed(
218 &pkg_record.name,
219 pkg_record.sub_package.as_deref(),
220 Scope::Project,
221 )
222 .ok()
223 .flatten()
224 });
225
226 if manifest.is_none() {
227 let name = if let Some(sub) = pkg_record.sub_package {
228 format!("{}:{}", pkg_record.name, sub)
229 } else {
230 pkg_record.name
231 };
232 Some(name)
233 } else {
234 None
235 }
236 })
237 .collect();
238
239 Ok(missing_packages)
240}
241
242pub fn check_orphaned_packages() -> Result<Vec<String>> {
243 let all_installed = local::get_installed_packages()?;
244
245 let orphaned: Vec<String> = all_installed
246 .into_par_iter()
247 .filter_map(|package| {
248 if !matches!(package.reason, InstallReason::Dependency { .. }) {
249 return None;
250 }
251
252 let package_dir = local::get_package_dir(
253 package.scope,
254 &package.registry_handle,
255 &package.repo,
256 &package.name,
257 )
258 .ok()?;
259
260 let dependents = local::get_dependents(&package_dir).ok()?;
261
262 if dependents.is_empty() {
263 let name = if let Some(sub) = package.sub_package {
264 format!("{}:{}", package.name, sub)
265 } else {
266 package.name
267 };
268 Some(name)
269 } else {
270 None
271 }
272 })
273 .collect();
274
275 Ok(orphaned)
276}
277
278pub fn check_ghost_dependents() -> Result<Vec<(PathBuf, String)>> {
279 let scopes = [Scope::User, Scope::System, Scope::Project];
280 let mut ghost_links = Vec::new();
281
282 let all_installed = local::get_installed_packages()?;
283 let mut installed_ids = HashSet::new();
284 for manifest in all_installed {
285 let full_id = format!(
286 "#{}@{}/{}@{}",
287 manifest.registry_handle, manifest.repo, manifest.name, manifest.version
288 );
289 installed_ids.insert(full_id);
290
291 if let Some(sub) = manifest.sub_package {
292 let full_id_sub = format!(
293 "#{}@{}/{}:{}@{}",
294 manifest.registry_handle, manifest.repo, manifest.name, sub, manifest.version
295 );
296 installed_ids.insert(full_id_sub);
297 }
298 }
299
300 for scope in scopes {
301 if let Ok(store_root) = local::get_store_base_dir(scope)
302 && store_root.exists()
303 {
304 for entry in fs::read_dir(store_root)? {
305 let path = entry?.path();
306 if path.is_dir() {
307 let dependents_dir = path.join("dependents");
308 if dependents_dir.exists() {
309 for dep_entry in fs::read_dir(dependents_dir)? {
310 let dep_path = dep_entry?.path();
311 if dep_path.is_file()
312 && let Some(file_name) =
313 dep_path.file_name().and_then(|s| s.to_str())
314 && let Ok(decoded) = hex::decode(file_name)
315 && let Ok(parent_id) = String::from_utf8(decoded)
316 && !installed_ids.contains(&parent_id)
317 {
318 ghost_links.push((dep_path, parent_id));
319 }
320 }
321 }
322 }
323 }
324 }
325 }
326
327 Ok(ghost_links)
328}
329
330pub fn prune_ghost_dependents(ghost_links: &[(PathBuf, String)]) -> Result<()> {
331 for (path, _) in ghost_links {
332 fs::remove_file(path)?;
333 }
334 Ok(())
335}
336
337pub struct ToolCheckResult {
338 pub essential_missing: Vec<String>,
339 pub recommended_missing: Vec<String>,
340}
341
342pub fn check_external_tools() -> ToolCheckResult {
343 let mut essential_missing = Vec::new();
344 let mut recommended_missing = Vec::new();
345
346 let essential = ["git"];
347 let recommended = ["gpg", "bwrap"];
348
349 for tool in essential {
350 if !utils::command_exists(tool) {
351 essential_missing.push(tool.to_string());
352 }
353 }
354
355 for tool in recommended {
356 if !utils::command_exists(tool) {
357 recommended_missing.push(tool.to_string());
358 }
359 }
360
361 ToolCheckResult {
362 essential_missing,
363 recommended_missing,
364 }
365}