master
John Lauer rust: show family, progress registry (verb_times, operations), kicad_upgrade with silent install; alpha.3, 94 verbs, 18… 46c748c 2mo ago
//! Silent KiCad install primitives (phase 3b), ported from handlers/upgrade.py:
//! `_elevation_facts` (token elevation query plus the two UAC registry values) and the
//! `proc.run([installer, scope, "/S"], timeout=900)` call with a kill on the deadline.
//!
//! The NsisMultiUser installer KiCad 10 ships REQUIRES a scope flag next to `/S`: bare
//! `/S` is "invalid command-line parameters" and exits instantly with rc=666660 (its
//! sibling 666661 is "elevation restricted"). `/allusers` needs an elevated token;
//! `/currentuser` installs to %LOCALAPPDATA%\Programs\KiCad with no admin and no UAC.

use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

use serde_json::{json, Value};
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::Security::{GetTokenInformation, TokenElevation, TokenElevationType, TOKEN_ELEVATION, TOKEN_QUERY};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
use windows::Win32::UI::Shell::IsUserAnAdmin;

/// The official downloads.kicad.org x86_64 NSIS installer (handlers/upgrade.py `_DL_URL`).
pub const DL_URL: &str = "https://downloads.kicad.org/kicad/windows/explore/stable/download/kicad-{v}-x86_64.exe";

pub fn download_url(version: &str) -> String {
    DL_URL.replace("{v}", version)
}

/// TokenElevationType (18) and TokenElevation (20) of this process's token, as
/// (elevation_type, is_elevated). Err carries the failing call.
fn token_facts() -> Result<(u32, u32), String> {
    let mut token = HANDLE::default();
    // SAFETY: GetCurrentProcess is a pseudo handle; `token` outlives the call and is
    // closed below.
    unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.map_err(|e| format!("OpenProcessToken failed: {e}"))?;
    let query = |class| -> Result<u32, String> {
        let mut val: u32 = 0;
        let mut len: u32 = 0;
        // SAFETY: both TOKEN_ELEVATION_TYPE and TOKEN_ELEVATION are a single DWORD;
        // `val` and `len` outlive the call.
        unsafe { GetTokenInformation(token, class, Some(&mut val as *mut u32 as *mut _), std::mem::size_of::<u32>() as u32, &mut len) }
            .map(|()| val)
            .map_err(|e| format!("GetTokenInformation failed: {e}"))
    };
    let kind = query(TokenElevationType);
    let elevated = query(TokenElevation);
    // SAFETY: the handle came from OpenProcessToken above and is not used afterwards.
    let _ = unsafe { CloseHandle(token) };
    debug_assert_eq!(std::mem::size_of::<TOKEN_ELEVATION>(), std::mem::size_of::<u32>());
    Ok((kind?, elevated?))
}

fn uac_registry() -> (Value, Value) {
    use winreg::enums::HKEY_LOCAL_MACHINE;
    use winreg::RegKey;
    let root = RegKey::predef(HKEY_LOCAL_MACHINE);
    match root.open_subkey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System") {
        Ok(k) => {
            let lua: Value = match k.get_value::<u32, _>("EnableLUA") {
                Ok(v) => json!(v),
                Err(e) => json!(format!("error: {e}")),
            };
            // Absent means the Windows default (5: prompt for consent on the secure desktop).
            let consent: Value = match k.get_value::<u32, _>("ConsentPromptBehaviorAdmin") {
                Ok(v) => json!(v),
                Err(_) => json!("not set (default 5: prompt)"),
            };
            (lua, consent)
        }
        Err(e) => (json!(format!("error: {e}")), Value::Null),
    }
}

