winbrew_app\operations\doctor\scan/
package.rs1use std::io::ErrorKind;
2use std::path::Path;
3
4use anyhow::Result;
5
6use crate::database;
7use crate::models::domains::install::EngineKind;
8use crate::models::domains::installed::InstalledPackage;
9use crate::models::domains::reporting::{DiagnosisResult, DiagnosisSeverity};
10
11use super::{ScanResult, sort_diagnoses, sort_recovery_findings};
12
13pub(crate) type PackageInstallScan = ScanResult;
14
15fn validate_install_path(pkg: &InstalledPackage) -> Option<DiagnosisResult> {
21 if pkg.install_dir.trim().is_empty() {
22 return Some(DiagnosisResult {
23 error_code: "empty_install_path".to_string(),
24 description: format!("{}: empty install directory", pkg.name),
25 severity: DiagnosisSeverity::Error,
26 });
27 }
28
29 if pkg.install_dir.contains('\0') {
30 return Some(DiagnosisResult {
31 error_code: "invalid_path_null_byte".to_string(),
32 description: format!(
33 "{}: path contains null byte ({})",
34 pkg.name, pkg.install_dir
35 ),
36 severity: DiagnosisSeverity::Error,
37 });
38 }
39
40 None
41}
42
43fn diagnose_install_dir_error(pkg: &InstalledPackage, err: std::io::Error) -> DiagnosisResult {
48 let (error_code, description) = match err.kind() {
49 ErrorKind::NotFound => (
50 "missing_install_directory",
51 format!(
52 "{}: missing install directory ({})",
53 pkg.name, pkg.install_dir
54 ),
55 ),
56 ErrorKind::PermissionDenied => (
57 "install_directory_permission_denied",
58 format!(
59 "{}: install directory permission denied ({})",
60 pkg.name, pkg.install_dir
61 ),
62 ),
63 _ => (
64 "install_directory_unreadable",
65 format!(
66 "{}: install directory is unreadable ({}) - {}",
67 pkg.name, pkg.install_dir, err
68 ),
69 ),
70 };
71
72 DiagnosisResult {
73 error_code: error_code.to_string(),
74 description,
75 severity: DiagnosisSeverity::Error,
76 }
77}
78
79pub(super) fn check_package(pkg: &InstalledPackage) -> Option<DiagnosisResult> {
85 check_package_with_registry(pkg, native_exe_registry_entry_exists)
86}
87
88fn check_package_with_registry(
89 pkg: &InstalledPackage,
90 native_exe_registry_entry_exists: impl Fn(&InstalledPackage) -> bool,
91) -> Option<DiagnosisResult> {
92 if let Some(diagnosis) = validate_install_path(pkg) {
93 return Some(diagnosis);
94 }
95
96 if matches!(pkg.engine_kind, EngineKind::NativeExe) && native_exe_registry_entry_exists(pkg) {
97 return None;
98 }
99
100 let install_dir = Path::new(&pkg.install_dir);
101
102 let metadata = match std::fs::metadata(install_dir) {
103 Ok(metadata) => metadata,
104 Err(err) => return Some(diagnose_install_dir_error(pkg, err)),
105 };
106
107 if !metadata.is_dir() {
108 return Some(DiagnosisResult {
109 error_code: "install_directory_not_a_directory".to_string(),
110 description: format!(
111 "{}: install path is not a directory ({})",
112 pkg.name, pkg.install_dir
113 ),
114 severity: DiagnosisSeverity::Error,
115 });
116 }
117
118 None
119}
120
121#[cfg(windows)]
122fn native_exe_registry_entry_exists(pkg: &InstalledPackage) -> bool {
123 match crate::windows::installed::uninstall_entries_matching(&pkg.name) {
124 Ok(entries) => entries.into_iter().any(|entry| {
125 entry
126 .display_name
127 .trim()
128 .eq_ignore_ascii_case(pkg.name.as_str())
129 }),
130 Err(_) => false,
131 }
132}
133
134#[cfg(not(windows))]
135fn native_exe_registry_entry_exists(_pkg: &InstalledPackage) -> bool {
136 false
137}
138
139pub(crate) fn scan_packages(packages: &[InstalledPackage]) -> PackageInstallScan {
140 let mut scan: PackageInstallScan = Default::default();
141
142 for pkg in packages {
143 if let Some(diagnosis) = check_package(pkg) {
144 scan.push(diagnosis, Some(Path::new(&pkg.install_dir)));
145 }
146 }
147
148 sort_diagnoses(&mut scan.diagnostics);
149 sort_recovery_findings(&mut scan.recovery_findings);
150
151 scan
152}
153
154pub(crate) fn installed_packages(
155 conn: &crate::database::DbConnection,
156) -> Result<Vec<InstalledPackage>> {
157 database::list_packages(conn)
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use crate::models::domains::install::InstallerType;
164 use crate::models::domains::installed::PackageStatus;
165 use crate::models::domains::reporting::{RecoveryActionGroup, RecoveryIssueKind};
166 use std::path::Path;
167 use tempfile::tempdir;
168
169 fn sample_package(name: &str, install_dir: &std::path::Path) -> InstalledPackage {
170 InstalledPackage {
171 name: name.to_string(),
172 version: "1.0.0".to_string(),
173 kind: InstallerType::Portable,
174 deployment_kind: InstallerType::Portable.deployment_kind(),
175 engine_kind: InstallerType::Portable.into(),
176 engine_metadata: None,
177 install_dir: install_dir.to_string_lossy().into_owned(),
178 dependencies: Vec::new(),
179 status: PackageStatus::Ok,
180 installed_at: "2026-04-05T00:00:00Z".to_string(),
181 }
182 }
183
184 #[test]
185 fn check_package_detects_missing_directory() {
186 let temp_dir = tempdir().expect("temp dir should be created");
187 let missing_dir = temp_dir.path().join("missing");
188 let package = sample_package("Contoso.Missing", &missing_dir);
189
190 let diagnosis = check_package(&package).expect("missing dir should diagnose");
191
192 assert_eq!(diagnosis.error_code, "missing_install_directory");
193 assert_eq!(diagnosis.severity, DiagnosisSeverity::Error);
194 assert!(diagnosis.description.contains("Contoso.Missing"));
195 }
196
197 #[test]
198 fn check_package_detects_non_directory_path() {
199 let temp_dir = tempdir().expect("temp dir should be created");
200 let file_path = temp_dir.path().join("not-a-dir.txt");
201 std::fs::write(&file_path, b"binary").expect("file should be created");
202 let package = sample_package("Contoso.File", &file_path);
203
204 let diagnosis = check_package(&package).expect("file path should diagnose");
205
206 assert_eq!(diagnosis.error_code, "install_directory_not_a_directory");
207 assert_eq!(diagnosis.severity, DiagnosisSeverity::Error);
208 assert!(diagnosis.description.contains("Contoso.File"));
209 }
210
211 #[test]
212 fn check_package_rejects_empty_install_path() {
213 let package = sample_package("Contoso.Empty", Path::new(""));
214
215 let diagnosis = check_package(&package).expect("empty path should diagnose");
216
217 assert_eq!(diagnosis.error_code, "empty_install_path");
218 assert_eq!(diagnosis.severity, DiagnosisSeverity::Error);
219 }
220
221 #[test]
222 fn diagnose_install_dir_error_maps_permission_denied() {
223 let package = sample_package("Contoso.Denied", Path::new("C:/deny"));
224 let error = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
225
226 let diagnosis = diagnose_install_dir_error(&package, error);
227
228 assert_eq!(diagnosis.error_code, "install_directory_permission_denied");
229 assert_eq!(diagnosis.severity, DiagnosisSeverity::Error);
230 assert!(diagnosis.description.contains("Contoso.Denied"));
231 }
232
233 #[test]
234 fn scan_packages_attaches_reinstall_targets() {
235 let temp_dir = tempdir().expect("temp dir should be created");
236 let missing_dir = temp_dir.path().join("missing");
237 let package = sample_package("Contoso.Missing", &missing_dir);
238 let missing_dir_string = missing_dir.to_string_lossy().to_string();
239
240 let scan = scan_packages(&[package]);
241
242 assert_eq!(scan.diagnostics.len(), 1);
243 assert_eq!(scan.recovery_findings.len(), 1);
244 assert_eq!(
245 scan.recovery_findings[0].action_group,
246 Some(RecoveryActionGroup::Reinstall)
247 );
248 assert_eq!(
249 scan.recovery_findings[0].issue_kind,
250 RecoveryIssueKind::DiskDrift
251 );
252 assert_eq!(
253 scan.recovery_findings[0].target_path.as_deref(),
254 Some(missing_dir_string.as_str())
255 );
256 }
257
258 #[test]
259 fn check_package_treats_native_exe_registry_presence_as_installed() {
260 let temp_dir = tempdir().expect("temp dir should be created");
261 let missing_dir = temp_dir.path().join("nativeexe");
262 let package = InstalledPackage {
263 name: "Contoso.NativeExe".to_string(),
264 version: "1.0.0".to_string(),
265 kind: InstallerType::Exe,
266 deployment_kind: InstallerType::Exe.deployment_kind(),
267 engine_kind: EngineKind::NativeExe,
268 engine_metadata: None,
269 install_dir: missing_dir.to_string_lossy().into_owned(),
270 dependencies: Vec::new(),
271 status: PackageStatus::Ok,
272 installed_at: "2026-04-05T00:00:00Z".to_string(),
273 };
274
275 let diagnosis = check_package_with_registry(&package, |_| true);
276
277 assert!(diagnosis.is_none());
278 }
279
280 #[test]
281 fn scan_packages_sorts_diagnoses_by_error_code() {
282 let temp_dir = tempdir().expect("temp dir should be created");
283 let missing_dir = temp_dir.path().join("Missing.Dir");
284 let file_path = temp_dir.path().join("not-a-dir.txt");
285 std::fs::write(&file_path, b"binary").expect("file should be created");
286
287 let packages = vec![
288 sample_package("Contoso.Missing", &missing_dir),
289 sample_package("Contoso.File", &file_path),
290 sample_package("Contoso.Empty", Path::new("")),
291 ];
292
293 let scan = scan_packages(&packages);
294
295 assert_eq!(scan.diagnostics.len(), 3);
296 assert_eq!(scan.diagnostics[0].error_code, "empty_install_path");
297 assert_eq!(
298 scan.diagnostics[1].error_code,
299 "install_directory_not_a_directory"
300 );
301 assert_eq!(scan.diagnostics[2].error_code, "missing_install_directory");
302 assert_eq!(scan.recovery_findings.len(), 2);
303 assert_eq!(
304 scan.recovery_findings[0].action_group,
305 Some(RecoveryActionGroup::Reinstall)
306 );
307 assert_eq!(
308 scan.recovery_findings[1].action_group,
309 Some(RecoveryActionGroup::Reinstall)
310 );
311 assert_eq!(
312 scan.recovery_findings[0].error_code,
313 "install_directory_not_a_directory"
314 );
315 assert_eq!(
316 scan.recovery_findings[1].error_code,
317 "missing_install_directory"
318 );
319 }
320}