winbrew_app\operations\doctor\scan/
package.rs

1use 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
15/// Validate the install path string stored on a package record.
16///
17/// The doctor scan treats empty paths and paths containing a null byte as
18/// immediate configuration errors because they cannot represent a valid
19/// filesystem location on Windows.
20fn 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
43/// Translate a filesystem metadata failure into a user-facing diagnosis.
44///
45/// The error code depends on the error kind so the final report can distinguish
46/// missing directories, permission problems, and generic unreadable paths.
47fn 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
79/// Check a single installed package for install-directory problems.
80///
81/// The scan is intentionally metadata-only: it validates the stored path string,
82/// checks that the directory exists, and confirms that the path is actually a
83/// directory. Anything else is turned into a diagnosis instead of a hard error.
84pub(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}