/// handlers/upgrade.py `_elevation_facts`, plus the four summary keys the trait promises.
pub fn elevation_info() -> Result<Value, String> {
    let mut facts = json!({
        "platform": "win32",
        "user": std::env::var("USERNAME").unwrap_or_default(),
    });
    // SAFETY: shell32 query with no arguments.
    let shell_admin = unsafe { IsUserAnAdmin() }.as_bool();
    facts["isUserAnAdmin"] = json!(shell_admin);
    let mut token_elevated: Option<bool> = None;
    match token_facts() {
        Ok((kind, elevated)) => {
            facts["tokenElevationType"] = json!(kind);
            facts["tokenElevated"] = json!(elevated);
            facts["tokenElevationTypeMeaning"] = json!(match kind {
                1 => "Default: no split token (UAC disabled machine-wide, or built-in Administrator): full admin, NO UAC prompt ever",
                2 => "Full: elevated via an approved UAC prompt (or elevated parent)",
                3 => "Limited: standard filtered token, NOT admin",
                _ => "unknown",
            });
            token_elevated = Some(elevated != 0);
        }
        Err(e) => facts["tokenError"] = json!(e),
    }
    let (lua, consent) = uac_registry();
    facts["uacEnableLUA"] = lua.clone();
    facts["uacConsentPromptBehaviorAdmin"] = consent.clone();

    // Summary. isAdmin is the token's own word when we could read it, else shell32's.
    let is_admin = token_elevated.unwrap_or(shell_admin);
    let uac_enabled: Value = match lua.as_u64() {
        Some(v) => json!(v != 0),
        None => Value::Null,
    };
    facts["isAdmin"] = json!(is_admin);
    facts["uacEnabled"] = uac_enabled;
    facts["consentPrompt"] = match consent.as_u64() {
        Some(v) => json!(v),
        None => Value::Null,
    };
    // /allusers /S runs to completion without a prompt only under an elevated token;
    // a limited token gets NsisMultiUser's 666661 (elevation restricted) in silent mode.
    facts["canInstallAllUsersSilently"] = json!(is_admin);
    Ok(facts)
}

/// `<installer> /allusers|/currentuser /S`, hidden, no console, blocked until it exits or
/// `timeout_secs` passes (then killed). Returns the exit code.
pub fn run_installer(path: &Path, all_users: bool, timeout_secs: u64) -> Result<i32, String> {
    if !path.is_file() {
        return Err(format!("{} does not exist", path.display()));
    }
    let scope = if all_users { "/allusers" } else { "/currentuser" };
    let mut cmd = Command::new(path);
    cmd.arg(scope).arg("/S");
    cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
    {
        use std::os::windows::process::CommandExt;
        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
        cmd.creation_flags(CREATE_NO_WINDOW);
    }
    let mut child = cmd.spawn().map_err(|e| format!("installer launch failed: {e}"))?;
    let deadline = Instant::now() + Duration::from_secs(timeout_secs);
    loop {
        match child.try_wait() {
            Ok(Some(status)) => return Ok(status.code().unwrap_or(-1)),
            Ok(None) => {}
            Err(e) => return Err(format!("waiting on the installer failed: {e}")),
        }
        if Instant::now() >= deadline {
            let _ = child.kill();
            let _ = child.wait();
            return Err(format!("installer did not finish within {timeout_secs} s; killed. A silent NSIS run that stalls this long is usually waiting on a UAC prompt nobody can see, or SmartScreen/AV holding the exe"));
        }
        std::thread::sleep(Duration::from_millis(250));
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn url_matches_python() {
        assert_eq!(download_url("10.0.6"), "https://downloads.kicad.org/kicad/windows/explore/stable/download/kicad-10.0.6-x86_64.exe");
    }

    #[test]
    fn elevation_facts_are_concrete() {
        let f = elevation_info().unwrap();
        assert!(f["isAdmin"].is_boolean());
        assert!(f["canInstallAllUsersSilently"].is_boolean());
        assert!(f["isUserAnAdmin"].is_boolean());
        assert!(f.get("uacEnableLUA").is_some());
        assert!(f.get("uacConsentPromptBehaviorAdmin").is_some());
        // The token is ours to read; a failure here is a real regression.
        assert!(f["tokenElevationType"].is_u64(), "{f}");
        assert!(f["tokenElevated"].is_u64(), "{f}");
    }

    #[test]
    fn missing_installer_is_refused() {
        assert!(run_installer(Path::new(r"C:\nope\kicad-0.0.0-x86_64.exe"), false, 5).is_err());
    }
}