//! The Windows Installer context of an installed copy, from Windows Installer's own registration, //! never inferred from a display registry entry. An existing copy must never fall back to a fresh //! installation when discovery fails. //! //! Shared by the launcher's installs and the apps' self-updates, so both keep a copy in the same //! context by the same rule. #![cfg_attr(not(windows), allow(dead_code))] /// The Windows Installer context a copy is installed in. A per-machine copy elevates and writes /// HKLM registrations; a per-user one must not silently become a second, per-machine copy (or the /// other way round). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MsiScope { PerUser, PerMachine, } /// The `msiexec` properties that ask a dual-scope package for `scope`. `ALLUSERS=1` installs for /// everyone (Windows asks for permission); `ALLUSERS=2` with `MSIINSTALLPERUSER=1` is the documented /// single-package way to ask for a per-user install. pub fn scope_arguments(scope: MsiScope) -> &'static [&'static str] { match scope { MsiScope::PerUser => &["ALLUSERS=2", "MSIINSTALLPERUSER=1"], MsiScope::PerMachine => &["ALLUSERS=1"], } } const UNKNOWN_CONTEXT: &str = "Windows could not confirm who this app is installed for. No changes were made. Try the app's installer instead."; const CONFLICTING_CONTEXT: &str = "This app is installed for both your account and everyone. Use its installer to choose which copy to update."; /// Why the Windows Installer lookup gave no context. #[derive(Debug, PartialEq, Eq)] pub enum ProbeError { /// The lookup ran but gave no usable answer. The context is never guessed from that. Unknown, /// The same product is registered for this account and for everyone. Conflicting, /// The lookup could not run (a registration key it may not read, no account identity). Carries /// the real error so it can be logged. Unavailable(String), } /// The context an update must keep. Windows Installer's own registration decides. Only when that /// lookup cannot run at all does the context come from the uninstall record the copy was found by /// (`record_scope`): the machine's records are per-machine and the account's own are per-user. A /// lookup that ran and gave no answer, or a copy registered twice, still stops the update. pub fn update_scope( is_msi: bool, product_code: Option<&str>, probe: impl FnOnce(&str) -> Result, record_scope: impl FnOnce(&str) -> Option, ) -> Result, String> { if !is_msi { return Ok(None); } let code = product_code.filter(|code| valid_product_code(code)).ok_or(UNKNOWN_CONTEXT)?; match probe(code) { Ok(scope) => Ok(Some(scope)), Err(ProbeError::Conflicting) => Err(CONFLICTING_CONTEXT.into()), Err(ProbeError::Unknown) => Err(UNKNOWN_CONTEXT.into()), Err(ProbeError::Unavailable(reason)) => { log::warn!("Windows Installer context lookup for {code} could not run: {reason}"); let scope = record_scope(code).ok_or(UNKNOWN_CONTEXT)?; log::info!("{code}: keeping the {scope:?} context of its uninstall record"); Ok(Some(scope)) } } } /// The one context every uninstall record of a product agrees on. Records that disagree (the copy is /// registered for this account and for everyone) or no records at all give `None`, so the update stops. pub fn single_scope(scopes: impl IntoIterator) -> Option { let mut scopes = scopes.into_iter(); let first = scopes.next()?; scopes.all(|scope| scope == first).then_some(first) } /// The context to install in. An update keeps `updated`, the context of the copy it replaces. A fresh /// install (no copy at all) is machine-wide, so every app lands in `C:\Program Files\ArtCraft\` /// (or the folder the person chose) and not in one account's profile; a dual-scope package would /// otherwise take its own default, which is per-user. Windows asks for permission once, for that /// install. Anything else (a copy that is not an MSI) takes the package's own default. pub fn install_scope(updated: Option, fresh: bool) -> Option { updated.or_else(|| fresh.then_some(MsiScope::PerMachine)) } /// Whether `code` is a braced Windows Installer product GUID, the only text ever used to name a /// registration key. pub fn valid_product_code(code: &str) -> bool { code.len() == 38 && code.bytes().enumerate().all(|(index, byte)| match index { 0 => byte == b'{', 37 => byte == b'}', 9 | 14 | 19 | 24 => byte == b'-', _ => byte.is_ascii_hexdigit(), }) } /// The one context the product is registered in: 1 and 2 (managed and unmanaged, this account) are /// per-user, 4 is the machine. None, any other value, or both kinds is never guessed from. fn scope_of(contexts: &[u32]) -> Result { let mut scope = None; for context in contexts { let context = match context { 1 | 2 => MsiScope::PerUser, 4 => MsiScope::PerMachine, _ => return Err(ProbeError::Unknown), }; if scope.is_some_and(|previous| previous != context) { return Err(ProbeError::Conflicting); } scope = Some(context); } scope.ok_or(ProbeError::Unknown) } /// Windows Installer's packed form of a validated product code, the name of its registration keys: /// the first three groups reversed, then each remaining byte's two digits swapped. fn packed_code(code: &str) -> Option { if !valid_product_code(code) { return None; } let hex: Vec = code.chars().filter(char::is_ascii_hexdigit).map(|c| c.to_ascii_uppercase()).collect(); let (groups, rest) = hex.split_at_checked(16)?; let mut packed = String::with_capacity(32); for range in [0..8, 8..12, 12..16] { packed.extend(groups.get(range)?.iter().rev()); } for pair in rest.chunks(2) { packed.extend(pair.iter().rev()); } Some(packed) } /// Asks Windows Installer, read-only, which context `product_code` is installed in, for this /// account and the machine, as `ProductsEx` does, from Windows Installer's own keys: no other /// account, no other program started, and never `Win32_Product` (which repairs). #[cfg(windows)] pub fn probe(product_code: &str) -> Result { use winreg::RegKey; use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY}; let packed = packed_code(product_code).ok_or(ProbeError::Unknown)?; let sid = current_user_sid().map_err(ProbeError::Unavailable)?; let keys = [ (1, HKEY_LOCAL_MACHINE, format!(r"SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\Managed\{sid}\Installer\Products\{packed}")), (2, HKEY_CURRENT_USER, format!(r"Software\Microsoft\Installer\Products\{packed}")), (4, HKEY_LOCAL_MACHINE, format!(r"SOFTWARE\Classes\Installer\Products\{packed}")), ]; let mut contexts = Vec::new(); for (context, hive, path) in keys { match RegKey::predef(hive).open_subkey_with_flags(&path, KEY_READ | KEY_WOW64_64KEY) { Ok(_) => contexts.push(context), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} // A key we may not read is a lookup that didn't run, not a product that isn't there. Err(e) => return Err(ProbeError::Unavailable(format!("couldn't read the Windows Installer registration ({e})"))), } } scope_of(&contexts) } /// This account's SID, which names its managed (policy-installed) registrations. #[cfg(windows)] fn current_user_sid() -> Result { use winsafe::{HACCESSTOKEN, TokenInfo, co}; let failed = |e: winsafe::co::ERROR| format!("couldn't identify this account ({e})"); match HACCESSTOKEN::GetCurrentProcessToken().GetTokenInformation(co::TOKEN_INFORMATION_CLASS::User).map_err(failed)? { TokenInfo::User(user) => user.User.Sid().map(|sid| sid.to_string()).ok_or_else(|| "couldn't identify this account".into()), _ => Err("couldn't identify this account".into()), } } #[cfg(test)] mod tests { use super::*; const CODE: &str = "{12345678-1234-1234-1234-123456789ABC}"; #[test] fn authoritative_machine_context_is_preserved() { assert_eq!(scope_of(&[4]), Ok(MsiScope::PerMachine)); } #[test] fn managed_and_unmanaged_accounts_are_both_per_user() { assert_eq!(scope_of(&[1]), Ok(MsiScope::PerUser)); assert_eq!(scope_of(&[2]), Ok(MsiScope::PerUser)); assert_eq!(scope_of(&[1, 2]), Ok(MsiScope::PerUser)); } #[test] fn missing_unknown_or_conflicting_contexts_are_not_guessed() { for contexts in [&[][..], &[0], &[7], &[2, 4], &[4, 1], &[4, 3]] { assert!(scope_of(contexts).is_err(), "{contexts:?}"); } assert_eq!(scope_of(&[4, 1]), Err(ProbeError::Conflicting)); assert_eq!(scope_of(&[7]), Err(ProbeError::Unknown)); } #[test] fn a_product_code_packs_the_way_windows_installer_names_its_keys() { assert_eq!(packed_code("{12345678-ABCD-EF01-2345-6789ABCDEF01}").as_deref(), Some("87654321DCBA10FE32547698BADCFE10")); assert_eq!(packed_code("{12345678-abcd-ef01-2345-6789abcdef01}").as_deref(), Some("87654321DCBA10FE32547698BADCFE10")); for code in ["", "12345678-ABCD-EF01-2345-6789ABCDEF01", "{12345678-ABCD-EF01-2345-6789ABCDEF0Z}", r"{1234567\-ABCD-EF01-2345-6789ABCDEF01}"] { assert_eq!(packed_code(code), None, "{code:?}"); } } /// A product nobody installed is registered nowhere: the lookup runs and finds no context. #[cfg(windows)] #[test] fn a_product_that_is_not_installed_has_no_context() { assert_eq!(probe("{00000000-1111-2222-3333-444444444444}"), Err(ProbeError::Unknown)); } #[cfg(windows)] #[test] fn this_accounts_sid_is_found() { // Local and domain accounts are S-1-5-..., Microsoft Entra ID accounts S-1-12-1-...: only the // shape is the same, `S-1-` and then numbers. let sid = current_user_sid().unwrap(); let parts: Vec<&str> = sid.split('-').collect(); let numbers = parts.get(1..).unwrap_or_default(); assert!(parts.first() == Some(&"S") && numbers.len() >= 3, "{sid}"); assert!(numbers.iter().all(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())), "{sid}"); assert_eq!(numbers.first(), Some(&"1"), "{sid}"); } #[test] fn a_fresh_install_is_not_looked_up() { assert_eq!(update_scope(false, None, |_| Err(ProbeError::Unknown), |_| None), Ok(None)); } #[test] fn known_updates_keep_the_authoritative_context() { for scope in [MsiScope::PerUser, MsiScope::PerMachine] { assert_eq!(update_scope(true, Some(CODE), |_| Ok(scope), |_| None), Ok(Some(scope))); } } #[test] fn an_update_without_its_product_code_stops_before_probing() { let called = std::cell::Cell::new(false); assert!( update_scope( true, None, |_| { called.set(true); Ok(MsiScope::PerMachine) }, |_| None ) .is_err() ); assert!(!called.get()); } #[test] fn malformed_product_codes_stop_before_any_lookup() { for code in ["", "not-a-guid", "{12345678-1234-1234-1234-123456789ABZ}", "';exit 0;#", "{12345678-1234-1234-1234-123456789ABC}\n"] { let called = std::cell::Cell::new(false); assert!( update_scope( true, Some(code), |_| { called.set(true); Ok(MsiScope::PerMachine) }, |_| None ) .is_err() ); assert!(!called.get(), "{code:?}"); } } #[test] fn unavailable_context_never_becomes_a_fresh_install() { let unavailable = || Err(ProbeError::Unavailable("query failed".into())); assert!(update_scope(true, Some(CODE), |_| unavailable(), |_| None).is_err()); } #[test] fn a_lookup_that_cannot_run_falls_back_to_the_uninstall_record() { for scope in [MsiScope::PerUser, MsiScope::PerMachine] { let seen = std::cell::RefCell::new(String::new()); let result = update_scope( true, Some(CODE), |_| Err(ProbeError::Unavailable("the registry could not be read".into())), |code| { *seen.borrow_mut() = code.to_owned(); Some(scope) }, ); assert_eq!(result, Ok(Some(scope))); assert_eq!(*seen.borrow(), CODE); } } #[test] fn an_answer_that_is_unclear_or_doubled_never_uses_the_uninstall_record() { for failure in [ProbeError::Unknown, ProbeError::Conflicting] { let result = update_scope(true, Some(CODE), |_| Err(failure), |_| panic!("must not consult the record")); assert!(result.is_err()); } } #[test] fn a_blocked_lookup_without_a_record_stops_the_update() { let result = update_scope(true, Some(CODE), |_| Err(ProbeError::Unavailable("timed out".into())), |_| None); assert_eq!(result, Err(UNKNOWN_CONTEXT.to_string())); } #[test] fn records_in_two_contexts_give_no_fallback_scope() { assert_eq!(single_scope([]), None); assert_eq!(single_scope([MsiScope::PerUser]), Some(MsiScope::PerUser)); assert_eq!(single_scope([MsiScope::PerMachine, MsiScope::PerMachine]), Some(MsiScope::PerMachine)); assert_eq!(single_scope([MsiScope::PerMachine, MsiScope::PerUser]), None); assert_eq!(single_scope([MsiScope::PerUser, MsiScope::PerMachine, MsiScope::PerMachine]), None); } #[test] fn each_context_asks_the_package_for_itself() { assert_eq!(scope_arguments(MsiScope::PerMachine), ["ALLUSERS=1"]); assert_eq!(scope_arguments(MsiScope::PerUser), ["ALLUSERS=2", "MSIINSTALLPERUSER=1"]); } #[test] fn a_fresh_install_is_machine_wide() { assert_eq!(install_scope(None, true), Some(MsiScope::PerMachine)); } #[test] fn an_update_keeps_the_context_of_the_copy_it_replaces() { for scope in [MsiScope::PerUser, MsiScope::PerMachine] { assert_eq!(install_scope(Some(scope), false), Some(scope)); } } #[test] fn a_copy_that_is_not_an_msi_takes_the_package_default() { assert_eq!(install_scope(None, false), None); } }