winbrew_engines\windows/
msi.rs

1use anyhow::{Context, Result, bail};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use tracing::{debug, warn};
6
7use crate::models::install::engine::{
8    EngineInstallReceipt, EngineKind, EngineMetadata, InstallScope,
9};
10use crate::models::install::installed::InstalledPackage;
11use crate::models::msi_inventory::records::MsiInventorySnapshot;
12use crate::windows_dep::installed::read_uninstall_registry_value;
13use crate::windows_dep::packages::msi_scan_inventory;
14
15const MSI_INSTALL_EXIT_CODES: &[i32] = &[0, 1641, 3010];
16const INSTALL_LOCATION_VALUE: &str = "InstallLocation";
17
18/// Install an MSI package through Windows Installer and capture MSI metadata.
19///
20/// The helper scans the downloaded MSI database first so the engine metadata
21/// can preserve the product code, upgrade code, and the stable registry/
22/// shortcut references stored in the package database. The actual installation
23/// then runs through `msiexec` in silent mode.
24pub(crate) fn install(
25    download_path: &Path,
26    install_dir: &Path,
27    package_name: &str,
28) -> Result<EngineInstallReceipt> {
29    let snapshot = msi_scan_inventory(
30        download_path,
31        install_dir,
32        package_name,
33        InstallScope::Installed,
34    )
35    .with_context(|| format!("failed to scan MSI inventory for {}", package_name))?;
36
37    fs::create_dir_all(install_dir)
38        .with_context(|| format!("failed to create {}", install_dir.display()))?;
39
40    let status = Command::new("msiexec")
41        .arg("/i")
42        .arg(download_path)
43        .arg(format!(r"TARGETDIR={}", install_dir.display()))
44        .arg(format!(r"INSTALLDIR={}", install_dir.display()))
45        .arg("/qn")
46        .arg("/norestart")
47        .status()
48        .context("failed to launch msiexec for MSI install")?;
49
50    let exit_code = status
51        .code()
52        .ok_or_else(|| anyhow::anyhow!("msiexec terminated without an exit code"))?;
53
54    if !MSI_INSTALL_EXIT_CODES.contains(&exit_code) {
55        bail!(
56            "msiexec failed for {} with exit code {}",
57            package_name,
58            exit_code
59        );
60    }
61
62    let install_dir = resolve_install_dir(&snapshot, install_dir, package_name);
63
64    let snapshot_receipt = snapshot.receipt.clone();
65    let registry_keys = collect_registry_keys(&snapshot);
66    let shortcuts = collect_shortcuts(&snapshot);
67
68    let engine_metadata = Some(EngineMetadata::Msi {
69        product_code: snapshot_receipt.product_code,
70        upgrade_code: snapshot_receipt.upgrade_code,
71        scope: snapshot_receipt.scope,
72        registry_keys,
73        shortcuts,
74    });
75
76    let mut receipt = EngineInstallReceipt::new(
77        EngineKind::Msi,
78        install_dir.to_string_lossy().into_owned(),
79        engine_metadata,
80    );
81    receipt.msi_inventory_snapshot = Some(snapshot);
82
83    Ok(receipt)
84}
85
86/// Remove an installed MSI package using the product code stored in metadata.
87pub(crate) fn remove(package: &InstalledPackage) -> Result<()> {
88    let product_code = match package.engine_metadata.as_ref() {
89        Some(EngineMetadata::Msi { product_code, .. }) => product_code.as_str(),
90        _ => bail!("missing MSI receipt metadata for '{}'", package.name),
91    };
92
93    let status = Command::new("msiexec")
94        .arg("/x")
95        .arg(product_code)
96        .arg("/qn")
97        .arg("/norestart")
98        .status()
99        .context("failed to launch msiexec for MSI removal")?;
100
101    let exit_code = status
102        .code()
103        .ok_or_else(|| anyhow::anyhow!("msiexec terminated without an exit code"))?;
104
105    if !MSI_INSTALL_EXIT_CODES.contains(&exit_code) {
106        bail!(
107            "msiexec removal failed for {} with exit code {}",
108            package.name,
109            exit_code
110        );
111    }
112
113    Ok(())
114}
115
116fn collect_registry_keys(snapshot: &MsiInventorySnapshot) -> Vec<String> {
117    let mut registry_keys = snapshot
118        .registry_entries
119        .iter()
120        .map(|entry| format!(r"{}\{}", entry.hive, entry.key_path))
121        .collect::<Vec<_>>();
122
123    registry_keys.sort_unstable();
124    registry_keys.dedup();
125
126    registry_keys
127}
128
129fn collect_shortcuts(snapshot: &MsiInventorySnapshot) -> Vec<String> {
130    let mut shortcuts = snapshot
131        .shortcuts
132        .iter()
133        .map(|shortcut| shortcut.path.clone())
134        .collect::<Vec<_>>();
135
136    shortcuts.sort_unstable();
137    shortcuts.dedup();
138
139    shortcuts
140}
141
142fn resolve_install_dir(
143    snapshot: &MsiInventorySnapshot,
144    requested_install_dir: &Path,
145    package_name: &str,
146) -> PathBuf {
147    match read_uninstall_registry_value(&snapshot.receipt.product_code, INSTALL_LOCATION_VALUE) {
148        Some(install_location) => {
149            let actual_install_dir = PathBuf::from(&install_location);
150
151            if !same_install_dir(&actual_install_dir, requested_install_dir) {
152                warn!(
153                    package = package_name,
154                    product_code = %snapshot.receipt.product_code,
155                    requested_install_dir = %requested_install_dir.display(),
156                    registry_install_location = %install_location,
157                    "MSI InstallLocation differs from the path used to build the scan snapshot"
158                );
159
160                debug!(
161                    package = package_name,
162                    product_code = %snapshot.receipt.product_code,
163                    file_count = snapshot.files.len(),
164                    registry_entry_count = snapshot.registry_entries.len(),
165                    shortcut_count = snapshot.shortcuts.len(),
166                    component_count = snapshot.components.len(),
167                    "MSI inventory details for InstallLocation mismatch"
168                );
169            }
170
171            actual_install_dir
172        }
173        None => {
174            warn!(
175                package = package_name,
176                product_code = %snapshot.receipt.product_code,
177                requested_install_dir = %requested_install_dir.display(),
178                file_count = snapshot.files.len(),
179                "MSI InstallLocation was not published; using the requested install directory"
180            );
181
182            debug!(
183                package = package_name,
184                product_code = %snapshot.receipt.product_code,
185                registry_entry_count = snapshot.registry_entries.len(),
186                shortcut_count = snapshot.shortcuts.len(),
187                component_count = snapshot.components.len(),
188                "MSI inventory details for missing InstallLocation"
189            );
190
191            requested_install_dir.to_path_buf()
192        }
193    }
194}
195
196fn same_install_dir(left: &Path, right: &Path) -> bool {
197    match (fs::canonicalize(left), fs::canonicalize(right)) {
198        (Ok(left), Ok(right)) => left == right,
199        _ => normalize_install_dir_text(left) == normalize_install_dir_text(right),
200    }
201}
202
203fn normalize_install_dir_text(path: &Path) -> String {
204    path.to_string_lossy()
205        .replace('/', "\\")
206        .trim_end_matches('\\')
207        .to_ascii_lowercase()
208}