From 1488638b56e4f054d84f5f7403973dd26efe6d0b Mon Sep 17 00:00:00 2001 From: asdf80730 Date: Tue, 27 Oct 2026 21:52:43 +0800 Subject: [PATCH] fix(launcher): warn in the log when started elevated (#973) An elevated PIMELauncher makes the Python backend create %APPDATA%\\PIME owned by BUILTIN\\Administrators, which the normal sign-in session cannot read; input methods then silently swallow keys with no error anywhere. The folder-creation and NULL-context fixes from the report landed in #988; this completes the remaining suggestion: the watchdog now logs a warning naming the cause and the two icacls repair commands right after logging is set up, before any backend can create those folders. The worker child inherits the same token and stays silent, so the log carries one warning. Architecture and design by asdf80730. Implementation generated with AI assistance from omp (Qwen3.8-Flash-Next). --- PIMELauncher/src/elevation.rs | 127 ++++++++++++++++++++++++++++++++++ PIMELauncher/src/lib.rs | 1 + PIMELauncher/src/main.rs | 9 +++ 3 files changed, 137 insertions(+) create mode 100644 PIMELauncher/src/elevation.rs 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");