From 6e4597b1d95f20ae63ce8896235abfb0ded212dc Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:27:32 +0000 Subject: [PATCH 1/5] Windows: report installer failures and prepare Agent 0.0.15 --- apps/maple-agent/Cargo.lock | 16 +- apps/maple-agent/Cargo.toml | 2 +- apps/maple-agent/app/Cargo.toml | 2 +- apps/maple-agent/app/src/main.rs | 54 ++++- apps/maple-agent/app/src/windows_bootstrap.rs | 83 +++++++- .../crates/maple-agent-updater/Cargo.toml | 1 + .../maple-agent-updater/src/installer.rs | 32 +++ .../src/installer/windows_bundle.rs | 22 +-- .../installer/windows_bundle/authenticode.rs | 111 ++++++++--- apps/maple-agent/docs/desktop-builds.md | 7 + .../scripts/test_windows_release.py | 178 ++++++++++++++++- .../maple-agent/scripts/windows-installer.nsi | 67 ++++++- apps/maple-agent/scripts/windows-release.py | 185 ++++++++++++++++++ 13 files changed, 688 insertions(+), 72 deletions(-) diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index e47c1f9a6..3471b4f14 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "maple-agent" -version = "0.0.14" +version = "0.0.15" dependencies = [ "agent-client-protocol", "anyhow", @@ -5818,7 +5818,7 @@ dependencies = [ [[package]] name = "maple-agent-app" -version = "0.0.14" +version = "0.0.15" dependencies = [ "ashpd", "axum", @@ -5880,7 +5880,7 @@ dependencies = [ [[package]] name = "maple-agent-updater" -version = "0.0.14" +version = "0.0.15" dependencies = [ "anyhow", "base64 0.22.1", @@ -5910,7 +5910,7 @@ dependencies = [ [[package]] name = "maple-billing" -version = "0.0.14" +version = "0.0.15" dependencies = [ "reqwest 0.13.4", "serde", @@ -7688,7 +7688,7 @@ dependencies = [ [[package]] name = "pi-agent-core" -version = "0.0.14" +version = "0.0.15" dependencies = [ "async-trait", "futures-util", @@ -7701,7 +7701,7 @@ dependencies = [ [[package]] name = "pi-ai" -version = "0.0.14" +version = "0.0.15" dependencies = [ "async-trait", "bytes", @@ -7718,7 +7718,7 @@ dependencies = [ [[package]] name = "pi-coding-agent" -version = "0.0.14" +version = "0.0.15" dependencies = [ "async-trait", "base64 0.22.1", @@ -10111,7 +10111,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "store-bench" -version = "0.0.14" +version = "0.0.15" dependencies = [ "anyhow", "maple-agent", diff --git a/apps/maple-agent/Cargo.toml b/apps/maple-agent/Cargo.toml index dcc12975c..87713db38 100644 --- a/apps/maple-agent/Cargo.toml +++ b/apps/maple-agent/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/maple-agent", "crates/maple-billing", "crates/maple-agent-upd [workspace.package] edition = "2024" -version = "0.0.14" +version = "0.0.15" license = "MIT" [workspace.dependencies] diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index dab8a6820..54aff092b 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -110,7 +110,7 @@ objc = { version = "=0.2.7", optional = true } raw-window-handle = { version = "0.6", optional = true } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Memory", "Win32_System_Threading", "Win32_System_ErrorReporting", "Win32_System_SystemInformation", "Win32_Storage_FileSystem"] } +windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Memory", "Win32_System_Threading", "Win32_System_ErrorReporting", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_UI_WindowsAndMessaging"] } [dev-dependencies] tempfile = "3" diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index 19f05f407..a1dc8ff6e 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -83,14 +83,18 @@ struct Cli { internal_theme_watch: bool, /// Credential-free native installer admission before any installed write. #[cfg(all(feature = "desktop", target_os = "windows"))] - #[arg(long, hide = true, value_name = "INSTALL_ROOT", conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_installer_activate", "internal_windows_uninstall_cleanup", "internal_windows_restart"])] + #[arg(long, hide = true, group = "windows_installer_action", value_name = "INSTALL_ROOT", conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_installer_activate", "internal_windows_uninstall_cleanup", "internal_windows_restart"])] internal_windows_installer_preflight: Option, #[cfg(all(feature = "desktop", target_os = "windows"))] - #[arg(long, hide = true, num_args = 2, value_names = ["INSTALL_ROOT", "GENERATION"], conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_installer_preflight", "internal_windows_installer_activate", "internal_windows_uninstall_cleanup", "internal_windows_restart"])] + #[arg(long, hide = true, group = "windows_installer_action", num_args = 2, value_names = ["INSTALL_ROOT", "GENERATION"], conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_installer_preflight", "internal_windows_installer_activate", "internal_windows_uninstall_cleanup", "internal_windows_restart"])] internal_windows_installer_prepare: Vec, #[cfg(all(feature = "desktop", target_os = "windows"))] - #[arg(long, hide = true, value_name = "GENERATION", conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_uninstall_cleanup", "internal_windows_restart"])] + #[arg(long, hide = true, group = "windows_installer_action", value_name = "GENERATION", conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_uninstall_cleanup", "internal_windows_restart"])] internal_windows_installer_activate: Option, + /// Interactive NSIS only; silent verification and updates never show a dialog. + #[cfg(all(feature = "desktop", target_os = "windows"))] + #[arg(long, hide = true, requires = "windows_installer_action")] + internal_windows_installer_show_errors: bool, #[cfg(all(feature = "desktop", target_os = "windows"))] #[arg(long, hide = true, conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_restart"])] internal_windows_uninstall_cleanup: bool, @@ -286,7 +290,9 @@ fn main() { // credentials or a host. Their paths and code identities are checked // by the updater's native installer before mutation or delegation. let mut internal_exit_code = 1; + let mut installer_phase = None; let internal = if let Some(root) = &cli.internal_windows_installer_preflight { + installer_phase = Some(windows_bootstrap::InstallerPhase::Preflight); Some( std::env::current_exe() .map_err(|_| "Could not locate the Windows installer checker".to_owned()) @@ -300,6 +306,7 @@ fn main() { }), ) } else if !cli.internal_windows_installer_prepare.is_empty() { + installer_phase = Some(windows_bootstrap::InstallerPhase::Preparation); Some( std::env::current_exe() .map_err(|_| "Could not locate the Windows installer checker".to_owned()) @@ -319,6 +326,7 @@ fn main() { }), ) } else if let Some(generation) = &cli.internal_windows_installer_activate { + installer_phase = Some(windows_bootstrap::InstallerPhase::Activation); Some( std::env::current_exe() .map_err(|_| "Could not locate the Windows bootstrap".to_owned()) @@ -355,6 +363,14 @@ fn main() { if let Some(result) = internal { if let Err(error) = result { eprintln!("{error}"); + if cli.internal_windows_installer_show_errors + && let Some(phase) = installer_phase + && windows_bootstrap::show_installer_error(profile::DISPLAY_NAME, phase, &error) + { + // This is still a failure. NSIS uses the acknowledgement + // only to avoid displaying a second generic error dialog. + std::process::exit(windows_bootstrap::INSTALLER_ERROR_SHOWN); + } std::process::exit(internal_exit_code); } return; @@ -888,6 +904,38 @@ mod tests { assert!(parse(&["--build-info", "some/file.txt"]).is_err()); } + #[cfg(all(feature = "desktop", target_os = "windows"))] + #[test] + fn installer_error_dialog_requires_an_explicit_installer_action() { + const SHOW: &str = "--internal-windows-installer-show-errors"; + for action in [ + vec!["--internal-windows-installer-preflight", "C:\\Maple"], + vec![ + "--internal-windows-installer-prepare", + "C:\\Maple", + "0.0.14-780", + ], + vec!["--internal-windows-installer-activate", "0.0.14-780"], + ] { + let args: Vec<_> = std::iter::once("maple-agent") + .chain(action.iter().copied()) + .collect(); + assert!( + !Cli::try_parse_from(&args) + .unwrap() + .internal_windows_installer_show_errors + ); + assert!( + Cli::try_parse_from(args.into_iter().chain([SHOW])) + .unwrap() + .internal_windows_installer_show_errors + ); + } + for args in [vec![SHOW], vec!["--build-info", SHOW], vec![SHOW, "acp"]] { + assert!(parse(&args).is_err()); + } + } + #[test] fn login_takes_an_optional_email_and_never_a_password() { assert_eq!( diff --git a/apps/maple-agent/app/src/windows_bootstrap.rs b/apps/maple-agent/app/src/windows_bootstrap.rs index a6b593748..5962e1515 100644 --- a/apps/maple-agent/app/src/windows_bootstrap.rs +++ b/apps/maple-agent/app/src/windows_bootstrap.rs @@ -1,4 +1,4 @@ -//! Credential-free Windows bootstrap and restart. Never evaluates a shell script. +//! Credential-free Windows bootstrap, restart and installer error UI. //! The installer owns destination/profile/code validation; this module only //! delegates to its selected generation or waits for an exact retiring process. @@ -15,8 +15,63 @@ use windows_sys::Win32::{ CREATE_NO_WINDOW, GetCurrentProcess, GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject, }, + UI::WindowsAndMessaging::{ + IDOK, MB_ICONERROR, MB_OK, MB_SETFOREGROUND, MB_TASKMODAL, MessageBoxW, + }, }; +/// UI acknowledgement only, never a successful installation or activation. +pub(crate) const INSTALLER_ERROR_SHOWN: i32 = 0x4DFF_0001; + +#[derive(Clone, Copy)] +pub(crate) enum InstallerPhase { + Preflight, + Preparation, + Activation, +} + +fn installer_error_message(phase: InstallerPhase, error: &str) -> String { + let phase = match phase { + InstallerPhase::Preflight => "preflight", + InstallerPhase::Preparation => "preparation", + InstallerPhase::Activation => "activation", + }; + // Native failures contain fixed validation labels and OS errors, never + // account/store state. Bound even unexpected text before handing it to UI. + let detail: String = error + .chars() + .filter(|character| !character.is_control()) + .take(2048) + .collect(); + format!( + "Installation stopped during {phase}.\r\n\r\n{detail}\r\n\r\nSend a screenshot of this message to Maple support." + ) +} + +/// Called only for an explicitly interactive installer failure, before any +/// application, account or store initialization. A failed dialog leaves NSIS's +/// own fallback error visible instead of falsely acknowledging presentation. +pub(crate) fn show_installer_error(display_name: &str, phase: InstallerPhase, error: &str) -> bool { + let text: Vec = installer_error_message(phase, error) + .encode_utf16() + .chain(Some(0)) + .collect(); + let title: Vec = format!("{display_name} Setup") + .encode_utf16() + .chain(Some(0)) + .collect(); + // SAFETY: both NUL-terminated UTF-16 buffers remain live until the modal + // call returns. No borrowed parent-window handle or mutable data is used. + unsafe { + MessageBoxW( + std::ptr::null_mut(), + text.as_ptr(), + title.as_ptr(), + MB_OK | MB_ICONERROR | MB_SETFOREGROUND | MB_TASKMODAL, + ) == IDOK + } +} + struct ProcessHandle(HANDLE); impl Drop for ProcessHandle { fn drop(&mut self) { @@ -190,6 +245,32 @@ fn forward_verified_target( mod tests { use super::*; + #[test] + fn installer_error_text_keeps_native_failure_and_distinct_stage() { + for (phase, label) in [ + (InstallerPhase::Preflight, "preflight"), + (InstallerPhase::Preparation, "preparation"), + (InstallerPhase::Activation, "activation"), + ] { + let message = installer_error_message( + phase, + "Windows could not verify Maple's code signature (WinTrust error 0x800B0109)", + ); + assert!(message.starts_with(&format!("Installation stopped during {label}."))); + assert!(message.contains("WinTrust error 0x800B0109")); + assert!(message.contains("Maple support")); + } + } + + #[test] + fn installer_error_text_bounds_and_sanitizes_unexpected_controls() { + let error = format!("native\0error\n{}", "x".repeat(4096)); + let message = installer_error_message(InstallerPhase::Preflight, &error); + assert!(message.contains("nativeerror")); + assert!(!message.contains('\0')); + assert!(message.len() < 2300); + } + #[test] fn windows_restart_identity_is_canonical_and_bounded() { assert_eq!(parse_restart_identity("42:12345"), Ok((42, 12345))); diff --git a/apps/maple-agent/crates/maple-agent-updater/Cargo.toml b/apps/maple-agent/crates/maple-agent-updater/Cargo.toml index a65af2c1b..8a87ec3cf 100644 --- a/apps/maple-agent/crates/maple-agent-updater/Cargo.toml +++ b/apps/maple-agent/crates/maple-agent-updater/Cargo.toml @@ -36,3 +36,4 @@ ed25519-dalek = "2" [target.'cfg(windows)'.dev-dependencies] windows = { version = "=0.61.3", features = ["Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "=0.61.2", features = ["Win32_UI_WindowsAndMessaging"] } diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs index 3388058f9..9d5f6323e 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs @@ -105,6 +105,8 @@ pub enum InstallError { Invalid(&'static str), #[error("The update does not have the required Maple code signature and identity")] CodeSignature, + #[error("Windows could not verify Maple's code signature (WinTrust error 0x{status:08X})")] + WindowsCodeSignature { status: u32 }, #[error("The update could not be installed: {0}")] Io(#[from] io::Error), #[error("Windows activation failed during {stage}: {source}")] @@ -150,6 +152,36 @@ type Result = std::result::Result; mod windows_activation_error_tests { use super::*; + #[test] + fn windows_signature_status_survives_activation_context_without_changing_error_policy() { + for status in [0x800B_0109, 0x8009_2013, 0x800B_0100, u32::MAX] { + let source = InstallError::WindowsCodeSignature { status }; + assert_eq!( + source.to_string(), + format!( + "Windows could not verify Maple's code signature (WinTrust error 0x{status:08X})" + ) + ); + assert!(!source.requires_manual_installation()); + let error = InstallError::WindowsActivation { + stage: WindowsActivationStage::BootstrapIdentity, + source: Box::new(source), + }; + assert!(error.to_string().contains("bootstrap-identity")); + assert!(error.to_string().contains(&format!("0x{status:08X}"))); + // The existing bounded activation status is not repurposed to + // truncate or disguise a full WinTrust DWORD. + assert_eq!(error.windows_activation_exit_code(), Some(0x4D04_0000)); + assert!(!error.requires_manual_installation()); + let InstallError::WindowsActivation { source, .. } = error else { + unreachable!() + }; + assert!( + matches!(*source, InstallError::WindowsCodeSignature { status: actual } if actual == status) + ); + } + } + #[test] fn activation_status_preserves_fixed_stage_and_original_native_error() { for (stage, number, label) in [ diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs index b04d28eb6..eb003c95c 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs @@ -452,15 +452,15 @@ fn verify_identity_metadata(directory: &Path, bundle: &Bundle) -> Result Result<()> { let executable = verify_identity_metadata(directory, bundle)?; #[cfg(windows)] - if !match purpose { + match purpose { IdentityPurpose::Admission => { - authenticode::verify_or_unsigned(&executable, bundle.profile == "dev") + authenticode::verify_or_unsigned_detailed(&executable, bundle.profile == "dev")?; } IdentityPurpose::Startup => { - authenticode::verify_startup_or_unsigned(&executable, bundle.profile == "dev") + if !authenticode::verify_startup_or_unsigned(&executable, bundle.profile == "dev") { + return Err(InstallError::CodeSignature); + } } - } { - return Err(InstallError::CodeSignature); } #[cfg(not(windows))] let _ = (purpose, executable); @@ -1168,10 +1168,10 @@ fn trusted_installer(executable: &Path, profile: &str) -> Result Result< .ok_or(InstallError::Invalid("Windows uninstall parent"))?; windows_fs::validate_path(root)?; registered_root(root, profile)?; - if pe_build_info(&executable)?["profile"] != profile - || !authenticode::verify_or_unsigned(&executable, profile == "dev") - { + if pe_build_info(&executable)?["profile"] != profile { return Err(InstallError::CodeSignature); } + authenticode::verify_or_unsigned_detailed(&executable, profile == "dev")?; let lock = open_install_lock(root)?; lock.try_lock_exclusive().map_err(|_| InstallError::Busy)?; let owner = installer_owner()?; diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs index 0afe15c5d..e4e449b55 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs @@ -40,6 +40,8 @@ use windows_sys::Win32::{ }, }; +use super::super::{InstallError, Result}; + const EXPECTED_PUBLISHER: &str = "Mutiny Wallet Inc. dba OpenSecret"; const MAX_NAME_UNITS: u32 = 512; @@ -300,7 +302,7 @@ fn pe_has_no_certificate(reader: &mut (impl Read + Seek)) -> bool { /// Windows may retrieve certificate-chain/revocation material under its trust /// policy; an unavailable or untrusted chain fails closed. pub(super) fn verify(path: &Path) -> bool { - verify_for_purpose(path, false, Purpose::Admission) + verify_or_unsigned(path, false) } /// Only the caller's embedded Dev profile may opt into unsigned local builds. @@ -308,7 +310,7 @@ pub(super) fn verify(path: &Path) -> bool { /// including when unsigned Dev files are permitted. Production updates call /// `verify`, and never enter this exception. pub(super) fn verify_or_unsigned(path: &Path, allow_unsigned: bool) -> bool { - verify_for_purpose(path, allow_unsigned, Purpose::Admission) + verify_or_unsigned_detailed(path, allow_unsigned).is_ok() } /// Reauthenticate an already admitted installed executable at normal startup. @@ -316,33 +318,34 @@ pub(super) fn verify_or_unsigned(path: &Path, allow_unsigned: bool) -> bool { /// retrieval; it still requires Windows trust, exact publisher and timestamp. /// Installation and update mutation must continue to use the admission APIs. pub(super) fn verify_startup_or_unsigned(path: &Path, allow_unsigned: bool) -> bool { - verify_for_purpose(path, allow_unsigned, Purpose::Startup) + verify_for_purpose(path, allow_unsigned, Purpose::Startup).is_ok() +} + +/// Preserve the native trust failure for installer diagnostics. The boolean +/// admission APIs use the same result and accept only `Ok(())`. +pub(super) fn verify_or_unsigned_detailed(path: &Path, allow_unsigned: bool) -> Result<()> { + verify_for_purpose(path, allow_unsigned, Purpose::Admission) } -fn verify_for_purpose(path: &Path, allow_unsigned: bool, purpose: Purpose) -> bool { +fn verify_for_purpose(path: &Path, allow_unsigned: bool, purpose: Purpose) -> Result<()> { if !path.is_absolute() { - return false; + return Err(InstallError::CodeSignature); } let mut wide: Vec = path.as_os_str().encode_wide().collect(); if wide.is_empty() || wide.len() > 32_766 || wide.contains(&0) { - return false; + return Err(InstallError::CodeSignature); } wide.push(0); // Deny concurrent writes/deletion while the same handle is authenticated. // The caller separately validates every staging ancestor and archive hash. - let Ok(file) = OpenOptions::new() + let file = OpenOptions::new() .read(true) .share_mode(FILE_SHARE_READ) .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) - .open(path) - else { - return false; - }; - let Ok(metadata) = file.metadata() else { - return false; - }; + .open(path)?; + let metadata = file.metadata()?; if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { - return false; + return Err(InstallError::CodeSignature); } let mut info = WINTRUST_FILE_INFO { cbStruct: size_of::() as u32, @@ -364,19 +367,21 @@ fn verify_for_purpose(path: &Path, allow_unsigned: bool, purpose: Purpose) -> bo (status, GetLastError()) }; if allow_unsigned && status == TRUST_E_NOSIGNATURE && reason == TRUST_E_NOSIGNATURE as u32 { - return pe_has_no_certificate(&mut &file) - && no_catalog_membership(catalog_memberships(&file)); - } - // WinVerifyTrust returns LONG, not HRESULT. Only exactly zero is success. - if status != 0 || state.0.hWVTStateData.is_null() { - return false; + return if pe_has_no_certificate(&mut &file) + && no_catalog_membership(catalog_memberships(&file)) + { + Ok(()) + } else { + Err(InstallError::CodeSignature) + }; } + verified_trust_state(status, state.0.hWVTStateData)?; // SAFETY: provider pointers are owned by the successful state and remain // valid until its guard closes it. No provider-owned memory is modified. unsafe { let provider = WTHelperProvDataFromStateData(state.0.hWVTStateData); if provider.is_null() || (*provider).dwError != 0 { - return false; + return Err(InstallError::CodeSignature); } let signer = WTHelperGetProvSignerFromChain(provider, 0, 0, 0); if signer.is_null() @@ -384,11 +389,11 @@ fn verify_for_purpose(path: &Path, allow_unsigned: bool, purpose: Purpose) -> bo || (*signer).csCertChain == 0 || (*signer).csCounterSigners == 0 { - return false; + return Err(InstallError::CodeSignature); } let certificate = WTHelperGetProvCertFromChain(signer, 0); if certificate.is_null() || (*certificate).dwError != 0 || (*certificate).pCert.is_null() { - return false; + return Err(InstallError::CodeSignature); } let count = CertGetNameStringW( (*certificate).pCert, @@ -399,7 +404,7 @@ fn verify_for_purpose(path: &Path, allow_unsigned: bool, purpose: Purpose) -> bo 0, ); if !(2..=MAX_NAME_UNITS).contains(&count) { - return false; + return Err(InstallError::CodeSignature); } let mut name = vec![0u16; count as usize]; if CertGetNameStringW( @@ -412,13 +417,31 @@ fn verify_for_purpose(path: &Path, allow_unsigned: bool, purpose: Purpose) -> bo ) != count || !publisher_matches(&name) { - return false; + return Err(InstallError::CodeSignature); } let timestamp = WTHelperGetProvSignerFromChain(provider, 0, 1, 0); - valid_timestamp(timestamp) + if valid_timestamp(timestamp) { + Ok(()) + } else { + Err(InstallError::CodeSignature) + } } } +// WinVerifyTrust returns LONG, not HRESULT. Retain all returned bits for +// diagnostics; neither positive status values nor a missing state mean success. +fn verified_trust_state(status: i32, state_data: *mut c_void) -> Result<()> { + if status != 0 { + return Err(InstallError::WindowsCodeSignature { + status: status as u32, + }); + } + if state_data.is_null() { + return Err(InstallError::CodeSignature); + } + Ok(()) +} + unsafe fn valid_timestamp(timestamp: *const CRYPT_PROVIDER_SGNR) -> bool { // SAFETY: this pointer, when non-null, comes from the live verified provider // state. Bounds come from that provider, not from serialized app metadata. @@ -439,6 +462,40 @@ unsafe fn valid_timestamp(timestamp: *const CRYPT_PROVIDER_SGNR) -> bool { mod tests { use super::*; + #[test] + fn native_trust_status_preserves_every_failure_bit_and_requires_verified_state() { + let state = std::ptr::dangling_mut::(); + assert!(verified_trust_state(0, state).is_ok()); + assert!(matches!( + verified_trust_state(0, std::ptr::null_mut()), + Err(InstallError::CodeSignature) + )); + for status in [1, -1, 0x800B_0109_u32 as i32, 0x8009_2013_u32 as i32] { + for state in [state, std::ptr::null_mut()] { + let result = verified_trust_state(status, state); + assert!(matches!( + result, + Err(InstallError::WindowsCodeSignature { status: actual }) if actual == status as u32 + )); + } + } + } + + #[test] + fn native_detailed_admission_preserves_file_error_and_boolean_apis_refuse_it() { + let temporary = tempfile::tempdir().unwrap(); + let missing = temporary.path().join("missing-signed-application.exe"); + for allow_unsigned in [false, true] { + let error = verify_or_unsigned_detailed(&missing, allow_unsigned).unwrap_err(); + assert!( + matches!(error, InstallError::Io(error) if error.kind() == std::io::ErrorKind::NotFound) + ); + assert!(!verify_or_unsigned(&missing, allow_unsigned)); + } + assert!(!verify(&missing)); + assert!(!verify_startup_or_unsigned(&missing, false)); + } + #[test] fn catalog_presence_and_lookup_errors_never_enable_unsigned_dev() { assert!(no_catalog_membership([CatalogLookup::Absent; 2])); diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index 32c416a81..33befe5d0 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -112,6 +112,13 @@ Closing a successfully completed interactive installer opens Maple. Silent the profile's Maple icon. Normal GUI startup also refreshes the icon for this profile's existing Start menu shortcuts and installed-app entry, including upgrades from 0.0.12, while preserving the stable launcher and shortcut targets. +An interactive installation refusal shows the native failing check and its +underlying Windows error, including the full WinTrust status for certificate +verification failures. If the checker cannot start, the installer reports its +own phase and any child exit code instead. Include that message when reporting +a failed install. Silent installs retain nonzero failure statuses without +opening the native error dialog. Existing signature and storage checks still +apply; administrator mode is not a bypass. macOS packages contain a DMG and application archive; drag the chosen Agent app into Applications. Both Agent profiles can coexist with Maple Research. Linux packages contain diff --git a/apps/maple-agent/scripts/test_windows_release.py b/apps/maple-agent/scripts/test_windows_release.py index 5f2a5c71c..995e21b97 100644 --- a/apps/maple-agent/scripts/test_windows_release.py +++ b/apps/maple-agent/scripts/test_windows_release.py @@ -550,22 +550,30 @@ def test_nsis_helper_launch_errors_cannot_reuse_a_previous_success_exit_code(sel ) with self.subTest(command=command): # NSIS leaves the output variable undefined on launch error. - # Test its error flag before ever reading a stale zero in $0. + # Test its error flag before ever reading a stale child status. self.assertEqual(instructions[index - 1], 'ClearErrors') self.assertEqual(instructions[index + 1], 'IfErrors ' + destination) child = {'refused': (22, 24), 'failed': (41,), 'refuse': (52,)}[destination] - self.assertIn(instructions[index + 2], ['StrCpy $FailureCode ' + str(code) for code in child]) - self.assertEqual(instructions[index + 3], 'StrCmp $0 0 0 ' + destination) + following = instructions[index + 2:] + compare = next(index for index, line in enumerate(following) if line.startswith('StrCmp ')) + metadata = following[:compare] + self.assertTrue(all(line.startswith('StrCpy ') for line in metadata)) + self.assertEqual([line for line in metadata if line.startswith('StrCpy $FailureCode ')], + [next(line for line in metadata if line in ['StrCpy $FailureCode ' + str(code) for code in child])]) + output = command.rsplit(' ', 1)[1] + self.assertEqual(following[compare], 'StrCmp ' + output + ' 0 0 ' + destination) def test_nsis_exit_codes_identify_probe_phase_and_launch_vs_child_failures(self): - source = (SCRIPTS / "windows-installer.nsi").read_text() + source = "\n".join(line for line in (SCRIPTS / "windows-installer.nsi").read_text().splitlines() + if not line.lstrip().startswith(';')) codes = {int(value) for value in re.findall(r'StrCpy \$FailureCode ([0-9]+)', source)} self.assertEqual(codes, set(release.NSIS_FAILURE_PHASES)) for flag, launch, child in (("installer-preflight", 21, 22), ("installer-prepare", 23, 24), ("installer-activate", 40, 41), ("uninstall-cleanup", 51, 52)): with self.subTest(flag=flag): - pattern = (r'StrCpy \$FailureCode ' + str(launch) + r'\s+ClearErrors\s+ExecWait [^\n]*--internal-windows-' + flag - + r'[^\n]*\n\s+IfErrors \w+\s+StrCpy \$FailureCode ' + str(child)) + metadata = r'(?:\s+StrCpy (?!\$FailureCode)[^\n]+)*' + pattern = (r'StrCpy \$FailureCode ' + str(launch) + metadata + r'\s+ClearErrors\s+ExecWait [^\n]*--internal-windows-' + flag + + r'[^\n]*\n\s+IfErrors \w+' + metadata + r'\s+StrCpy \$FailureCode ' + str(child)) self.assertRegex(source, pattern) self.assertRegex(source, r'ClearErrors\s+GetDLLVersion [^\n]+\n\s+IfErrors unsupported') self.assertIn('SetErrorLevel 0', source) @@ -806,6 +814,152 @@ def test_owned_gui_exit_race_is_accepted_only_when_the_same_handle_has_exited(se ui.kernel.CloseHandle.assert_called_once_with(987) +class InteractiveRefusalSmokeTests(unittest.TestCase): + def execute(self, *, message=None, ack=None, installer_code=22, duplicate=False, mutation=None, no_dialog=False): + with tempfile.TemporaryDirectory() as temporary: + scratch = Path(temporary) + for name in ("config", "data", "cache", "tmp"): + (scratch / name).mkdir() + checker = scratch / "verified-checker.exe" + checker.write_bytes(b"verified checker fixture") + process = Mock(pid=123, returncode=None) + process.poll.side_effect = lambda: process.returncode + process.wait.side_effect = lambda timeout: process.returncode if process.returncode is not None else (_ for _ in ()).throw(subprocess.TimeoutExpired("owned installer", timeout)) + process.terminate.side_effect = lambda: setattr(process, "returncode", -1) + child = Mock(pid=456, handle=987) + child.exit_code.return_value = release.INSTALLER_ERROR_SHOWN if ack is None else ack + ui = Mock() + ui.kernel.WaitForSingleObject.return_value = 0 + ui.installer_child.return_value = child + state = {"installed": False, "dismissed": False} + body = message or ("Installation stopped during preflight.\nThe prepared update is invalid: " + "unknown files in Windows installation; choose an empty directory") + def button(pid, caption): + if caption == "Install" and not state["installed"] or caption == "Close" and state["dismissed"]: + return pid, caption + return None + def diagnostic(pid): + if pid == child.pid and not state["dismissed"] and not no_dialog: + return (pid, "OK"), body + if pid == process.pid and state["dismissed"] and duplicate: + return (pid, "OK"), "Installation stopped while checking the installation." + return None + def click(selected): + if selected == (process.pid, "Install"): + state["installed"] = True + elif selected == (child.pid, "OK"): + state["dismissed"] = True + if mutation: + mutation(scratch) + elif selected == (process.pid, "Close"): + process.returncode = installer_code + ui.button.side_effect, ui.diagnostic.side_effect, ui.click.side_effect = button, diagnostic, click + clock = InteractiveInstallerSmokeTests.Clock() + def missing(*_arguments): + raise FileNotFoundError + registry = types.SimpleNamespace(KEY_READ=1, KEY_WOW64_32KEY=2, HKEY_CURRENT_USER=3, OpenKey=missing) + self.process, self.child, self.ui = process, child, ui + with patch.dict(sys.modules, winreg=registry), patch.object(release.subprocess, "Popen", return_value=process) as popen, patch.object(release.time, "monotonic", clock.monotonic), patch.object(release.time, "sleep", clock.sleep): + release.interactive_installer_refusal_smoke(Path("C:/verified setup.exe"), checker, scratch, {"FIXTURE": "sanitized"}, "fixture.bundle", ui=ui) + return popen.call_args, scratch, ui + + def test_real_path_driver_requires_actual_error_then_failed_installer_without_mutation(self): + call, _scratch, ui = self.execute() + self.assertIn('"C:/verified setup.exe" /D=', call.args[0]) + self.assertNotIn("/S", call.args[0]) + self.assertEqual(ui.click.call_args_list, [unittest.mock.call((123, "Install")), unittest.mock.call((456, "OK")), unittest.mock.call((123, "Close"))]) + ui.installer_child.assert_called_once() + self.assertEqual(ui.installer_child.call_args.args[0], 123) + self.assertEqual(ui.installer_child.call_args.args[2:], (len(b"verified checker fixture"), hashlib.sha256(b"verified checker fixture").hexdigest())) + self.child.close.assert_called_once() + self.process.terminate.assert_not_called() + + def test_wrong_native_stage_or_missing_actual_guard_reason_cannot_pass(self): + for message in ("Installation stopped during preparation. unknown files in Windows installation; choose an empty directory", + "Installation stopped during preflight. some unrelated error"): + with self.subTest(message=message), self.assertRaisesRegex(ValueError, "stage and actual refusal reason"): + self.execute(message=message) + self.process.terminate.assert_called_once() + self.child.close.assert_called_once() + + def test_shown_message_does_not_bless_an_acknowledgement_or_success_status(self): + with self.assertRaisesRegex(ValueError, "acknowledge actual native failure"): + self.execute(ack=1) + with self.assertRaisesRegex(ValueError, "changed the NSIS preflight refusal"): + self.execute(installer_code=0) + + def test_duplicate_generic_dialog_is_a_failure(self): + with self.assertRaisesRegex(ValueError, "duplicate generic error"): + self.execute(duplicate=True) + self.process.terminate.assert_called_once() + self.child.close.assert_called_once() + + def test_missing_dialog_times_out_and_cleans_only_bound_processes(self): + with self.assertRaisesRegex(ValueError, "bounded native diagnostic"): + self.execute(no_dialog=True) + self.process.terminate.assert_called_once() + self.child.close.assert_called_once() + + def test_failed_installation_must_preserve_unknown_file_and_profile_state(self): + for mutation, expected in ( + (lambda scratch: (scratch / "refused install/user-created-file.txt").write_bytes(b"changed"), "existing destination"), + (lambda scratch: (scratch / "data/unexpected.txt").write_bytes(b"changed"), "profile state"), + ): + with self.subTest(expected=expected), self.assertRaisesRegex(ValueError, expected): + self.execute(mutation=mutation) + + +class InstallerChildOwnershipTests(unittest.TestCase): + def driver(self, image): + ui = object.__new__(release.WindowsSmokeUI) + ui.ctypes, ui.types = ctypes, types.SimpleNamespace(DWORD=ctypes.c_uint32) + ui.child_pids = Mock(return_value=[456]) + def query(_handle, _flags, buffer, _size): + buffer.value = str(image) + return 1 + ui.kernel = types.SimpleNamespace(OpenProcess=Mock(return_value=987), + QueryFullProcessImageNameW=Mock(side_effect=query), CloseHandle=Mock()) + return ui + + def test_checker_selection_requires_exact_direct_child_path_size_and_bytes(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + private = root / "owned tmp" + private.mkdir() + image = private / "maple-installer-check.exe" + image.write_bytes(b"verified bytes") + digest = hashlib.sha256(image.read_bytes()).hexdigest() + for size, expected, kind in ((image.stat().st_size, digest, "valid"), + (1, digest, "size"), (image.stat().st_size, "0" * 64, "bytes")): + with self.subTest(kind=kind): + ui = self.driver(image) + owned = Mock() + with patch.object(release, "WindowsSmokeApplication", return_value=owned) as wrapper: + if kind == "valid": + self.assertIs(ui.installer_child(123, private, size, expected), owned) + wrapper.assert_called_once_with(ui, 987, 456) + ui.kernel.CloseHandle.assert_not_called() + else: + with self.assertRaisesRegex(ValueError, "size differs|differs from verified bytes"): + ui.installer_child(123, private, size, expected) + wrapper.assert_not_called() + ui.kernel.CloseHandle.assert_called_once_with(987) + ui.child_pids.assert_called_once_with(123) + ui = self.driver(image) + (root / "elsewhere").mkdir() + with self.assertRaisesRegex(ValueError, "escaped the isolated"): + ui.installer_child(123, root / "elsewhere", image.stat().st_size, digest) + ui.kernel.CloseHandle.assert_called_once_with(987) + + def test_other_executables_are_released_without_becoming_cleanup_targets(self): + ui = self.driver(Path("C:/other-process.exe")) + with patch.object(release, "regular") as regular, patch.object(release, "WindowsSmokeApplication") as wrapper: + self.assertIsNone(ui.installer_child(123, Path("C:/owned"), 1, "0" * 64)) + regular.assert_not_called() + wrapper.assert_not_called() + ui.kernel.CloseHandle.assert_called_once_with(987) + + class NativeShortcutProbeTests(unittest.TestCase): def native(self, metadata): native = object.__new__(release.WindowsShortcutCOM) @@ -1254,6 +1408,11 @@ def test_actual_repeat_and_identity_assertions_are_the_only_pinned_smoke_operati class SmokeDiagnosticsTests(unittest.TestCase): + def setUp(self): + # These cases isolate preservation of an existing primary failure. The + # real NSIS/native interactive refusal is covered by its own smoke. + self.refusal = self.enterContext(patch.object(release, "interactive_installer_refusal_smoke")) + def registry(self): def missing(*_arguments): raise FileNotFoundError @@ -1269,7 +1428,7 @@ def test_native_activation_status_is_bounded_to_known_stages_and_marker(self): "native_stage": name, "native_win32_error": raw or None, }) for code in (None, True, "1292435488", -1, 0, 1, 40, 41, 0xC0000005, - 0x4D000005, 0x4D0B0005, 0x4DFF0005, 0x14D090005): + 0x4D000005, 0x4D0B0005, 0x4DFF0005, 0x14D090005, release.INSTALLER_ERROR_SHOWN): with self.subTest(code=code): self.assertIsNone(release.decode_activation_failure(code)) @@ -1282,7 +1441,7 @@ def test_nsis_actual_numeric_branch_passes_only_known_activation_failures(self): instructions = [line.strip() for line in branch.splitlines() if line.strip() and not line.lstrip().startswith(";")] def propagated(phase, code): - registers = {"$FailureCode": str(phase), "$0": str(code)} + registers = {"$FailureCode": str(phase), "$NativeStatus": str(code)} index = 0 def value(token): return registers.get(token, token) @@ -1315,7 +1474,7 @@ def number(token): self.assertEqual(propagated(41, code), code) self.assertEqual(propagated(40, code), 40) self.assertEqual(propagated(30, code), 30) - for code in (0, 1, -1, 0xC0000005, 0x4D000005, 0x4D0B0005, 0x4DFF0005): + for code in (0, 1, -1, 0xC0000005, 0x4D000005, 0x4D0B0005, 0x4DFF0005, release.INSTALLER_ERROR_SHOWN): self.assertEqual(propagated(41, code), 41) def test_actual_child_status_is_reported_without_replay_or_primary_error_change(self): @@ -1413,6 +1572,7 @@ def test_failed_readonly_preflight_never_runs_the_installer_or_prepare(self): with self.assertRaises(subprocess.CalledProcessError): release.smoke_installer(Path("fixture.exe"), info(), False, "12", {}, checker=Path("verified-checker.exe")) commands.assert_not_called() + self.refusal.assert_not_called() self.assertIn("fixed native guard failure", diagnostics.getvalue()) self.assertNotIn("\x1b", diagnostics.getvalue()) self.assertNotIn("\x00", diagnostics.getvalue()) diff --git a/apps/maple-agent/scripts/windows-installer.nsi b/apps/maple-agent/scripts/windows-installer.nsi index 77e88879a..95e49950d 100644 --- a/apps/maple-agent/scripts/windows-installer.nsi +++ b/apps/maple-agent/scripts/windows-installer.nsi @@ -37,6 +37,12 @@ UninstPage uninstConfirm UninstPage instfiles Var FailureCode +Var FailureStage +Var NativeStatus +Var NativeStatusAvailable +Var NativeActivationStage +Var NativeWin32Error +Var DiagnosticArgs Function .onInit SetShellVarContext current @@ -73,11 +79,19 @@ Var PreviousDisplayVersion Var HadRegistration Section "Install" + StrCpy $DiagnosticArgs "" + StrCpy $NativeStatus 0 + StrCpy $NativeStatusAvailable 0 + StrCpy $NativeActivationStage 0 + IfSilent diagnostics_ready + StrCpy $DiagnosticArgs " --internal-windows-installer-show-errors" +diagnostics_ready: ; The temporary copy is the same verified signed application PE. Its private ; early command performs no GUI, account, store or backend initialization. ; All destination/DACL/reparse checks happen before any installed file or ; registry changes. Unsigned Prod previews deliberately stop here. StrCpy $FailureCode 20 + StrCpy $FailureStage "extracting the installation checker" SetErrorLevel 20 ClearErrors InitPluginsDir @@ -85,23 +99,33 @@ Section "Install" File /oname=maple-installer-check.exe "${PAYLOAD}\maple-agent.exe" IfErrors refused StrCpy $FailureCode 21 + StrCpy $FailureStage "starting installation checks" + StrCpy $NativeStatusAvailable 0 ClearErrors - ExecWait '"$PLUGINSDIR\maple-installer-check.exe" --internal-windows-installer-preflight "$INSTDIR"' $0 + ExecWait '"$PLUGINSDIR\maple-installer-check.exe" --internal-windows-installer-preflight "$INSTDIR"$DiagnosticArgs' $NativeStatus IfErrors refused + StrCpy $NativeStatusAvailable 1 StrCpy $FailureCode 22 - StrCmp $0 0 0 refused + StrCpy $FailureStage "checking the installation" + StrCmp $NativeStatus 0 0 refused ; The signed helper durably records the manual operation, creates a private ; root, bootstraps only a fresh install and repairs incomplete non-current ; generation files. A retry must never overwrite a live or valid rollback ; generation, nor remain stuck with an interrupted NSIS file copy. StrCpy $FailureCode 23 + StrCpy $FailureStage "starting installation preparation" + StrCpy $NativeStatusAvailable 0 ClearErrors - ExecWait '"$PLUGINSDIR\maple-installer-check.exe" --internal-windows-installer-prepare "$INSTDIR" ${GENERATION}' $0 + ExecWait '"$PLUGINSDIR\maple-installer-check.exe" --internal-windows-installer-prepare "$INSTDIR" ${GENERATION}$DiagnosticArgs' $NativeStatus IfErrors refused + StrCpy $NativeStatusAvailable 1 StrCpy $FailureCode 24 - StrCmp $0 0 0 refused + StrCpy $FailureStage "preparing the installation" + StrCmp $NativeStatus 0 0 refused StrCpy $FailureCode 31 + StrCpy $FailureStage "checking installation registration" + StrCpy $NativeStatusAvailable 0 StrCpy $HadRegistration 0 ClearErrors ReadRegStr $PreviousInstallLocation HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "InstallLocation" @@ -113,6 +137,7 @@ unregistered: ; Never overwrite the stable bootstrap or an existing generation. All new ; generation bytes arrive before the native helper commits current.json. StrCpy $FailureCode 30 + StrCpy $FailureStage "copying application files" SetOutPath "$INSTDIR" IfFileExists "$INSTDIR\maple-agent.exe" bootstrap_ready ClearErrors @@ -135,6 +160,7 @@ bootstrap_ready: IfErrors failed uninstaller_ready: StrCpy $FailureCode 31 + StrCpy $FailureStage "registering the application" CreateDirectory "$SMPROGRAMS\${DISPLAY_NAME}" CreateShortcut "$SMPROGRAMS\${DISPLAY_NAME}\${DISPLAY_NAME}.lnk" "$INSTDIR\maple-agent.exe" CreateShortcut "$SMPROGRAMS\${DISPLAY_NAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe" @@ -149,11 +175,17 @@ uninstaller_ready: WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "NoRepair" 1 IfErrors failed StrCpy $FailureCode 40 + StrCpy $FailureStage "starting installation activation" + StrCpy $NativeStatusAvailable 0 ClearErrors - ExecWait '"$INSTDIR\maple-agent.exe" --internal-windows-installer-activate ${GENERATION}' $0 + ; Existing immutable bootstraps predate the optional diagnostic flag. Keep + ; their activation grammar unchanged when installing a new generation. + ExecWait '"$INSTDIR\maple-agent.exe" --internal-windows-installer-activate ${GENERATION}' $NativeStatus IfErrors failed + StrCpy $NativeStatusAvailable 1 StrCpy $FailureCode 41 - StrCmp $0 0 0 failed + StrCpy $FailureStage "activating the installation" + StrCmp $NativeStatus 0 0 failed SetErrorLevel 0 Goto done failed: @@ -161,13 +193,15 @@ failed: ; ordinary failures at 41; only the fixed marker and stages 1..10 can carry ; an activation stage plus its bounded Win32 error to an unattended caller. StrCmp $FailureCode 41 0 activation_diagnostic_done - IntOp $1 $0 & 0xFF000000 + IntOp $1 $NativeStatus & 0xFF000000 IntCmp $1 0x4D000000 0 activation_diagnostic_done activation_diagnostic_done - IntOp $1 $0 >> 16 + IntOp $1 $NativeStatus >> 16 IntOp $1 $1 & 0xFF IntCmp $1 1 0 activation_diagnostic_done 0 IntCmp $1 10 0 0 activation_diagnostic_done - StrCpy $FailureCode $0 + StrCpy $NativeActivationStage $1 + IntOp $NativeWin32Error $NativeStatus & 0xFFFF + StrCpy $FailureCode $NativeStatus activation_diagnostic_done: ; The native activation failure leaves the old pointer unchanged. Restore ; registration truth; user data and an earlier generation remain untouched. @@ -178,7 +212,20 @@ restore_registration: WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "InstallLocation" "$PreviousInstallLocation" WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "DisplayVersion" "$PreviousDisplayVersion" refused: - MessageBox MB_OK|MB_ICONSTOP "Installation could not be completed safely. Close the application and retry from a verified installer." /SD IDOK + ; Only the actual failed child may acknowledge that it already displayed its + ; detailed native error. It still follows this refusal path and rollback. + StrCmp $NativeStatusAvailable 1 0 fallback_error + IntCmp $NativeStatus 0x4DFF0001 error_reported +fallback_error: + StrCpy $1 "Installation stopped while $FailureStage.$\r$\n$\r$\nInstaller stage: $FailureCode" + StrCmp $NativeStatusAvailable 1 0 show_error + IntFmt $2 "0x%08X" $NativeStatus + StrCpy $1 "$1$\r$\nWindows process exit code: $2" + StrCmp $NativeActivationStage 0 show_error + StrCpy $1 "$1$\r$\nNative activation stage: $NativeActivationStage$\r$\nWindows error: $NativeWin32Error" +show_error: + MessageBox MB_OK|MB_ICONSTOP "$1$\r$\n$\r$\nPlease include these details when contacting Maple support." /SD IDOK +error_reported: SetErrorLevel $FailureCode Abort done: diff --git a/apps/maple-agent/scripts/windows-release.py b/apps/maple-agent/scripts/windows-release.py index 929f2dcef..4beb7d199 100644 --- a/apps/maple-agent/scripts/windows-release.py +++ b/apps/maple-agent/scripts/windows-release.py @@ -67,6 +67,7 @@ 52: "uninstall-child", 53: "uninstall-files", } ACTIVATION_FAILURE_MARKER = 0x4D000000 +INSTALLER_ERROR_SHOWN = 0x4DFF0001 ACTIVATION_FAILURE_STAGES = { 1: "activation-input", 2: "root-storage", 3: "registration", 4: "bootstrap-identity", 5: "install-lock", 6: "intent-identity", @@ -971,6 +972,9 @@ def smoke_installer(installer, info, unsigned, build, expected_hashes, *, checke raise subprocess.CalledProcessError(preflight.returncode, preflight.args) if unsigned and info["profile"] == "prod" and preflight.returncode == 0: raise ValueError("Unsigned Prod checker did not refuse read-only preflight") + if not (unsigned and info["profile"] == "prod"): + phase = "interactive-refusal" + interactive_installer_refusal_smoke(installer, checker, scratch, environment, info["bundle_id"]) phase = "install" if unsigned and info["profile"] == "prod": # Keep the actual NSIS refusal proof in addition to the direct @@ -1107,11 +1111,22 @@ def __init__(self): self.kernel = ctypes.WinDLL("kernel32", use_last_error=True) self.user = ctypes.WinDLL("user32", use_last_error=True) self.psapi = ctypes.WinDLL("psapi", use_last_error=True) + class ProcessEntry(ctypes.Structure): + _fields_ = [("dwSize", wintypes.DWORD), ("cntUsage", wintypes.DWORD), + ("th32ProcessID", wintypes.DWORD), ("th32DefaultHeapID", ctypes.c_size_t), + ("th32ModuleID", wintypes.DWORD), ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), ("pcPriClassBase", wintypes.LONG), + ("dwFlags", wintypes.DWORD), ("szExeFile", wintypes.WCHAR * 260)] + self.process_entry = ProcessEntry self.callback = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) declarations = ( (self.kernel.OpenProcess, (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD), wintypes.HANDLE), (self.kernel.QueryFullProcessImageNameW, (wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)), wintypes.BOOL), (self.kernel.GetProcessTimes, (wintypes.HANDLE, *([ctypes.POINTER(wintypes.FILETIME)] * 4)), wintypes.BOOL), + (self.kernel.GetExitCodeProcess, (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)), wintypes.BOOL), + (self.kernel.CreateToolhelp32Snapshot, (wintypes.DWORD, wintypes.DWORD), wintypes.HANDLE), + (self.kernel.Process32FirstW, (wintypes.HANDLE, ctypes.POINTER(ProcessEntry)), wintypes.BOOL), + (self.kernel.Process32NextW, (wintypes.HANDLE, ctypes.POINTER(ProcessEntry)), wintypes.BOOL), (self.kernel.CloseHandle, (wintypes.HANDLE,), wintypes.BOOL), (self.kernel.WaitForSingleObject, (wintypes.HANDLE, wintypes.DWORD), wintypes.DWORD), (self.kernel.TerminateProcess, (wintypes.HANDLE, wintypes.UINT), wintypes.BOOL), @@ -1155,6 +1170,74 @@ def click(self, button): if not self.user.PostMessageW(button[0], 0x111, 1, button[1]): raise self.ctypes.WinError(self.ctypes.get_last_error()) + def diagnostic(self, pid): + for window in self.windows(pid): + body, button = self.user.GetDlgItem(window, 0xFFFF), self.user.GetDlgItem(window, 1) + if not body or not button or not self.user.IsWindowVisible(window) or not self.user.IsWindowEnabled(button): + continue + text = self.ctypes.create_unicode_buffer(DIAGNOSTIC_LIMIT) + count = self.user.GetWindowTextW(body, text, len(text)) + if count >= len(text) - 1: + raise ValueError("Windows installer error dialog exceeded diagnostic bounds") + if count: + return (window, button), text.value + return None + + def child_pids(self, parent_pid): + snapshot = self.kernel.CreateToolhelp32Snapshot(2, 0) # TH32CS_SNAPPROCESS + if snapshot == self.ctypes.c_void_p(-1).value: + raise self.ctypes.WinError(self.ctypes.get_last_error()) + try: + entry = self.process_entry() + entry.dwSize = self.ctypes.sizeof(entry) + present = self.kernel.Process32FirstW(snapshot, self.ctypes.byref(entry)) + children, count = [], 0 + while present: + count += 1 + if count > 4096: + raise ValueError("Windows installer process snapshot exceeded its bound") + if entry.th32ParentProcessID == parent_pid: + children.append(entry.th32ProcessID) + present = self.kernel.Process32NextW(snapshot, self.ctypes.byref(entry)) + if self.ctypes.get_last_error() != 18: # ERROR_NO_MORE_FILES + raise self.ctypes.WinError(self.ctypes.get_last_error()) + return children + finally: + self.kernel.CloseHandle(snapshot) + + def installer_child(self, parent_pid, temporary, expected_size, expected_hash): + # Only the known installer's direct child, inside this smoke's temp + # directory and byte-identical to its verified checker, may be operated. + for pid in self.child_pids(parent_pid): + handle = self.kernel.OpenProcess(0x1000 | 0x100000 | 1, False, pid) + if not handle: + continue + retained = False + try: + image = self.ctypes.create_unicode_buffer(32768) + size = self.types.DWORD(len(image)) + if not self.kernel.QueryFullProcessImageNameW(handle, 0, image, self.ctypes.byref(size)): + continue + path = Path(image.value) + if path.name.lower() != "maple-installer-check.exe": + continue + regular(path) + if not path.resolve(strict=True).is_relative_to(temporary.resolve(strict=True)): + raise ValueError("Owned installer checker escaped the isolated temporary directory") + if path.stat().st_size != expected_size: + raise ValueError("Owned installer checker size differs from verified bytes") + with path.open("rb") as source: + if hashlib.file_digest(source, "sha256").hexdigest() != expected_hash: + raise ValueError("Owned installer checker differs from verified bytes") + # The wrapper owns and closes this handle even if collecting + # its creation time fails. Do not close it a second time. + retained = True + return WindowsSmokeApplication(self, handle, pid) + finally: + if not retained: + self.kernel.CloseHandle(handle) + return None + def applications(self, executable): pids = (self.types.DWORD * 4096)() used = self.types.DWORD() @@ -1217,6 +1300,12 @@ def alive(self): def visible(self): return any(self.ui.user.IsWindowVisible(window) for window in self.ui.windows(self.pid)) + def exit_code(self): + code = self.ui.types.DWORD() + if not self.ui.kernel.GetExitCodeProcess(self.handle, self.ui.ctypes.byref(code)): + raise self.ui.ctypes.WinError(self.ui.ctypes.get_last_error()) + return code.value + def close(self): try: if self.alive(): @@ -1269,6 +1358,102 @@ def quiet_installer_smoke(executable, *, ui=None, existing=()): time.sleep(0.05) +def interactive_installer_refusal_smoke(installer, checker, scratch, environment, bundle_id, *, ui=None): + """Exercise the real NSIS -> native error dialog -> failed NSIS path.""" + import winreg + ui = ui or WindowsSmokeUI() + installed = scratch / "refused install" + installed.mkdir() + sentinel = installed / "user-created-file.txt" + sentinel.write_bytes(b"preserve this unknown file") + expected_size = checker.stat().st_size + with checker.open("rb") as source: + expected_hash = hashlib.file_digest(source, "sha256").hexdigest() + command = command_as_windows_args(f'"{installer}" /D={installed}') + process = subprocess.Popen(command, cwd=scratch, env=environment, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + child, primary = None, None + deadline = time.monotonic() + 120 + try: + while time.monotonic() < deadline: + if process.poll() is not None: + raise ValueError("Refusal installer exited before its Install action") + button = ui.button(process.pid, "Install") + if button: + ui.click(button) + break + time.sleep(0.05) + else: + raise ValueError("Refusal installer did not reach Install") + while time.monotonic() < deadline: + if process.poll() is not None: + raise ValueError("Refused installation did not display the native error") + child = child or ui.installer_child(process.pid, scratch / "tmp", expected_size, expected_hash) + diagnostic = ui.diagnostic(child.pid) if child else None + if diagnostic: + button, message = diagnostic + if (not message.startswith("Installation stopped during preflight.") or + "unknown files in Windows installation; choose an empty directory" not in message): + raise ValueError("Native installer error did not preserve its stage and actual refusal reason") + ui.click(button) + break + time.sleep(0.05) + else: + raise ValueError("Refused installation did not reach its bounded native diagnostic") + if ui.kernel.WaitForSingleObject(child.handle, 10000) != 0 or child.exit_code() != INSTALLER_ERROR_SHOWN: + raise ValueError("Displayed installer error did not acknowledge actual native failure") + while time.monotonic() < deadline: + duplicate = ui.diagnostic(process.pid) + if duplicate and duplicate[1].startswith("Installation stopped while"): + raise ValueError("NSIS displayed a duplicate generic error after the native diagnostic") + button = ui.button(process.pid, "Close") + if button: + ui.click(button) + break + if process.poll() is not None: + break + time.sleep(0.05) + else: + raise ValueError("Refused installation did not finish its NSIS failure path") + if process.wait(timeout=max(1, deadline - time.monotonic())) != 22: + raise ValueError("Displayed native error changed the NSIS preflight refusal outcome") + if set(installed.iterdir()) != {sentinel} or sentinel.read_bytes() != b"preserve this unknown file": + raise ValueError("Refused installation changed its existing destination") + if any(next((scratch / name).iterdir(), None) is not None for name in ("config", "data", "cache")): + raise ValueError("Refused native installation opened application profile state") + try: + key = "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\" + bundle_id + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_READ | winreg.KEY_WOW64_32KEY): + raise ValueError("Refused interactive installation changed registration") + except FileNotFoundError: + pass + except BaseException as error: + primary = error + raise + finally: + cleanup_error = None + if child is None and process.poll() is None: + try: + child = ui.installer_child(process.pid, scratch / "tmp", expected_size, expected_hash) + except Exception as error: + cleanup_error = error + try: + if process.poll() is None: + process.terminate() + process.wait(timeout=10) + except Exception as error: + cleanup_error = cleanup_error or error + try: + if child: + child.close() + except Exception as error: + cleanup_error = cleanup_error or error + if cleanup_error: + print("Windows refusal smoke cleanup failure: " + type(cleanup_error).__name__, file=sys.stderr) + if primary is None: + raise cleanup_error + + def interactive_installer_smoke(installer, installed, executable, scratch, environment, *, ui=None, existing=(), before_close=None, integration_ready=None): ui = ui or WindowsSmokeUI() command = command_as_windows_args(f'"{installer}" /D={installed}') From b7bf885df5b1190b9cddddd31991e4be9fda4eb4 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:51:47 +0000 Subject: [PATCH 2/5] Windows: keep installer diagnostics in the existing error dialog --- apps/maple-agent/app/Cargo.toml | 2 +- apps/maple-agent/app/src/main.rs | 64 +----- apps/maple-agent/app/src/windows_bootstrap.rs | 83 +------- .../crates/maple-agent-updater/Cargo.toml | 1 - .../maple-agent-updater/src/installer.rs | 76 +++++++ apps/maple-agent/docs/desktop-builds.md | 14 +- .../scripts/test_windows_release.py | 179 ++++++++--------- .../maple-agent/scripts/windows-installer.nsi | 85 ++++++-- apps/maple-agent/scripts/windows-release.py | 190 +++++++----------- 9 files changed, 327 insertions(+), 367 deletions(-) diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index 54aff092b..dab8a6820 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -110,7 +110,7 @@ objc = { version = "=0.2.7", optional = true } raw-window-handle = { version = "0.6", optional = true } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Memory", "Win32_System_Threading", "Win32_System_ErrorReporting", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Memory", "Win32_System_Threading", "Win32_System_ErrorReporting", "Win32_System_SystemInformation", "Win32_Storage_FileSystem"] } [dev-dependencies] tempfile = "3" diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index a1dc8ff6e..e3470a1f6 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -83,18 +83,14 @@ struct Cli { internal_theme_watch: bool, /// Credential-free native installer admission before any installed write. #[cfg(all(feature = "desktop", target_os = "windows"))] - #[arg(long, hide = true, group = "windows_installer_action", value_name = "INSTALL_ROOT", conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_installer_activate", "internal_windows_uninstall_cleanup", "internal_windows_restart"])] + #[arg(long, hide = true, value_name = "INSTALL_ROOT", conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_installer_activate", "internal_windows_uninstall_cleanup", "internal_windows_restart"])] internal_windows_installer_preflight: Option, #[cfg(all(feature = "desktop", target_os = "windows"))] - #[arg(long, hide = true, group = "windows_installer_action", num_args = 2, value_names = ["INSTALL_ROOT", "GENERATION"], conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_installer_preflight", "internal_windows_installer_activate", "internal_windows_uninstall_cleanup", "internal_windows_restart"])] + #[arg(long, hide = true, num_args = 2, value_names = ["INSTALL_ROOT", "GENERATION"], conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_installer_preflight", "internal_windows_installer_activate", "internal_windows_uninstall_cleanup", "internal_windows_restart"])] internal_windows_installer_prepare: Vec, #[cfg(all(feature = "desktop", target_os = "windows"))] - #[arg(long, hide = true, group = "windows_installer_action", value_name = "GENERATION", conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_uninstall_cleanup", "internal_windows_restart"])] + #[arg(long, hide = true, value_name = "GENERATION", conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_uninstall_cleanup", "internal_windows_restart"])] internal_windows_installer_activate: Option, - /// Interactive NSIS only; silent verification and updates never show a dialog. - #[cfg(all(feature = "desktop", target_os = "windows"))] - #[arg(long, hide = true, requires = "windows_installer_action")] - internal_windows_installer_show_errors: bool, #[cfg(all(feature = "desktop", target_os = "windows"))] #[arg(long, hide = true, conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_restart"])] internal_windows_uninstall_cleanup: bool, @@ -290,9 +286,7 @@ fn main() { // credentials or a host. Their paths and code identities are checked // by the updater's native installer before mutation or delegation. let mut internal_exit_code = 1; - let mut installer_phase = None; let internal = if let Some(root) = &cli.internal_windows_installer_preflight { - installer_phase = Some(windows_bootstrap::InstallerPhase::Preflight); Some( std::env::current_exe() .map_err(|_| "Could not locate the Windows installer checker".to_owned()) @@ -302,11 +296,13 @@ fn main() { root, profile::PROFILE, ) - .map_err(|error| error.to_string()) + .map_err(|error| { + internal_exit_code = error.windows_installer_exit_code(); + error.to_string() + }) }), ) } else if !cli.internal_windows_installer_prepare.is_empty() { - installer_phase = Some(windows_bootstrap::InstallerPhase::Preparation); Some( std::env::current_exe() .map_err(|_| "Could not locate the Windows installer checker".to_owned()) @@ -322,11 +318,13 @@ fn main() { profile::PROFILE, generation, ) - .map_err(|error| error.to_string()) + .map_err(|error| { + internal_exit_code = error.windows_installer_exit_code(); + error.to_string() + }) }), ) } else if let Some(generation) = &cli.internal_windows_installer_activate { - installer_phase = Some(windows_bootstrap::InstallerPhase::Activation); Some( std::env::current_exe() .map_err(|_| "Could not locate the Windows bootstrap".to_owned()) @@ -363,14 +361,6 @@ fn main() { if let Some(result) = internal { if let Err(error) = result { eprintln!("{error}"); - if cli.internal_windows_installer_show_errors - && let Some(phase) = installer_phase - && windows_bootstrap::show_installer_error(profile::DISPLAY_NAME, phase, &error) - { - // This is still a failure. NSIS uses the acknowledgement - // only to avoid displaying a second generic error dialog. - std::process::exit(windows_bootstrap::INSTALLER_ERROR_SHOWN); - } std::process::exit(internal_exit_code); } return; @@ -904,38 +894,6 @@ mod tests { assert!(parse(&["--build-info", "some/file.txt"]).is_err()); } - #[cfg(all(feature = "desktop", target_os = "windows"))] - #[test] - fn installer_error_dialog_requires_an_explicit_installer_action() { - const SHOW: &str = "--internal-windows-installer-show-errors"; - for action in [ - vec!["--internal-windows-installer-preflight", "C:\\Maple"], - vec![ - "--internal-windows-installer-prepare", - "C:\\Maple", - "0.0.14-780", - ], - vec!["--internal-windows-installer-activate", "0.0.14-780"], - ] { - let args: Vec<_> = std::iter::once("maple-agent") - .chain(action.iter().copied()) - .collect(); - assert!( - !Cli::try_parse_from(&args) - .unwrap() - .internal_windows_installer_show_errors - ); - assert!( - Cli::try_parse_from(args.into_iter().chain([SHOW])) - .unwrap() - .internal_windows_installer_show_errors - ); - } - for args in [vec![SHOW], vec!["--build-info", SHOW], vec![SHOW, "acp"]] { - assert!(parse(&args).is_err()); - } - } - #[test] fn login_takes_an_optional_email_and_never_a_password() { assert_eq!( diff --git a/apps/maple-agent/app/src/windows_bootstrap.rs b/apps/maple-agent/app/src/windows_bootstrap.rs index 5962e1515..a6b593748 100644 --- a/apps/maple-agent/app/src/windows_bootstrap.rs +++ b/apps/maple-agent/app/src/windows_bootstrap.rs @@ -1,4 +1,4 @@ -//! Credential-free Windows bootstrap, restart and installer error UI. +//! Credential-free Windows bootstrap and restart. Never evaluates a shell script. //! The installer owns destination/profile/code validation; this module only //! delegates to its selected generation or waits for an exact retiring process. @@ -15,63 +15,8 @@ use windows_sys::Win32::{ CREATE_NO_WINDOW, GetCurrentProcess, GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject, }, - UI::WindowsAndMessaging::{ - IDOK, MB_ICONERROR, MB_OK, MB_SETFOREGROUND, MB_TASKMODAL, MessageBoxW, - }, }; -/// UI acknowledgement only, never a successful installation or activation. -pub(crate) const INSTALLER_ERROR_SHOWN: i32 = 0x4DFF_0001; - -#[derive(Clone, Copy)] -pub(crate) enum InstallerPhase { - Preflight, - Preparation, - Activation, -} - -fn installer_error_message(phase: InstallerPhase, error: &str) -> String { - let phase = match phase { - InstallerPhase::Preflight => "preflight", - InstallerPhase::Preparation => "preparation", - InstallerPhase::Activation => "activation", - }; - // Native failures contain fixed validation labels and OS errors, never - // account/store state. Bound even unexpected text before handing it to UI. - let detail: String = error - .chars() - .filter(|character| !character.is_control()) - .take(2048) - .collect(); - format!( - "Installation stopped during {phase}.\r\n\r\n{detail}\r\n\r\nSend a screenshot of this message to Maple support." - ) -} - -/// Called only for an explicitly interactive installer failure, before any -/// application, account or store initialization. A failed dialog leaves NSIS's -/// own fallback error visible instead of falsely acknowledging presentation. -pub(crate) fn show_installer_error(display_name: &str, phase: InstallerPhase, error: &str) -> bool { - let text: Vec = installer_error_message(phase, error) - .encode_utf16() - .chain(Some(0)) - .collect(); - let title: Vec = format!("{display_name} Setup") - .encode_utf16() - .chain(Some(0)) - .collect(); - // SAFETY: both NUL-terminated UTF-16 buffers remain live until the modal - // call returns. No borrowed parent-window handle or mutable data is used. - unsafe { - MessageBoxW( - std::ptr::null_mut(), - text.as_ptr(), - title.as_ptr(), - MB_OK | MB_ICONERROR | MB_SETFOREGROUND | MB_TASKMODAL, - ) == IDOK - } -} - struct ProcessHandle(HANDLE); impl Drop for ProcessHandle { fn drop(&mut self) { @@ -245,32 +190,6 @@ fn forward_verified_target( mod tests { use super::*; - #[test] - fn installer_error_text_keeps_native_failure_and_distinct_stage() { - for (phase, label) in [ - (InstallerPhase::Preflight, "preflight"), - (InstallerPhase::Preparation, "preparation"), - (InstallerPhase::Activation, "activation"), - ] { - let message = installer_error_message( - phase, - "Windows could not verify Maple's code signature (WinTrust error 0x800B0109)", - ); - assert!(message.starts_with(&format!("Installation stopped during {label}."))); - assert!(message.contains("WinTrust error 0x800B0109")); - assert!(message.contains("Maple support")); - } - } - - #[test] - fn installer_error_text_bounds_and_sanitizes_unexpected_controls() { - let error = format!("native\0error\n{}", "x".repeat(4096)); - let message = installer_error_message(InstallerPhase::Preflight, &error); - assert!(message.contains("nativeerror")); - assert!(!message.contains('\0')); - assert!(message.len() < 2300); - } - #[test] fn windows_restart_identity_is_canonical_and_bounded() { assert_eq!(parse_restart_identity("42:12345"), Ok((42, 12345))); diff --git a/apps/maple-agent/crates/maple-agent-updater/Cargo.toml b/apps/maple-agent/crates/maple-agent-updater/Cargo.toml index 8a87ec3cf..a65af2c1b 100644 --- a/apps/maple-agent/crates/maple-agent-updater/Cargo.toml +++ b/apps/maple-agent/crates/maple-agent-updater/Cargo.toml @@ -36,4 +36,3 @@ ed25519-dalek = "2" [target.'cfg(windows)'.dev-dependencies] windows = { version = "=0.61.3", features = ["Win32_UI_WindowsAndMessaging"] } -windows-sys = { version = "=0.61.2", features = ["Win32_UI_WindowsAndMessaging"] } diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs index 9d5f6323e..1ac2f63c7 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs @@ -129,6 +129,26 @@ impl InstallError { } } + /// Report a failed preflight/preparation through NSIS without losing a + /// native error's high bits. Every category remains a nonzero failure; + /// activation keeps its existing separately decoded stage contract. + pub fn windows_installer_exit_code(&self) -> i32 { + match self { + Self::WindowsCodeSignature { status } if *status != 0 => *status as i32, + Self::CodeSignature | Self::WindowsCodeSignature { .. } => 0x4E00_0001, + Self::Busy => 0x4E00_0002, + Self::Invalid(_) => 0x4E00_0003, + Self::NotifyOnly(_) => 0x4E00_0004, + Self::Io(error) => error + .raw_os_error() + .filter(|code| *code != 0) + .unwrap_or(0x4E00_0005), + Self::WindowsActivation { .. } => { + self.windows_activation_exit_code().unwrap_or(0x4E00_0003) + } + } + } + /// Encode only an activation failure, without paths, SIDs or error text. /// The original typed source remains available to direct callers. pub fn windows_activation_exit_code(&self) -> Option { @@ -152,6 +172,62 @@ type Result = std::result::Result; mod windows_activation_error_tests { use super::*; + #[test] + fn installer_status_retains_full_native_bits_including_negative_trust_errors() { + for status in [1, 0x800B_0109, 0x8009_2013, u32::MAX] { + let error = InstallError::WindowsCodeSignature { status }; + let code = error.windows_installer_exit_code(); + assert_eq!(code as u32, status); + assert_ne!(code, 0); + } + for code in [5, 32, 65_536, i32::MIN, i32::MAX, -1] { + let error = InstallError::Io(io::Error::from_raw_os_error(code)); + assert_eq!(error.windows_installer_exit_code(), code); + } + } + + #[test] + fn installer_status_fallbacks_never_return_success_or_change_activation_status() { + for (error, code) in [ + (InstallError::CodeSignature, 0x4E00_0001), + ( + InstallError::WindowsCodeSignature { status: 0 }, + 0x4E00_0001, + ), + (InstallError::Busy, 0x4E00_0002), + (InstallError::Invalid("public fixture"), 0x4E00_0003), + (InstallError::NotifyOnly("public fixture"), 0x4E00_0004), + ( + InstallError::Io(io::Error::from(io::ErrorKind::PermissionDenied)), + 0x4E00_0005, + ), + ( + InstallError::Io(io::Error::from_raw_os_error(0)), + 0x4E00_0005, + ), + ] { + assert_eq!(error.windows_installer_exit_code(), code); + assert_ne!(code, 0); + } + for source in [ + InstallError::WindowsCodeSignature { + status: 0x800B_0109, + }, + InstallError::Io(io::Error::from_raw_os_error(32)), + InstallError::Invalid("public fixture"), + ] { + let error = InstallError::WindowsActivation { + stage: WindowsActivationStage::GenerationVerification, + source: Box::new(source), + }; + assert_eq!( + Some(error.windows_installer_exit_code()), + error.windows_activation_exit_code() + ); + assert_ne!(error.windows_installer_exit_code(), 0); + } + } + #[test] fn windows_signature_status_survives_activation_context_without_changing_error_policy() { for status in [0x800B_0109, 0x8009_2013, 0x800B_0100, u32::MAX] { diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index 33befe5d0..ee6a1863d 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -112,13 +112,13 @@ Closing a successfully completed interactive installer opens Maple. Silent the profile's Maple icon. Normal GUI startup also refreshes the icon for this profile's existing Start menu shortcuts and installed-app entry, including upgrades from 0.0.12, while preserving the stable launcher and shortcut targets. -An interactive installation refusal shows the native failing check and its -underlying Windows error, including the full WinTrust status for certificate -verification failures. If the checker cannot start, the installer reports its -own phase and any child exit code instead. Include that message when reporting -a failed install. Silent installs retain nonzero failure statuses without -opening the native error dialog. Existing signature and storage checks still -apply; administrator mode is not a bypass. +An installation refusal identifies the installer phase and available native +error code, including the full WinTrust status for certificate failures and the +Win32 code for operating-system failures. Known errors include a short +explanation; include the complete message when reporting a failed install. +Silent installs retain nonzero failure statuses without opening an error +dialog. Existing signature and storage checks still apply; administrator mode +is not a bypass. macOS packages contain a DMG and application archive; drag the chosen Agent app into Applications. Both Agent profiles can coexist with Maple Research. Linux packages contain diff --git a/apps/maple-agent/scripts/test_windows_release.py b/apps/maple-agent/scripts/test_windows_release.py index 995e21b97..2e2b2dd85 100644 --- a/apps/maple-agent/scripts/test_windows_release.py +++ b/apps/maple-agent/scripts/test_windows_release.py @@ -815,39 +815,33 @@ def test_owned_gui_exit_race_is_accepted_only_when_the_same_handle_has_exited(se class InteractiveRefusalSmokeTests(unittest.TestCase): - def execute(self, *, message=None, ack=None, installer_code=22, duplicate=False, mutation=None, no_dialog=False): + def execute(self, *, message=None, installer_code=22, mutation=None, no_dialog=False): with tempfile.TemporaryDirectory() as temporary: scratch = Path(temporary) for name in ("config", "data", "cache", "tmp"): (scratch / name).mkdir() - checker = scratch / "verified-checker.exe" - checker.write_bytes(b"verified checker fixture") process = Mock(pid=123, returncode=None) process.poll.side_effect = lambda: process.returncode process.wait.side_effect = lambda timeout: process.returncode if process.returncode is not None else (_ for _ in ()).throw(subprocess.TimeoutExpired("owned installer", timeout)) process.terminate.side_effect = lambda: setattr(process, "returncode", -1) - child = Mock(pid=456, handle=987) - child.exit_code.return_value = release.INSTALLER_ERROR_SHOWN if ack is None else ack ui = Mock() - ui.kernel.WaitForSingleObject.return_value = 0 - ui.installer_child.return_value = child + ui.windows.return_value = [100] state = {"installed": False, "dismissed": False} - body = message or ("Installation stopped during preflight.\nThe prepared update is invalid: " - "unknown files in Windows installation; choose an empty directory") + body = message or ("Installation stopped while checking the installation.\nInstaller stage: 22\n" + "Windows process exit code: 0x4E000003\nThe installation folder or application files did not pass validation.") def button(pid, caption): if caption == "Install" and not state["installed"] or caption == "Close" and state["dismissed"]: return pid, caption return None - def diagnostic(pid): - if pid == child.pid and not state["dismissed"] and not no_dialog: + def diagnostic(pid, **_options): + self.assertEqual(pid, process.pid) + if state["installed"] and not state["dismissed"] and not no_dialog: return (pid, "OK"), body - if pid == process.pid and state["dismissed"] and duplicate: - return (pid, "OK"), "Installation stopped while checking the installation." return None def click(selected): if selected == (process.pid, "Install"): state["installed"] = True - elif selected == (child.pid, "OK"): + elif selected == (process.pid, "OK"): state["dismissed"] = True if mutation: mutation(scratch) @@ -858,47 +852,39 @@ def click(selected): def missing(*_arguments): raise FileNotFoundError registry = types.SimpleNamespace(KEY_READ=1, KEY_WOW64_32KEY=2, HKEY_CURRENT_USER=3, OpenKey=missing) - self.process, self.child, self.ui = process, child, ui + self.process, self.ui = process, ui with patch.dict(sys.modules, winreg=registry), patch.object(release.subprocess, "Popen", return_value=process) as popen, patch.object(release.time, "monotonic", clock.monotonic), patch.object(release.time, "sleep", clock.sleep): - release.interactive_installer_refusal_smoke(Path("C:/verified setup.exe"), checker, scratch, {"FIXTURE": "sanitized"}, "fixture.bundle", ui=ui) - return popen.call_args, scratch, ui + release.interactive_installer_refusal_smoke(Path("C:/verified setup.exe"), scratch, {"FIXTURE": "sanitized"}, "fixture.bundle", ui=ui) + return popen.call_args, ui def test_real_path_driver_requires_actual_error_then_failed_installer_without_mutation(self): - call, _scratch, ui = self.execute() + call, ui = self.execute() self.assertIn('"C:/verified setup.exe" /D=', call.args[0]) self.assertNotIn("/S", call.args[0]) - self.assertEqual(ui.click.call_args_list, [unittest.mock.call((123, "Install")), unittest.mock.call((456, "OK")), unittest.mock.call((123, "Close"))]) - ui.installer_child.assert_called_once() - self.assertEqual(ui.installer_child.call_args.args[0], 123) - self.assertEqual(ui.installer_child.call_args.args[2:], (len(b"verified checker fixture"), hashlib.sha256(b"verified checker fixture").hexdigest())) - self.child.close.assert_called_once() + self.assertEqual(ui.click.call_args_list, [unittest.mock.call((123, "Install")), unittest.mock.call((123, "OK")), unittest.mock.call((123, "Close"))]) self.process.terminate.assert_not_called() - def test_wrong_native_stage_or_missing_actual_guard_reason_cannot_pass(self): - for message in ("Installation stopped during preparation. unknown files in Windows installation; choose an empty directory", - "Installation stopped during preflight. some unrelated error"): - with self.subTest(message=message), self.assertRaisesRegex(ValueError, "stage and actual refusal reason"): - self.execute(message=message) + def test_wrong_stage_or_native_code_or_missing_guidance_cannot_pass(self): + for changed in ("Installer stage: 24; Windows process exit code: 0x4E000003; did not pass validation", + "Installer stage: 22; Windows process exit code: 0x00000001; did not pass validation", + "Installer stage: 22; Windows process exit code: 0x4E000003; unrelated advice"): + with self.subTest(message=changed), self.assertRaisesRegex(ValueError, "stage, native validation code and guidance"): + self.execute(message=changed) self.process.terminate.assert_called_once() - self.child.close.assert_called_once() - def test_shown_message_does_not_bless_an_acknowledgement_or_success_status(self): - with self.assertRaisesRegex(ValueError, "acknowledge actual native failure"): - self.execute(ack=1) + def test_displayed_message_does_not_bless_a_success_status(self): with self.assertRaisesRegex(ValueError, "changed the NSIS preflight refusal"): self.execute(installer_code=0) - def test_duplicate_generic_dialog_is_a_failure(self): - with self.assertRaisesRegex(ValueError, "duplicate generic error"): - self.execute(duplicate=True) - self.process.terminate.assert_called_once() - self.child.close.assert_called_once() - - def test_missing_dialog_times_out_and_cleans_only_bound_processes(self): - with self.assertRaisesRegex(ValueError, "bounded native diagnostic"): + def test_missing_dialog_reports_bounded_observer_state_and_stops_owned_installer(self): + output = io.StringIO() + with patch.object(sys, "stderr", output), self.assertRaisesRegex(ValueError, "no readable installer diagnostic"): self.execute(no_dialog=True) self.process.terminate.assert_called_once() - self.child.close.assert_called_once() + details = json.loads(output.getvalue().split("Windows installer refusal observation: ", 1)[1]) + self.assertEqual(details, {"installer_alive": True, "installer_top_window_count": 1, + "expected_error_dialog_present": False}) + self.assertNotIn("window text", output.getvalue()) def test_failed_installation_must_preserve_unknown_file_and_profile_state(self): for mutation, expected in ( @@ -909,55 +895,68 @@ def test_failed_installation_must_preserve_unknown_file_and_profile_state(self): self.execute(mutation=mutation) -class InstallerChildOwnershipTests(unittest.TestCase): - def driver(self, image): +class InstallerDialogObservationTests(unittest.TestCase): + def driver(self, *, controls=None, unresponsive=False, oversized=False): ui = object.__new__(release.WindowsSmokeUI) - ui.ctypes, ui.types = ctypes, types.SimpleNamespace(DWORD=ctypes.c_uint32) - ui.child_pids = Mock(return_value=[456]) - def query(_handle, _flags, buffer, _size): - buffer.value = str(image) + ui.ctypes, ui.types, ui.callback = ctypes, types.SimpleNamespace(DWORD=ctypes.c_uint32), lambda function: function + ui.windows = Mock(return_value=[100]) + controls = controls or {100: [11, 12, 13, 14]} + body = "Installation stopped while checking the installation.\nInstaller stage: 22\nWindows process exit code: 0x4E000003" + def enumerate_children(window, callback, data): + for control in controls[window]: + if not callback(control, data): + return 0 return 1 - ui.kernel = types.SimpleNamespace(OpenProcess=Mock(return_value=987), - QueryFullProcessImageNameW=Mock(side_effect=query), CloseHandle=Mock()) - return ui - - def test_checker_selection_requires_exact_direct_child_path_size_and_bytes(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - private = root / "owned tmp" - private.mkdir() - image = private / "maple-installer-check.exe" - image.write_bytes(b"verified bytes") - digest = hashlib.sha256(image.read_bytes()).hexdigest() - for size, expected, kind in ((image.stat().st_size, digest, "valid"), - (1, digest, "size"), (image.stat().st_size, "0" * 64, "bytes")): - with self.subTest(kind=kind): - ui = self.driver(image) - owned = Mock() - with patch.object(release, "WindowsSmokeApplication", return_value=owned) as wrapper: - if kind == "valid": - self.assertIs(ui.installer_child(123, private, size, expected), owned) - wrapper.assert_called_once_with(ui, 987, 456) - ui.kernel.CloseHandle.assert_not_called() - else: - with self.assertRaisesRegex(ValueError, "size differs|differs from verified bytes"): - ui.installer_child(123, private, size, expected) - wrapper.assert_not_called() - ui.kernel.CloseHandle.assert_called_once_with(987) - ui.child_pids.assert_called_once_with(123) - ui = self.driver(image) - (root / "elsewhere").mkdir() - with self.assertRaisesRegex(ValueError, "escaped the isolated"): - ui.installer_child(123, root / "elsewhere", image.stat().st_size, digest) - ui.kernel.CloseHandle.assert_called_once_with(987) - - def test_other_executables_are_released_without_becoming_cleanup_targets(self): - ui = self.driver(Path("C:/other-process.exe")) - with patch.object(release, "regular") as regular, patch.object(release, "WindowsSmokeApplication") as wrapper: - self.assertIsNone(ui.installer_child(123, Path("C:/owned"), 1, "0" * 64)) - regular.assert_not_called() - wrapper.assert_not_called() - ui.kernel.CloseHandle.assert_called_once_with(987) + def owner(control, output): + ctypes.cast(output, ctypes.POINTER(ctypes.c_uint32)).contents.value = 999 if control == 13 else 123 + return 1 + def class_name(control, output, maximum): + output.value = "Button" if control == 12 else "Static" + return len(output.value) + def text(control, message, maximum, address, flags, timeout, copied): + self.assertEqual((message, maximum, flags, timeout), (0x000D, release.DIAGNOSTIC_LIMIT, 0x23, 250)) + if unresponsive: + return 0 + value = "Normal installer page" if control == 15 else body + ctypes.cast(address, ctypes.POINTER(ctypes.c_wchar * maximum)).contents.value = value + ctypes.cast(copied, ctypes.POINTER(ctypes.c_size_t)).contents.value = maximum - 1 if oversized else len(value) + return 1 + ui.user = types.SimpleNamespace(EnumChildWindows=Mock(side_effect=enumerate_children), + GetWindowThreadProcessId=Mock(side_effect=owner), GetClassNameW=Mock(side_effect=class_name), + GetWindowLongPtrW=Mock(side_effect=lambda control, _index: 3 if control == 11 else 0), + SendMessageTimeoutW=Mock(side_effect=text), GetWindowTextW=Mock(side_effect=AssertionError("cross-process text must use WM_GETTEXT")), + GetDlgItem=Mock(return_value=999), IsWindowVisible=Mock(return_value=True), IsWindowEnabled=Mock(return_value=True)) + return ui, body + + def test_reads_only_owned_text_static_controls_without_assuming_the_body_id(self): + ui, body = self.driver() + self.assertEqual(ui.diagnostic(123), ((100, 999), body)) + self.assertEqual(ui.user.GetDlgItem.call_args_list, [unittest.mock.call(100, 1)]) + self.assertEqual(ui.user.SendMessageTimeoutW.call_count, 1) + self.assertEqual(ui.user.SendMessageTimeoutW.call_args.args[0], 14) + ui.user.GetWindowTextW.assert_not_called() + + def test_unresponsive_control_is_bounded_and_is_not_a_readable_dialog(self): + ui, _ = self.driver(unresponsive=True) + self.assertIsNone(ui.diagnostic(123)) + self.assertEqual(ui.user.SendMessageTimeoutW.call_args.args[5], 250) + + def test_truncated_native_message_cannot_pass_as_complete_error_evidence(self): + ui, _ = self.driver(oversized=True) + with self.assertRaisesRegex(ValueError, "diagnostic bounds"): + ui.diagnostic(123) + + def test_enumeration_stops_before_reading_an_unbounded_control_tree(self): + ui, _ = self.driver(controls={100: list(range(1000, 1100))}) + with self.assertRaisesRegex(ValueError, "control bound"): + ui.diagnostic(123) + ui.user.GetClassNameW.assert_not_called() + ui.user.SendMessageTimeoutW.assert_not_called() + + def test_expected_error_dialog_is_selected_instead_of_the_installer_page(self): + ui, body = self.driver(controls={100: [15], 200: [14]}) + ui.windows.return_value = [100, 200] + self.assertEqual(ui.diagnostic(123, required_prefix="Installation stopped while"), ((200, 999), body)) class NativeShortcutProbeTests(unittest.TestCase): @@ -1428,7 +1427,7 @@ def test_native_activation_status_is_bounded_to_known_stages_and_marker(self): "native_stage": name, "native_win32_error": raw or None, }) for code in (None, True, "1292435488", -1, 0, 1, 40, 41, 0xC0000005, - 0x4D000005, 0x4D0B0005, 0x4DFF0005, 0x14D090005, release.INSTALLER_ERROR_SHOWN): + 0x4D000005, 0x4D0B0005, 0x4DFF0005, 0x14D090005): with self.subTest(code=code): self.assertIsNone(release.decode_activation_failure(code)) @@ -1474,7 +1473,7 @@ def number(token): self.assertEqual(propagated(41, code), code) self.assertEqual(propagated(40, code), 40) self.assertEqual(propagated(30, code), 30) - for code in (0, 1, -1, 0xC0000005, 0x4D000005, 0x4D0B0005, 0x4DFF0005, release.INSTALLER_ERROR_SHOWN): + for code in (0, 1, -1, 0xC0000005, 0x4D000005, 0x4D0B0005, 0x4DFF0005): self.assertEqual(propagated(41, code), 41) def test_actual_child_status_is_reported_without_replay_or_primary_error_change(self): diff --git a/apps/maple-agent/scripts/windows-installer.nsi b/apps/maple-agent/scripts/windows-installer.nsi index 95e49950d..ce39dd954 100644 --- a/apps/maple-agent/scripts/windows-installer.nsi +++ b/apps/maple-agent/scripts/windows-installer.nsi @@ -42,7 +42,7 @@ Var NativeStatus Var NativeStatusAvailable Var NativeActivationStage Var NativeWin32Error -Var DiagnosticArgs +Var FailureAdvice Function .onInit SetShellVarContext current @@ -79,13 +79,9 @@ Var PreviousDisplayVersion Var HadRegistration Section "Install" - StrCpy $DiagnosticArgs "" StrCpy $NativeStatus 0 StrCpy $NativeStatusAvailable 0 StrCpy $NativeActivationStage 0 - IfSilent diagnostics_ready - StrCpy $DiagnosticArgs " --internal-windows-installer-show-errors" -diagnostics_ready: ; The temporary copy is the same verified signed application PE. Its private ; early command performs no GUI, account, store or backend initialization. ; All destination/DACL/reparse checks happen before any installed file or @@ -102,7 +98,7 @@ diagnostics_ready: StrCpy $FailureStage "starting installation checks" StrCpy $NativeStatusAvailable 0 ClearErrors - ExecWait '"$PLUGINSDIR\maple-installer-check.exe" --internal-windows-installer-preflight "$INSTDIR"$DiagnosticArgs' $NativeStatus + ExecWait '"$PLUGINSDIR\maple-installer-check.exe" --internal-windows-installer-preflight "$INSTDIR"' $NativeStatus IfErrors refused StrCpy $NativeStatusAvailable 1 StrCpy $FailureCode 22 @@ -116,7 +112,7 @@ diagnostics_ready: StrCpy $FailureStage "starting installation preparation" StrCpy $NativeStatusAvailable 0 ClearErrors - ExecWait '"$PLUGINSDIR\maple-installer-check.exe" --internal-windows-installer-prepare "$INSTDIR" ${GENERATION}$DiagnosticArgs' $NativeStatus + ExecWait '"$PLUGINSDIR\maple-installer-check.exe" --internal-windows-installer-prepare "$INSTDIR" ${GENERATION}' $NativeStatus IfErrors refused StrCpy $NativeStatusAvailable 1 StrCpy $FailureCode 24 @@ -178,8 +174,6 @@ uninstaller_ready: StrCpy $FailureStage "starting installation activation" StrCpy $NativeStatusAvailable 0 ClearErrors - ; Existing immutable bootstraps predate the optional diagnostic flag. Keep - ; their activation grammar unchanged when installing a new generation. ExecWait '"$INSTDIR\maple-agent.exe" --internal-windows-installer-activate ${GENERATION}' $NativeStatus IfErrors failed StrCpy $NativeStatusAvailable 1 @@ -212,25 +206,82 @@ restore_registration: WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "InstallLocation" "$PreviousInstallLocation" WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "DisplayVersion" "$PreviousDisplayVersion" refused: - ; Only the actual failed child may acknowledge that it already displayed its - ; detailed native error. It still follows this refusal path and rollback. - StrCmp $NativeStatusAvailable 1 0 fallback_error - IntCmp $NativeStatus 0x4DFF0001 error_reported -fallback_error: StrCpy $1 "Installation stopped while $FailureStage.$\r$\n$\r$\nInstaller stage: $FailureCode" StrCmp $NativeStatusAvailable 1 0 show_error IntFmt $2 "0x%08X" $NativeStatus StrCpy $1 "$1$\r$\nWindows process exit code: $2" - StrCmp $NativeActivationStage 0 show_error - StrCpy $1 "$1$\r$\nNative activation stage: $NativeActivationStage$\r$\nWindows error: $NativeWin32Error" + StrCmp $NativeActivationStage 0 describe_error + StrCpy $1 "$1$\r$\nNative activation stage: $NativeActivationStage" + StrCmp $NativeWin32Error 0 describe_error + StrCpy $1 "$1$\r$\nWindows error: $NativeWin32Error" +describe_error: + Call DescribeInstallerError + StrCpy $1 "$1$\r$\n$\r$\n$FailureAdvice" show_error: MessageBox MB_OK|MB_ICONSTOP "$1$\r$\n$\r$\nPlease include these details when contacting Maple support." /SD IDOK -error_reported: SetErrorLevel $FailureCode Abort done: SectionEnd +Function DescribeInstallerError + StrCpy $FailureAdvice "Windows could not complete this installation." + StrCpy $2 $NativeStatus + StrCmp $NativeActivationStage 0 classify + StrCpy $2 $NativeWin32Error +classify: + IntCmp $2 0x800B0109 untrusted + IntCmp $2 0x800B010A untrusted + IntCmp $2 0x80092013 revocation + IntCmp $2 0x80092012 revocation + IntCmp $2 0x800B0101 certificate_date + IntCmp $2 0x80096010 signature + IntCmp $2 0x800B0100 signature + IntCmp $2 0x4E000001 signature + IntCmp $2 0x4E000002 busy + IntCmp $2 0x4E000003 validation + IntCmp $2 0x4E000004 manual + IntCmp $2 0x4E000005 storage + IntCmp $2 5 access + IntCmp $2 32 locked + IntCmp $2 33 locked + IntCmp $2 0xC0000135 component + Goto done +untrusted: + StrCpy $FailureAdvice "Windows could not establish trust in the signing certificate. Check Windows Update and internet access, then retry." + Goto done +revocation: + StrCpy $FailureAdvice "Windows could not check the signing certificate's revocation status. Check your internet connection, then retry." + Goto done +certificate_date: + StrCpy $FailureAdvice "Windows reported a certificate validity problem. Check the computer's date and time and Windows Update." + Goto done +signature: + StrCpy $FailureAdvice "The application signature or identity could not be verified. Download a new installer from Maple's official download page." + Goto done +busy: + StrCpy $FailureAdvice "Another Maple installation or update is active. Let it finish before retrying." + Goto done +validation: + StrCpy $FailureAdvice "The installation folder or application files did not pass validation. For a first installation, choose a new empty folder. Otherwise contact Maple support." + Goto done +manual: + StrCpy $FailureAdvice "Maple could not safely replace this installation. Contact Maple support with these details." + Goto done +storage: + StrCpy $FailureAdvice "Windows could not read or write installation files. Contact Maple support with these details." + Goto done +access: + StrCpy $FailureAdvice "Windows denied access to the installation folder. Choose a folder owned by your user account, or contact Maple support." + Goto done +locked: + StrCpy $FailureAdvice "An installation file is in use. Close ${DISPLAY_NAME} and its background host, then retry." + Goto done +component: + StrCpy $FailureAdvice "Windows could not load a required system component. Check Windows Update and contact Maple support with this code." +done: +FunctionEnd + Function .onInstSuccess ; The success callback runs when the completed installer closes. Silent ; updates/reinstalls stay quiet, and failed or cancelled installs never call diff --git a/apps/maple-agent/scripts/windows-release.py b/apps/maple-agent/scripts/windows-release.py index 4beb7d199..3acf195a0 100644 --- a/apps/maple-agent/scripts/windows-release.py +++ b/apps/maple-agent/scripts/windows-release.py @@ -67,7 +67,6 @@ 52: "uninstall-child", 53: "uninstall-files", } ACTIVATION_FAILURE_MARKER = 0x4D000000 -INSTALLER_ERROR_SHOWN = 0x4DFF0001 ACTIVATION_FAILURE_STAGES = { 1: "activation-input", 2: "root-storage", 3: "registration", 4: "bootstrap-identity", 5: "install-lock", 6: "intent-identity", @@ -974,7 +973,7 @@ def smoke_installer(installer, info, unsigned, build, expected_hashes, *, checke raise ValueError("Unsigned Prod checker did not refuse read-only preflight") if not (unsigned and info["profile"] == "prod"): phase = "interactive-refusal" - interactive_installer_refusal_smoke(installer, checker, scratch, environment, info["bundle_id"]) + interactive_installer_refusal_smoke(installer, scratch, environment, info["bundle_id"]) phase = "install" if unsigned and info["profile"] == "prod": # Keep the actual NSIS refusal proof in addition to the direct @@ -1111,32 +1110,26 @@ def __init__(self): self.kernel = ctypes.WinDLL("kernel32", use_last_error=True) self.user = ctypes.WinDLL("user32", use_last_error=True) self.psapi = ctypes.WinDLL("psapi", use_last_error=True) - class ProcessEntry(ctypes.Structure): - _fields_ = [("dwSize", wintypes.DWORD), ("cntUsage", wintypes.DWORD), - ("th32ProcessID", wintypes.DWORD), ("th32DefaultHeapID", ctypes.c_size_t), - ("th32ModuleID", wintypes.DWORD), ("cntThreads", wintypes.DWORD), - ("th32ParentProcessID", wintypes.DWORD), ("pcPriClassBase", wintypes.LONG), - ("dwFlags", wintypes.DWORD), ("szExeFile", wintypes.WCHAR * 260)] - self.process_entry = ProcessEntry self.callback = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) declarations = ( (self.kernel.OpenProcess, (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD), wintypes.HANDLE), (self.kernel.QueryFullProcessImageNameW, (wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)), wintypes.BOOL), (self.kernel.GetProcessTimes, (wintypes.HANDLE, *([ctypes.POINTER(wintypes.FILETIME)] * 4)), wintypes.BOOL), - (self.kernel.GetExitCodeProcess, (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)), wintypes.BOOL), - (self.kernel.CreateToolhelp32Snapshot, (wintypes.DWORD, wintypes.DWORD), wintypes.HANDLE), - (self.kernel.Process32FirstW, (wintypes.HANDLE, ctypes.POINTER(ProcessEntry)), wintypes.BOOL), - (self.kernel.Process32NextW, (wintypes.HANDLE, ctypes.POINTER(ProcessEntry)), wintypes.BOOL), (self.kernel.CloseHandle, (wintypes.HANDLE,), wintypes.BOOL), (self.kernel.WaitForSingleObject, (wintypes.HANDLE, wintypes.DWORD), wintypes.DWORD), (self.kernel.TerminateProcess, (wintypes.HANDLE, wintypes.UINT), wintypes.BOOL), (self.psapi.EnumProcesses, (ctypes.POINTER(wintypes.DWORD), wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)), wintypes.BOOL), (self.user.EnumWindows, (self.callback, wintypes.LPARAM), wintypes.BOOL), + (self.user.EnumChildWindows, (wintypes.HWND, self.callback, wintypes.LPARAM), wintypes.BOOL), (self.user.GetWindowThreadProcessId, (wintypes.HWND, ctypes.POINTER(wintypes.DWORD)), wintypes.DWORD), (self.user.IsWindowVisible, (wintypes.HWND,), wintypes.BOOL), (self.user.IsWindowEnabled, (wintypes.HWND,), wintypes.BOOL), (self.user.GetDlgItem, (wintypes.HWND, ctypes.c_int), wintypes.HWND), (self.user.GetWindowTextW, (wintypes.HWND, wintypes.LPWSTR, ctypes.c_int), ctypes.c_int), + (self.user.GetClassNameW, (wintypes.HWND, wintypes.LPWSTR, ctypes.c_int), ctypes.c_int), + (self.user.GetWindowLongPtrW, (wintypes.HWND, ctypes.c_int), wintypes.LPARAM), + (self.user.SendMessageTimeoutW, (wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM, + wintypes.UINT, wintypes.UINT, ctypes.POINTER(ctypes.c_size_t)), wintypes.LPARAM), (self.user.PostMessageW, (wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM), wintypes.BOOL), ) for function, arguments, result in declarations: @@ -1170,72 +1163,55 @@ def click(self, button): if not self.user.PostMessageW(button[0], 0x111, 1, button[1]): raise self.ctypes.WinError(self.ctypes.get_last_error()) - def diagnostic(self, pid): + def static_controls(self, window, pid): + children = [] + @self.callback + def collect(control, _data): + children.append(control) + return len(children) <= 64 + # EnumChildWindows may return zero when our callback stops enumeration. + self.user.EnumChildWindows(window, collect, 0) + if len(children) > 64: + raise ValueError("Windows installer dialog exceeded its control bound") + controls = [] + for control in children: + owner = self.types.DWORD() + self.user.GetWindowThreadProcessId(control, self.ctypes.byref(owner)) + name = self.ctypes.create_unicode_buffer(128) + if owner.value != pid or not self.user.GetClassNameW(control, name, len(name)) or name.value.lower() != "static": + continue + # SS_ICON/SS_BITMAP return handles, not Unicode message text. + kind = self.user.GetWindowLongPtrW(control, -16) & 0x1F # GWL_STYLE / SS_TYPEMASK + if kind not in (0, 1, 2, 11, 12): # Text-bearing Static styles only. + continue + controls.append(control) + if len(controls) > 16: + raise ValueError("Windows installer dialog exceeded its static-text bound") + return controls + + def control_text(self, control): + text = self.ctypes.create_unicode_buffer(DIAGNOSTIC_LIMIT) + copied = self.ctypes.c_size_t() + # Unlike GetWindowText, WM_GETTEXT retrieves a control's text across + # processes. Bound the synchronous send and abort hung/exiting targets. + if not self.user.SendMessageTimeoutW(control, 0x000D, len(text), self.ctypes.addressof(text), + 0x23, 250, self.ctypes.byref(copied)): + return None + if copied.value >= len(text) - 1: + raise ValueError("Windows installer error dialog exceeded diagnostic bounds") + return text.value + + def diagnostic(self, pid, *, required_prefix=None): for window in self.windows(pid): - body, button = self.user.GetDlgItem(window, 0xFFFF), self.user.GetDlgItem(window, 1) - if not body or not button or not self.user.IsWindowVisible(window) or not self.user.IsWindowEnabled(button): + button = self.user.GetDlgItem(window, 1) + if not button or not self.user.IsWindowVisible(window) or not self.user.IsWindowEnabled(button): continue - text = self.ctypes.create_unicode_buffer(DIAGNOSTIC_LIMIT) - count = self.user.GetWindowTextW(body, text, len(text)) - if count >= len(text) - 1: + parts = [self.control_text(control) for control in self.static_controls(window, pid)] + message = "\n".join(part for part in parts if part) + if len(message) >= DIAGNOSTIC_LIMIT: raise ValueError("Windows installer error dialog exceeded diagnostic bounds") - if count: - return (window, button), text.value - return None - - def child_pids(self, parent_pid): - snapshot = self.kernel.CreateToolhelp32Snapshot(2, 0) # TH32CS_SNAPPROCESS - if snapshot == self.ctypes.c_void_p(-1).value: - raise self.ctypes.WinError(self.ctypes.get_last_error()) - try: - entry = self.process_entry() - entry.dwSize = self.ctypes.sizeof(entry) - present = self.kernel.Process32FirstW(snapshot, self.ctypes.byref(entry)) - children, count = [], 0 - while present: - count += 1 - if count > 4096: - raise ValueError("Windows installer process snapshot exceeded its bound") - if entry.th32ParentProcessID == parent_pid: - children.append(entry.th32ProcessID) - present = self.kernel.Process32NextW(snapshot, self.ctypes.byref(entry)) - if self.ctypes.get_last_error() != 18: # ERROR_NO_MORE_FILES - raise self.ctypes.WinError(self.ctypes.get_last_error()) - return children - finally: - self.kernel.CloseHandle(snapshot) - - def installer_child(self, parent_pid, temporary, expected_size, expected_hash): - # Only the known installer's direct child, inside this smoke's temp - # directory and byte-identical to its verified checker, may be operated. - for pid in self.child_pids(parent_pid): - handle = self.kernel.OpenProcess(0x1000 | 0x100000 | 1, False, pid) - if not handle: - continue - retained = False - try: - image = self.ctypes.create_unicode_buffer(32768) - size = self.types.DWORD(len(image)) - if not self.kernel.QueryFullProcessImageNameW(handle, 0, image, self.ctypes.byref(size)): - continue - path = Path(image.value) - if path.name.lower() != "maple-installer-check.exe": - continue - regular(path) - if not path.resolve(strict=True).is_relative_to(temporary.resolve(strict=True)): - raise ValueError("Owned installer checker escaped the isolated temporary directory") - if path.stat().st_size != expected_size: - raise ValueError("Owned installer checker size differs from verified bytes") - with path.open("rb") as source: - if hashlib.file_digest(source, "sha256").hexdigest() != expected_hash: - raise ValueError("Owned installer checker differs from verified bytes") - # The wrapper owns and closes this handle even if collecting - # its creation time fails. Do not close it a second time. - retained = True - return WindowsSmokeApplication(self, handle, pid) - finally: - if not retained: - self.kernel.CloseHandle(handle) + if message and (required_prefix is None or message.startswith(required_prefix)): + return (window, button), message return None def applications(self, executable): @@ -1300,12 +1276,6 @@ def alive(self): def visible(self): return any(self.ui.user.IsWindowVisible(window) for window in self.ui.windows(self.pid)) - def exit_code(self): - code = self.ui.types.DWORD() - if not self.ui.kernel.GetExitCodeProcess(self.handle, self.ui.ctypes.byref(code)): - raise self.ui.ctypes.WinError(self.ui.ctypes.get_last_error()) - return code.value - def close(self): try: if self.alive(): @@ -1358,21 +1328,18 @@ def quiet_installer_smoke(executable, *, ui=None, existing=()): time.sleep(0.05) -def interactive_installer_refusal_smoke(installer, checker, scratch, environment, bundle_id, *, ui=None): - """Exercise the real NSIS -> native error dialog -> failed NSIS path.""" +def interactive_installer_refusal_smoke(installer, scratch, environment, bundle_id, *, ui=None): + """The real NSIS failure dialog must retain the native code and refuse safely.""" import winreg ui = ui or WindowsSmokeUI() installed = scratch / "refused install" installed.mkdir() sentinel = installed / "user-created-file.txt" sentinel.write_bytes(b"preserve this unknown file") - expected_size = checker.stat().st_size - with checker.open("rb") as source: - expected_hash = hashlib.file_digest(source, "sha256").hexdigest() command = command_as_windows_args(f'"{installer}" /D={installed}') process = subprocess.Popen(command, cwd=scratch, env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - child, primary = None, None + primary = None deadline = time.monotonic() + 120 try: while time.monotonic() < deadline: @@ -1387,25 +1354,27 @@ def interactive_installer_refusal_smoke(installer, checker, scratch, environment raise ValueError("Refusal installer did not reach Install") while time.monotonic() < deadline: if process.poll() is not None: - raise ValueError("Refused installation did not display the native error") - child = child or ui.installer_child(process.pid, scratch / "tmp", expected_size, expected_hash) - diagnostic = ui.diagnostic(child.pid) if child else None + raise ValueError("Refused installation did not display its error") + diagnostic = ui.diagnostic(process.pid, required_prefix="Installation stopped while") if diagnostic: button, message = diagnostic - if (not message.startswith("Installation stopped during preflight.") or - "unknown files in Windows installation; choose an empty directory" not in message): - raise ValueError("Native installer error did not preserve its stage and actual refusal reason") + if ("Installer stage: 22" not in message or "Windows process exit code: 0x4E000003" not in message + or "did not pass validation" not in message): + raise ValueError("Installer error did not preserve its stage, native validation code and guidance") ui.click(button) break time.sleep(0.05) else: - raise ValueError("Refused installation did not reach its bounded native diagnostic") - if ui.kernel.WaitForSingleObject(child.handle, 10000) != 0 or child.exit_code() != INSTALLER_ERROR_SHOWN: - raise ValueError("Displayed installer error did not acknowledge actual native failure") + observation = {"installer_alive": process.poll() is None, + "installer_top_window_count": 0, "expected_error_dialog_present": False} + try: + observation["installer_top_window_count"] = len(ui.windows(process.pid)) + observation["expected_error_dialog_present"] = ui.diagnostic(process.pid, required_prefix="Installation stopped while") is not None + except Exception as error: + observation["observation_error"] = type(error).__name__ + print("Windows installer refusal observation: " + json.dumps(observation, sort_keys=True), file=sys.stderr) + raise ValueError("Refused installation displayed no readable installer diagnostic") while time.monotonic() < deadline: - duplicate = ui.diagnostic(process.pid) - if duplicate and duplicate[1].startswith("Installation stopped while"): - raise ValueError("NSIS displayed a duplicate generic error after the native diagnostic") button = ui.button(process.pid, "Close") if button: ui.click(button) @@ -1416,7 +1385,7 @@ def interactive_installer_refusal_smoke(installer, checker, scratch, environment else: raise ValueError("Refused installation did not finish its NSIS failure path") if process.wait(timeout=max(1, deadline - time.monotonic())) != 22: - raise ValueError("Displayed native error changed the NSIS preflight refusal outcome") + raise ValueError("Displayed installer error changed the NSIS preflight refusal outcome") if set(installed.iterdir()) != {sentinel} or sentinel.read_bytes() != b"preserve this unknown file": raise ValueError("Refused installation changed its existing destination") if any(next((scratch / name).iterdir(), None) is not None for name in ("config", "data", "cache")): @@ -1427,31 +1396,20 @@ def interactive_installer_refusal_smoke(installer, checker, scratch, environment raise ValueError("Refused interactive installation changed registration") except FileNotFoundError: pass + print("Verified Windows installer validation error dialog, exact native status, refusal and unchanged destination/profile/registration") except BaseException as error: primary = error raise finally: - cleanup_error = None - if child is None and process.poll() is None: - try: - child = ui.installer_child(process.pid, scratch / "tmp", expected_size, expected_hash) - except Exception as error: - cleanup_error = error try: if process.poll() is None: process.terminate() process.wait(timeout=10) except Exception as error: - cleanup_error = cleanup_error or error - try: - if child: - child.close() - except Exception as error: - cleanup_error = cleanup_error or error - if cleanup_error: - print("Windows refusal smoke cleanup failure: " + type(cleanup_error).__name__, file=sys.stderr) + print("Windows refusal smoke cleanup failure: " + type(error).__name__, file=sys.stderr) if primary is None: - raise cleanup_error + raise + def interactive_installer_smoke(installer, installed, executable, scratch, environment, *, ui=None, existing=(), before_close=None, integration_ready=None): From 7313b1045f8d2c2ed69205cd88b0e8f585aae2da Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:16:16 +0000 Subject: [PATCH 3/5] Windows: keep diagnostics scoped to the existing installer checks --- .../scripts/test_windows_release.py | 151 ------------------ apps/maple-agent/scripts/windows-release.py | 143 ----------------- 2 files changed, 294 deletions(-) diff --git a/apps/maple-agent/scripts/test_windows_release.py b/apps/maple-agent/scripts/test_windows_release.py index 2e2b2dd85..9b7b5be40 100644 --- a/apps/maple-agent/scripts/test_windows_release.py +++ b/apps/maple-agent/scripts/test_windows_release.py @@ -814,151 +814,6 @@ def test_owned_gui_exit_race_is_accepted_only_when_the_same_handle_has_exited(se ui.kernel.CloseHandle.assert_called_once_with(987) -class InteractiveRefusalSmokeTests(unittest.TestCase): - def execute(self, *, message=None, installer_code=22, mutation=None, no_dialog=False): - with tempfile.TemporaryDirectory() as temporary: - scratch = Path(temporary) - for name in ("config", "data", "cache", "tmp"): - (scratch / name).mkdir() - process = Mock(pid=123, returncode=None) - process.poll.side_effect = lambda: process.returncode - process.wait.side_effect = lambda timeout: process.returncode if process.returncode is not None else (_ for _ in ()).throw(subprocess.TimeoutExpired("owned installer", timeout)) - process.terminate.side_effect = lambda: setattr(process, "returncode", -1) - ui = Mock() - ui.windows.return_value = [100] - state = {"installed": False, "dismissed": False} - body = message or ("Installation stopped while checking the installation.\nInstaller stage: 22\n" - "Windows process exit code: 0x4E000003\nThe installation folder or application files did not pass validation.") - def button(pid, caption): - if caption == "Install" and not state["installed"] or caption == "Close" and state["dismissed"]: - return pid, caption - return None - def diagnostic(pid, **_options): - self.assertEqual(pid, process.pid) - if state["installed"] and not state["dismissed"] and not no_dialog: - return (pid, "OK"), body - return None - def click(selected): - if selected == (process.pid, "Install"): - state["installed"] = True - elif selected == (process.pid, "OK"): - state["dismissed"] = True - if mutation: - mutation(scratch) - elif selected == (process.pid, "Close"): - process.returncode = installer_code - ui.button.side_effect, ui.diagnostic.side_effect, ui.click.side_effect = button, diagnostic, click - clock = InteractiveInstallerSmokeTests.Clock() - def missing(*_arguments): - raise FileNotFoundError - registry = types.SimpleNamespace(KEY_READ=1, KEY_WOW64_32KEY=2, HKEY_CURRENT_USER=3, OpenKey=missing) - self.process, self.ui = process, ui - with patch.dict(sys.modules, winreg=registry), patch.object(release.subprocess, "Popen", return_value=process) as popen, patch.object(release.time, "monotonic", clock.monotonic), patch.object(release.time, "sleep", clock.sleep): - release.interactive_installer_refusal_smoke(Path("C:/verified setup.exe"), scratch, {"FIXTURE": "sanitized"}, "fixture.bundle", ui=ui) - return popen.call_args, ui - - def test_real_path_driver_requires_actual_error_then_failed_installer_without_mutation(self): - call, ui = self.execute() - self.assertIn('"C:/verified setup.exe" /D=', call.args[0]) - self.assertNotIn("/S", call.args[0]) - self.assertEqual(ui.click.call_args_list, [unittest.mock.call((123, "Install")), unittest.mock.call((123, "OK")), unittest.mock.call((123, "Close"))]) - self.process.terminate.assert_not_called() - - def test_wrong_stage_or_native_code_or_missing_guidance_cannot_pass(self): - for changed in ("Installer stage: 24; Windows process exit code: 0x4E000003; did not pass validation", - "Installer stage: 22; Windows process exit code: 0x00000001; did not pass validation", - "Installer stage: 22; Windows process exit code: 0x4E000003; unrelated advice"): - with self.subTest(message=changed), self.assertRaisesRegex(ValueError, "stage, native validation code and guidance"): - self.execute(message=changed) - self.process.terminate.assert_called_once() - - def test_displayed_message_does_not_bless_a_success_status(self): - with self.assertRaisesRegex(ValueError, "changed the NSIS preflight refusal"): - self.execute(installer_code=0) - - def test_missing_dialog_reports_bounded_observer_state_and_stops_owned_installer(self): - output = io.StringIO() - with patch.object(sys, "stderr", output), self.assertRaisesRegex(ValueError, "no readable installer diagnostic"): - self.execute(no_dialog=True) - self.process.terminate.assert_called_once() - details = json.loads(output.getvalue().split("Windows installer refusal observation: ", 1)[1]) - self.assertEqual(details, {"installer_alive": True, "installer_top_window_count": 1, - "expected_error_dialog_present": False}) - self.assertNotIn("window text", output.getvalue()) - - def test_failed_installation_must_preserve_unknown_file_and_profile_state(self): - for mutation, expected in ( - (lambda scratch: (scratch / "refused install/user-created-file.txt").write_bytes(b"changed"), "existing destination"), - (lambda scratch: (scratch / "data/unexpected.txt").write_bytes(b"changed"), "profile state"), - ): - with self.subTest(expected=expected), self.assertRaisesRegex(ValueError, expected): - self.execute(mutation=mutation) - - -class InstallerDialogObservationTests(unittest.TestCase): - def driver(self, *, controls=None, unresponsive=False, oversized=False): - ui = object.__new__(release.WindowsSmokeUI) - ui.ctypes, ui.types, ui.callback = ctypes, types.SimpleNamespace(DWORD=ctypes.c_uint32), lambda function: function - ui.windows = Mock(return_value=[100]) - controls = controls or {100: [11, 12, 13, 14]} - body = "Installation stopped while checking the installation.\nInstaller stage: 22\nWindows process exit code: 0x4E000003" - def enumerate_children(window, callback, data): - for control in controls[window]: - if not callback(control, data): - return 0 - return 1 - def owner(control, output): - ctypes.cast(output, ctypes.POINTER(ctypes.c_uint32)).contents.value = 999 if control == 13 else 123 - return 1 - def class_name(control, output, maximum): - output.value = "Button" if control == 12 else "Static" - return len(output.value) - def text(control, message, maximum, address, flags, timeout, copied): - self.assertEqual((message, maximum, flags, timeout), (0x000D, release.DIAGNOSTIC_LIMIT, 0x23, 250)) - if unresponsive: - return 0 - value = "Normal installer page" if control == 15 else body - ctypes.cast(address, ctypes.POINTER(ctypes.c_wchar * maximum)).contents.value = value - ctypes.cast(copied, ctypes.POINTER(ctypes.c_size_t)).contents.value = maximum - 1 if oversized else len(value) - return 1 - ui.user = types.SimpleNamespace(EnumChildWindows=Mock(side_effect=enumerate_children), - GetWindowThreadProcessId=Mock(side_effect=owner), GetClassNameW=Mock(side_effect=class_name), - GetWindowLongPtrW=Mock(side_effect=lambda control, _index: 3 if control == 11 else 0), - SendMessageTimeoutW=Mock(side_effect=text), GetWindowTextW=Mock(side_effect=AssertionError("cross-process text must use WM_GETTEXT")), - GetDlgItem=Mock(return_value=999), IsWindowVisible=Mock(return_value=True), IsWindowEnabled=Mock(return_value=True)) - return ui, body - - def test_reads_only_owned_text_static_controls_without_assuming_the_body_id(self): - ui, body = self.driver() - self.assertEqual(ui.diagnostic(123), ((100, 999), body)) - self.assertEqual(ui.user.GetDlgItem.call_args_list, [unittest.mock.call(100, 1)]) - self.assertEqual(ui.user.SendMessageTimeoutW.call_count, 1) - self.assertEqual(ui.user.SendMessageTimeoutW.call_args.args[0], 14) - ui.user.GetWindowTextW.assert_not_called() - - def test_unresponsive_control_is_bounded_and_is_not_a_readable_dialog(self): - ui, _ = self.driver(unresponsive=True) - self.assertIsNone(ui.diagnostic(123)) - self.assertEqual(ui.user.SendMessageTimeoutW.call_args.args[5], 250) - - def test_truncated_native_message_cannot_pass_as_complete_error_evidence(self): - ui, _ = self.driver(oversized=True) - with self.assertRaisesRegex(ValueError, "diagnostic bounds"): - ui.diagnostic(123) - - def test_enumeration_stops_before_reading_an_unbounded_control_tree(self): - ui, _ = self.driver(controls={100: list(range(1000, 1100))}) - with self.assertRaisesRegex(ValueError, "control bound"): - ui.diagnostic(123) - ui.user.GetClassNameW.assert_not_called() - ui.user.SendMessageTimeoutW.assert_not_called() - - def test_expected_error_dialog_is_selected_instead_of_the_installer_page(self): - ui, body = self.driver(controls={100: [15], 200: [14]}) - ui.windows.return_value = [100, 200] - self.assertEqual(ui.diagnostic(123, required_prefix="Installation stopped while"), ((200, 999), body)) - - class NativeShortcutProbeTests(unittest.TestCase): def native(self, metadata): native = object.__new__(release.WindowsShortcutCOM) @@ -1407,11 +1262,6 @@ def test_actual_repeat_and_identity_assertions_are_the_only_pinned_smoke_operati class SmokeDiagnosticsTests(unittest.TestCase): - def setUp(self): - # These cases isolate preservation of an existing primary failure. The - # real NSIS/native interactive refusal is covered by its own smoke. - self.refusal = self.enterContext(patch.object(release, "interactive_installer_refusal_smoke")) - def registry(self): def missing(*_arguments): raise FileNotFoundError @@ -1571,7 +1421,6 @@ def test_failed_readonly_preflight_never_runs_the_installer_or_prepare(self): with self.assertRaises(subprocess.CalledProcessError): release.smoke_installer(Path("fixture.exe"), info(), False, "12", {}, checker=Path("verified-checker.exe")) commands.assert_not_called() - self.refusal.assert_not_called() self.assertIn("fixed native guard failure", diagnostics.getvalue()) self.assertNotIn("\x1b", diagnostics.getvalue()) self.assertNotIn("\x00", diagnostics.getvalue()) diff --git a/apps/maple-agent/scripts/windows-release.py b/apps/maple-agent/scripts/windows-release.py index 3acf195a0..929f2dcef 100644 --- a/apps/maple-agent/scripts/windows-release.py +++ b/apps/maple-agent/scripts/windows-release.py @@ -971,9 +971,6 @@ def smoke_installer(installer, info, unsigned, build, expected_hashes, *, checke raise subprocess.CalledProcessError(preflight.returncode, preflight.args) if unsigned and info["profile"] == "prod" and preflight.returncode == 0: raise ValueError("Unsigned Prod checker did not refuse read-only preflight") - if not (unsigned and info["profile"] == "prod"): - phase = "interactive-refusal" - interactive_installer_refusal_smoke(installer, scratch, environment, info["bundle_id"]) phase = "install" if unsigned and info["profile"] == "prod": # Keep the actual NSIS refusal proof in addition to the direct @@ -1120,16 +1117,11 @@ def __init__(self): (self.kernel.TerminateProcess, (wintypes.HANDLE, wintypes.UINT), wintypes.BOOL), (self.psapi.EnumProcesses, (ctypes.POINTER(wintypes.DWORD), wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)), wintypes.BOOL), (self.user.EnumWindows, (self.callback, wintypes.LPARAM), wintypes.BOOL), - (self.user.EnumChildWindows, (wintypes.HWND, self.callback, wintypes.LPARAM), wintypes.BOOL), (self.user.GetWindowThreadProcessId, (wintypes.HWND, ctypes.POINTER(wintypes.DWORD)), wintypes.DWORD), (self.user.IsWindowVisible, (wintypes.HWND,), wintypes.BOOL), (self.user.IsWindowEnabled, (wintypes.HWND,), wintypes.BOOL), (self.user.GetDlgItem, (wintypes.HWND, ctypes.c_int), wintypes.HWND), (self.user.GetWindowTextW, (wintypes.HWND, wintypes.LPWSTR, ctypes.c_int), ctypes.c_int), - (self.user.GetClassNameW, (wintypes.HWND, wintypes.LPWSTR, ctypes.c_int), ctypes.c_int), - (self.user.GetWindowLongPtrW, (wintypes.HWND, ctypes.c_int), wintypes.LPARAM), - (self.user.SendMessageTimeoutW, (wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM, - wintypes.UINT, wintypes.UINT, ctypes.POINTER(ctypes.c_size_t)), wintypes.LPARAM), (self.user.PostMessageW, (wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM), wintypes.BOOL), ) for function, arguments, result in declarations: @@ -1163,57 +1155,6 @@ def click(self, button): if not self.user.PostMessageW(button[0], 0x111, 1, button[1]): raise self.ctypes.WinError(self.ctypes.get_last_error()) - def static_controls(self, window, pid): - children = [] - @self.callback - def collect(control, _data): - children.append(control) - return len(children) <= 64 - # EnumChildWindows may return zero when our callback stops enumeration. - self.user.EnumChildWindows(window, collect, 0) - if len(children) > 64: - raise ValueError("Windows installer dialog exceeded its control bound") - controls = [] - for control in children: - owner = self.types.DWORD() - self.user.GetWindowThreadProcessId(control, self.ctypes.byref(owner)) - name = self.ctypes.create_unicode_buffer(128) - if owner.value != pid or not self.user.GetClassNameW(control, name, len(name)) or name.value.lower() != "static": - continue - # SS_ICON/SS_BITMAP return handles, not Unicode message text. - kind = self.user.GetWindowLongPtrW(control, -16) & 0x1F # GWL_STYLE / SS_TYPEMASK - if kind not in (0, 1, 2, 11, 12): # Text-bearing Static styles only. - continue - controls.append(control) - if len(controls) > 16: - raise ValueError("Windows installer dialog exceeded its static-text bound") - return controls - - def control_text(self, control): - text = self.ctypes.create_unicode_buffer(DIAGNOSTIC_LIMIT) - copied = self.ctypes.c_size_t() - # Unlike GetWindowText, WM_GETTEXT retrieves a control's text across - # processes. Bound the synchronous send and abort hung/exiting targets. - if not self.user.SendMessageTimeoutW(control, 0x000D, len(text), self.ctypes.addressof(text), - 0x23, 250, self.ctypes.byref(copied)): - return None - if copied.value >= len(text) - 1: - raise ValueError("Windows installer error dialog exceeded diagnostic bounds") - return text.value - - def diagnostic(self, pid, *, required_prefix=None): - for window in self.windows(pid): - button = self.user.GetDlgItem(window, 1) - if not button or not self.user.IsWindowVisible(window) or not self.user.IsWindowEnabled(button): - continue - parts = [self.control_text(control) for control in self.static_controls(window, pid)] - message = "\n".join(part for part in parts if part) - if len(message) >= DIAGNOSTIC_LIMIT: - raise ValueError("Windows installer error dialog exceeded diagnostic bounds") - if message and (required_prefix is None or message.startswith(required_prefix)): - return (window, button), message - return None - def applications(self, executable): pids = (self.types.DWORD * 4096)() used = self.types.DWORD() @@ -1328,90 +1269,6 @@ def quiet_installer_smoke(executable, *, ui=None, existing=()): time.sleep(0.05) -def interactive_installer_refusal_smoke(installer, scratch, environment, bundle_id, *, ui=None): - """The real NSIS failure dialog must retain the native code and refuse safely.""" - import winreg - ui = ui or WindowsSmokeUI() - installed = scratch / "refused install" - installed.mkdir() - sentinel = installed / "user-created-file.txt" - sentinel.write_bytes(b"preserve this unknown file") - command = command_as_windows_args(f'"{installer}" /D={installed}') - process = subprocess.Popen(command, cwd=scratch, env=environment, stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - primary = None - deadline = time.monotonic() + 120 - try: - while time.monotonic() < deadline: - if process.poll() is not None: - raise ValueError("Refusal installer exited before its Install action") - button = ui.button(process.pid, "Install") - if button: - ui.click(button) - break - time.sleep(0.05) - else: - raise ValueError("Refusal installer did not reach Install") - while time.monotonic() < deadline: - if process.poll() is not None: - raise ValueError("Refused installation did not display its error") - diagnostic = ui.diagnostic(process.pid, required_prefix="Installation stopped while") - if diagnostic: - button, message = diagnostic - if ("Installer stage: 22" not in message or "Windows process exit code: 0x4E000003" not in message - or "did not pass validation" not in message): - raise ValueError("Installer error did not preserve its stage, native validation code and guidance") - ui.click(button) - break - time.sleep(0.05) - else: - observation = {"installer_alive": process.poll() is None, - "installer_top_window_count": 0, "expected_error_dialog_present": False} - try: - observation["installer_top_window_count"] = len(ui.windows(process.pid)) - observation["expected_error_dialog_present"] = ui.diagnostic(process.pid, required_prefix="Installation stopped while") is not None - except Exception as error: - observation["observation_error"] = type(error).__name__ - print("Windows installer refusal observation: " + json.dumps(observation, sort_keys=True), file=sys.stderr) - raise ValueError("Refused installation displayed no readable installer diagnostic") - while time.monotonic() < deadline: - button = ui.button(process.pid, "Close") - if button: - ui.click(button) - break - if process.poll() is not None: - break - time.sleep(0.05) - else: - raise ValueError("Refused installation did not finish its NSIS failure path") - if process.wait(timeout=max(1, deadline - time.monotonic())) != 22: - raise ValueError("Displayed installer error changed the NSIS preflight refusal outcome") - if set(installed.iterdir()) != {sentinel} or sentinel.read_bytes() != b"preserve this unknown file": - raise ValueError("Refused installation changed its existing destination") - if any(next((scratch / name).iterdir(), None) is not None for name in ("config", "data", "cache")): - raise ValueError("Refused native installation opened application profile state") - try: - key = "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\" + bundle_id - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_READ | winreg.KEY_WOW64_32KEY): - raise ValueError("Refused interactive installation changed registration") - except FileNotFoundError: - pass - print("Verified Windows installer validation error dialog, exact native status, refusal and unchanged destination/profile/registration") - except BaseException as error: - primary = error - raise - finally: - try: - if process.poll() is None: - process.terminate() - process.wait(timeout=10) - except Exception as error: - print("Windows refusal smoke cleanup failure: " + type(error).__name__, file=sys.stderr) - if primary is None: - raise - - - def interactive_installer_smoke(installer, installed, executable, scratch, environment, *, ui=None, existing=(), before_close=None, integration_ready=None): ui = ui or WindowsSmokeUI() command = command_as_windows_args(f'"{installer}" /D={installed}') From 157d0a19ee5e4170442bf3afa6ace9b55f2022de Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:19:00 +0000 Subject: [PATCH 4/5] Windows: ensure installer metadata cannot overwrite the child status --- apps/maple-agent/scripts/test_windows_release.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/maple-agent/scripts/test_windows_release.py b/apps/maple-agent/scripts/test_windows_release.py index 9b7b5be40..ac428c309 100644 --- a/apps/maple-agent/scripts/test_windows_release.py +++ b/apps/maple-agent/scripts/test_windows_release.py @@ -558,9 +558,10 @@ def test_nsis_helper_launch_errors_cannot_reuse_a_previous_success_exit_code(sel compare = next(index for index, line in enumerate(following) if line.startswith('StrCmp ')) metadata = following[:compare] self.assertTrue(all(line.startswith('StrCpy ') for line in metadata)) + output = command.rsplit(' ', 1)[1] + self.assertNotIn(output, [line.split(maxsplit=2)[1] for line in metadata]) self.assertEqual([line for line in metadata if line.startswith('StrCpy $FailureCode ')], [next(line for line in metadata if line in ['StrCpy $FailureCode ' + str(code) for code in child])]) - output = command.rsplit(' ', 1)[1] self.assertEqual(following[compare], 'StrCmp ' + output + ' 0 0 ' + destination) def test_nsis_exit_codes_identify_probe_phase_and_launch_vs_child_failures(self): From 525d4c832e987ae11af8d930523bf2f8f2a9cd74 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:26:37 +0000 Subject: [PATCH 5/5] Windows: share installer errors with the standalone signature probe --- .../maple-agent-updater/examples/windows_signature_probe.rs | 5 +++++ .../src/installer/windows_bundle/authenticode.rs | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/maple-agent/crates/maple-agent-updater/examples/windows_signature_probe.rs b/apps/maple-agent/crates/maple-agent-updater/examples/windows_signature_probe.rs index a8bac7c35..87ad9770e 100644 --- a/apps/maple-agent/crates/maple-agent-updater/examples/windows_signature_probe.rs +++ b/apps/maple-agent/crates/maple-agent-updater/examples/windows_signature_probe.rs @@ -1,6 +1,11 @@ //! Manual, read-only historical Authenticode interoperability probe. The selected //! executable is never launched; this is not installed-update qualification. +#[cfg(windows)] +use maple_agent_updater::installer::InstallError; +#[cfg(windows)] +type Result = std::result::Result; + #[cfg(windows)] #[allow(dead_code)] #[path = "../src/installer/windows_bundle/authenticode.rs"] diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs index e4e449b55..44a887d4e 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs @@ -40,7 +40,7 @@ use windows_sys::Win32::{ }, }; -use super::super::{InstallError, Result}; +use super::{InstallError, Result}; const EXPECTED_PUBLISHER: &str = "Mutiny Wallet Inc. dba OpenSecret"; const MAX_NAME_UNITS: u32 = 512;