diff --git a/PIMELauncher/src/elevation.rs b/PIMELauncher/src/elevation.rs new file mode 100644 index 00000000..649776da --- /dev/null +++ b/PIMELauncher/src/elevation.rs @@ -0,0 +1,127 @@ +/// Whether the launcher should surface the elevation warning for this process. +/// +/// Only the interactive watchdog instance warns: the worker child inherits the +/// same token, and a second copy of the warning would be pure noise. A normal +/// (non-elevated) run must stay silent. +pub fn should_warn(elevated: bool, is_worker: bool) -> bool { + elevated && !is_worker +} + +/// The user-facing explanation shown when the launcher runs elevated. +/// +/// The two `icacls` lines are the repair from issue #973: an elevated first +/// run leaves `%APPDATA%\PIME` owned by `BUILTIN\Administrators`, which a +/// normal session cannot read. +pub fn elevation_advice() -> &'static str { + "PIMELauncher is running as Administrator.\n\ + \n\ + PIME stores per-user settings and language profiles under %APPDATA%\\PIME. \ + Folders created by this elevated run end up owned by Administrators and \ + become unreadable to your normal session, which silently breaks the input \ + methods (keys stop composing, with no error anywhere).\n\ + \n\ + Quit this instance and restart PIMELauncher as the normal user \u{2014} sign \ + out and in again, or launch it from a non-elevated terminal.\n\ + \n\ + If %APPDATA%\\PIME was already created by an elevated run, repair it from a \ + normal (non-administrator) command prompt:\n\ + \n\ + \x20 icacls \"%APPDATA%\\PIME\" /setowner \"%USERDOMAIN%\\%USERNAME%\" /T\n\ + \x20 icacls \"%APPDATA%\\PIME\" /reset /T" +} + +/// Whether this process runs with an elevated (Administrator) token. +/// +/// Reads `TokenElevation` from the process token: under UAC a member of the +/// Administrators group gets a filtered token whose `TokenIsElevated` is 0, +/// so this reports the real privilege level rather than group membership. +pub fn is_process_elevated() -> Result { + use std::ffi::c_void; + use std::mem::size_of; + use windows::Win32::Foundation::{CloseHandle, HANDLE}; + use windows::Win32::Security::{ + GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY, + }; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + unsafe { + let mut token = HANDLE::default(); + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) + .map_err(|e| std::io::Error::from_raw_os_error(e.code().0))?; + + let mut elevation = TOKEN_ELEVATION::default(); + let mut returned_length = 0u32; + let query = GetTokenInformation( + token, + TokenElevation, + Some(&mut elevation as *mut TOKEN_ELEVATION as *mut c_void), + size_of::() as u32, + &mut returned_length, + ); + // Close the token before propagating a query failure. + let _ = CloseHandle(token); + query.map_err(|e| std::io::Error::from_raw_os_error(e.code().0))?; + + Ok(elevation.TokenIsElevated != 0) + } +} + +/// Logs the elevation warning once, from the watchdog process. +/// +/// The warning goes to the `warn` log stream, which reaches the launcher's +/// `/console` window and its `/log-file` sink; a debug log captured while +/// chasing a "keys do nothing" report (issue #973) then carries the cause. +pub fn warn_if_elevated(is_worker: bool) { + // A failed query must never block the launcher; assume not elevated. + let elevated = is_process_elevated().unwrap_or(false); + if !should_warn(elevated, is_worker) { + return; + } + let advice = elevation_advice(); + tracing::warn!("{advice}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_an_elevated_watchdog_warns() { + assert!(should_warn(true, false), "elevated watchdog must warn"); + assert!( + !should_warn(true, true), + "worker child must not duplicate the warning" + ); + assert!( + !should_warn(false, false), + "normal sign-in run must stay silent" + ); + assert!(!should_warn(false, true)); + } + + #[test] + fn advice_names_the_repair_from_the_issue() { + let text = elevation_advice(); + let lowered = text.to_lowercase(); + assert!( + text.contains("%APPDATA%\\PIME"), + "must name the poisoned folder" + ); + assert!(lowered.contains("administrator"), "must name the cause"); + assert!(lowered.contains("icacls"), "must give the repair commands"); + assert!(lowered.contains("/setowner"), "must give the ownership fix"); + assert!(lowered.contains("/reset"), "must give the ACL reset"); + assert!( + lowered.contains("restart"), + "must tell the user to relaunch unelevated" + ); + } + + #[test] + fn elevation_query_answers_for_current_process() { + // The TokenElevation query must succeed on the test process itself; + // the answer depends on how the harness was started, so only the + // Ok-ness is asserted. + let _elevated = is_process_elevated().expect("TokenElevation query must succeed"); + } +} diff --git a/PIMELauncher/src/lib.rs b/PIMELauncher/src/lib.rs index d3f5b0f0..44bf5834 100644 --- a/PIMELauncher/src/lib.rs +++ b/PIMELauncher/src/lib.rs @@ -8,6 +8,7 @@ pub mod acl; pub mod backend_manager; pub mod backend_registry; pub mod client_session; +pub mod elevation; pub mod pipe_server; pub mod protocol; pub mod testing; diff --git a/PIMELauncher/src/main.rs b/PIMELauncher/src/main.rs index d544c521..887bab40 100644 --- a/PIMELauncher/src/main.rs +++ b/PIMELauncher/src/main.rs @@ -2,6 +2,7 @@ use pimelauncher::backend_manager::BackendManager; use pimelauncher::backend_registry::BackendRegistry; +use pimelauncher::elevation; use pimelauncher::pipe_server; use std::path::PathBuf; use tracing::{error, info, warn}; @@ -241,6 +242,14 @@ async fn main() { setup_logging(&args); + // Issue #973: an elevated run poisons %APPDATA%\PIME (owned by + // Administrators, unreadable to the normal session). The backend that + // creates those folders only starts below, so this is the last point + // where the user can still avoid the damage. The worker child inherits + // the token; warn_if_elevated stays silent there so the log carries one + // warning, not two. + elevation::warn_if_elevated(args.is_worker); + // Ensure single instance (using a named mutex) // The Watchdog process (parent) holds this mutex. let mutex_name = get_per_session_name("PIMELauncher2_WatchdogMutex");