From 4c810040c51e00397539da7057dcc5e0f32cf6fb Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:58:32 +0000 Subject: [PATCH 1/2] Diagnose Windows repeat activation and isolate native fixtures --- apps/maple-agent/app/src/main.rs | 8 +- .../maple-agent-updater/src/installer.rs | 132 ++++++++++++ .../src/installer/windows_bundle.rs | 200 +++++++++++++----- .../installer/windows_bundle/windows_fs.rs | 125 ++++++++++- .../scripts/test_windows_release.py | 74 +++++++ .../maple-agent/scripts/windows-installer.nsi | 12 ++ apps/maple-agent/scripts/windows-release.py | 27 ++- scripts/ci/test_windows_updater_workflow.py | 7 + scripts/ci/windows-updater-tests.ps1 | 2 + 9 files changed, 522 insertions(+), 65 deletions(-) diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index 541d110fd..8b4ff0356 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -271,6 +271,7 @@ fn main() { // The stable launcher and installer modes never open application data, // 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 internal = if let Some(root) = &cli.internal_windows_installer_preflight { Some( std::env::current_exe() @@ -313,7 +314,10 @@ fn main() { profile::PROFILE, generation, ) - .map_err(|error| error.to_string()) + .map_err(|error| { + internal_exit_code = error.windows_activation_exit_code().unwrap_or(1); + error.to_string() + }) }), ) } else if cli.internal_windows_uninstall_cleanup { @@ -337,7 +341,7 @@ fn main() { if let Some(result) = internal { if let Err(error) = result { eprintln!("{error}"); - std::process::exit(1); + std::process::exit(internal_exit_code); } return; } 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 2d06783e0..4c074adbf 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs @@ -60,6 +60,39 @@ const MAX_EXTENSION_BYTES: u64 = 16 * 1024; #[cfg(any(target_os = "macos", all(test, unix)))] const CODESIGN_REQUIREMENT: &str = "=anchor apple generic and identifier \"cloud.opensecret.maple.agent\" and certificate leaf[subject.OU] = \"X773Y823TN\" and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists"; +/// Fixed public operation labels for the native Windows installer child. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum WindowsActivationStage { + Input = 1, + RootStorage = 2, + Registration = 3, + BootstrapIdentity = 4, + InstallLock = 5, + IntentIdentity = 6, + GenerationIdentity = 7, + GenerationVerification = 8, + GenerationGuards = 9, + PointerCommit = 10, +} + +impl std::fmt::Display for WindowsActivationStage { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Input => "activation-input", + Self::RootStorage => "root-storage", + Self::Registration => "registration", + Self::BootstrapIdentity => "bootstrap-identity", + Self::InstallLock => "install-lock", + Self::IntentIdentity => "intent-identity", + Self::GenerationIdentity => "generation-identity", + Self::GenerationVerification => "generation-verification", + Self::GenerationGuards => "generation-guards", + Self::PointerCommit => "pointer-commit", + }) + } +} + #[derive(Debug, Error)] pub enum InstallError { #[error("This installation needs a manual download: {0}")] @@ -72,6 +105,12 @@ pub enum InstallError { CodeSignature, #[error("The update could not be installed: {0}")] Io(#[from] io::Error), + #[error("Windows activation failed during {stage}: {source}")] + WindowsActivation { + stage: WindowsActivationStage, + #[source] + source: Box, + }, } impl InstallError { @@ -81,13 +120,106 @@ impl InstallError { match self { Self::NotifyOnly(_) => true, Self::Io(error) => permission_error(error), + Self::WindowsActivation { source, .. } => source.requires_manual_installation(), _ => false, } } + + /// 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 { + let Self::WindowsActivation { stage, source } = self else { + return None; + }; + let raw = match source.as_ref() { + Self::Io(error) => error + .raw_os_error() + .and_then(|code| u16::try_from(code).ok()) + .unwrap_or(0), + _ => 0, + }; + Some(0x4D00_0000 | ((*stage as i32) << 16) | i32::from(raw)) + } } type Result = std::result::Result; +#[cfg(test)] +mod windows_activation_error_tests { + use super::*; + + #[test] + fn activation_status_preserves_fixed_stage_and_original_native_error() { + for (stage, number, label) in [ + (WindowsActivationStage::Input, 1, "activation-input"), + (WindowsActivationStage::RootStorage, 2, "root-storage"), + (WindowsActivationStage::Registration, 3, "registration"), + ( + WindowsActivationStage::BootstrapIdentity, + 4, + "bootstrap-identity", + ), + (WindowsActivationStage::InstallLock, 5, "install-lock"), + (WindowsActivationStage::IntentIdentity, 6, "intent-identity"), + ( + WindowsActivationStage::GenerationIdentity, + 7, + "generation-identity", + ), + ( + WindowsActivationStage::GenerationVerification, + 8, + "generation-verification", + ), + ( + WindowsActivationStage::GenerationGuards, + 9, + "generation-guards", + ), + (WindowsActivationStage::PointerCommit, 10, "pointer-commit"), + ] { + let error = InstallError::WindowsActivation { + stage, + source: Box::new(InstallError::Io(io::Error::from_raw_os_error(32))), + }; + assert_eq!( + error.windows_activation_exit_code(), + Some(0x4D00_0020 | (number << 16)) + ); + assert!(error.to_string().contains(label)); + let InstallError::WindowsActivation { source, .. } = error else { + unreachable!() + }; + let InstallError::Io(source) = *source else { + unreachable!() + }; + assert_eq!(source.raw_os_error(), Some(32)); + } + } + + #[test] + fn activation_status_keeps_error_policy_and_never_encodes_unrelated_errors() { + for source in [ + InstallError::Busy, + InstallError::CodeSignature, + InstallError::Invalid("public fixture"), + InstallError::NotifyOnly("public fixture"), + InstallError::Io(io::Error::from(io::ErrorKind::PermissionDenied)), + InstallError::Io(io::Error::from_raw_os_error(-1)), + InstallError::Io(io::Error::from_raw_os_error(65536)), + ] { + assert_eq!(source.windows_activation_exit_code(), None); + let manual = source.requires_manual_installation(); + let error = InstallError::WindowsActivation { + stage: WindowsActivationStage::Input, + source: Box::new(source), + }; + assert_eq!(error.requires_manual_installation(), manual); + assert_eq!(error.windows_activation_exit_code(), Some(0x4D01_0000)); + } + } +} + /// The on-disk values are untrusted on restoration. Keep the authenticated /// `PreparedUpdate` separately and pass it to every revalidation/replacement. #[derive(Clone, Debug, Serialize, Deserialize)] 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 6fea1c344..87836c6d2 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 @@ -1825,66 +1825,82 @@ pub fn activate_windows_installation( ) -> Result<()> { #[cfg(windows)] { - if !valid_generation(generation_name) { - return Err(InstallError::Invalid("Windows activation generation")); - } - let executable = executable.canonicalize()?; - let root = executable - .parent() - .ok_or(InstallError::Invalid("Windows activation root"))?; - if executable - .file_name() - .is_none_or(|x| x != "maple-agent.exe") - { - return Err(InstallError::Invalid("Windows activation executable")); - } - windows_fs::private_directory(root)?; - registered_root(root, profile)?; - let _ = trusted_installer(&executable, profile)?; - let lock = open_install_lock(root)?; - lock.try_lock_exclusive().map_err(|error| { - if error.kind() == io::ErrorKind::WouldBlock - || error.raw_os_error() == fs2::lock_contended_error().raw_os_error() + let mut stage = WindowsActivationStage::Input; + let result = (|| -> Result<()> { + if !valid_generation(generation_name) { + return Err(InstallError::Invalid("Windows activation generation")); + } + let executable = executable.canonicalize()?; + let root = executable + .parent() + .ok_or(InstallError::Invalid("Windows activation root"))?; + if executable + .file_name() + .is_none_or(|x| x != "maple-agent.exe") { - InstallError::Busy - } else { - error.into() + return Err(InstallError::Invalid("Windows activation executable")); } - })?; - let intent: ManualIntent = - serde_json::from_slice(&read_bounded(&root.join(MANUAL_INTENT), DESCRIPTOR_LIMIT)?) - .map_err(|_| InstallError::Invalid("Windows installer intent"))?; - if intent.format != "maple-agent-windows-installer-intent-v1" - || intent.profile != profile - || intent.generation != generation_name - || (intent.owner_pid, intent.owner_created) != installer_owner()? - { - return Err(InstallError::Invalid("Windows installer intent identity")); - } - let path = root.join("versions").join(generation_name); - let bundle = descriptor(&path, profile)?; - if bundle.generation() != generation_name - || bundle.version != intent.version - || bundle.source_sha != intent.source_sha - || exact_file_digest(&path.join("maple-agent.exe"))? != intent.executable_sha256 - { - return Err(InstallError::Invalid( - "Windows installer activation identity", - )); - } - let _ = scan_generation(&path)?; - verify_identity(&path, &bundle)?; - let _generation_guards = generation_write_guards(root)?; - write_private_json(&root.join(POINTER), &Current::new(&bundle))?; - drop(_generation_guards); - // Publication committed. Cleanup cannot report an uncommitted failure - // to NSIS, which would otherwise roll back valid registration. Startup - // authenticates this exact generation and retries retained intent. - if retire_abandoned_uninstall(root, profile, &bundle.generation()).is_ok() { - let _ = fs::remove_file(root.join(MANUAL_INTENT)); - } - let _ = windows_sync_directory(root); - Ok(()) + stage = WindowsActivationStage::RootStorage; + windows_fs::private_directory(root)?; + stage = WindowsActivationStage::Registration; + registered_root(root, profile)?; + stage = WindowsActivationStage::BootstrapIdentity; + let _ = trusted_installer(&executable, profile)?; + stage = WindowsActivationStage::InstallLock; + let lock = open_install_lock(root)?; + lock.try_lock_exclusive().map_err(|error| { + if error.kind() == io::ErrorKind::WouldBlock + || error.raw_os_error() == fs2::lock_contended_error().raw_os_error() + { + InstallError::Busy + } else { + error.into() + } + })?; + stage = WindowsActivationStage::IntentIdentity; + let intent: ManualIntent = + serde_json::from_slice(&read_bounded(&root.join(MANUAL_INTENT), DESCRIPTOR_LIMIT)?) + .map_err(|_| InstallError::Invalid("Windows installer intent"))?; + if intent.format != "maple-agent-windows-installer-intent-v1" + || intent.profile != profile + || intent.generation != generation_name + || (intent.owner_pid, intent.owner_created) != installer_owner()? + { + return Err(InstallError::Invalid("Windows installer intent identity")); + } + stage = WindowsActivationStage::GenerationIdentity; + let path = root.join("versions").join(generation_name); + let bundle = descriptor(&path, profile)?; + if bundle.generation() != generation_name + || bundle.version != intent.version + || bundle.source_sha != intent.source_sha + || exact_file_digest(&path.join("maple-agent.exe"))? != intent.executable_sha256 + { + return Err(InstallError::Invalid( + "Windows installer activation identity", + )); + } + stage = WindowsActivationStage::GenerationVerification; + let _ = scan_generation(&path)?; + verify_identity(&path, &bundle)?; + stage = WindowsActivationStage::GenerationGuards; + let _generation_guards = generation_write_guards(root)?; + stage = WindowsActivationStage::PointerCommit; + write_private_json(&root.join(POINTER), &Current::new(&bundle))?; + drop(_generation_guards); + // Publication committed. Cleanup cannot report an uncommitted failure + // to NSIS, which would otherwise roll back valid registration. Startup + // authenticates this exact generation and retries retained intent. + if retire_abandoned_uninstall(root, profile, &bundle.generation()).is_ok() { + let _ = fs::remove_file(root.join(MANUAL_INTENT)); + } + let _ = windows_sync_directory(root); + Ok(()) + })(); + result.map_err(|source| InstallError::WindowsActivation { + stage, + source: Box::new(source), + }) } #[cfg(not(windows))] { @@ -2054,3 +2070,71 @@ mod tests; #[cfg(all(test, windows))] #[path = "windows_bundle_transaction_tests.rs"] mod transaction_tests; + +#[cfg(all(test, windows))] +mod manual_activation_tests { + use super::*; + + fn generation_fixture(root: &Path, version: &str, build: &str) -> Bundle { + let bundle = Bundle { + format: "maple-agent-windows-bundle-v1".into(), + product: "maple-agent".into(), + profile: "dev".into(), + version: version.into(), + build: build.into(), + source_sha: "a".repeat(40), + bundle_id: profile_identity("dev").unwrap().into(), + entrypoint: "maple-agent.exe".into(), + }; + let path = root.join("versions").join(bundle.generation()); + for directory in DIRS { + windows_fs::private_directory(&path.join(directory)).unwrap(); + } + for name in FILES { + let bytes = if *name == DESCRIPTOR { + serde_json::to_vec(&bundle).unwrap() + } else { + format!("ordinary manual-activation fixture {version}/{build}: {name}\n") + .into_bytes() + }; + let mut file = windows_fs::open_private(&path.join(name), true).unwrap(); + file.write_all(&bytes).unwrap(); + file.sync_all().unwrap(); + } + bundle + } + + #[test] + fn native_manual_pointer_republication_with_generation_guards_preserves_payloads() { + // Exercise the filesystem publication phase only. These ordinary data + // files claim no executable, Authenticode or registered-install proof. + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("installation"); + windows_fs::private_directory(&root.join("versions")).unwrap(); + let previous = generation_fixture(&root, "0.0.11", "738"); + let next = generation_fixture(&root, "0.0.12", "739"); + let previous_path = root.join("versions").join(previous.generation()); + let next_path = root.join("versions").join(next.generation()); + let previous_nodes = scan_generation(&previous_path).unwrap(); + let next_nodes = scan_generation(&next_path).unwrap(); + write_private_json(&root.join(POINTER), &Current::new(&previous)).unwrap(); + + for bundle in [&previous, &next, &next] { + let guards = generation_write_guards(&root).unwrap(); + assert_eq!(guards.len(), 2); + for path in [&previous_path, &next_path] { + assert!(windows_fs::open_readonly(&path.join("maple-agent.exe")).is_err()); + } + // First repeat the already-selected generation, then publish and + // repeat the next one using the same guards as manual activation. + write_private_json(&root.join(POINTER), &Current::new(bundle)).unwrap(); + drop(guards); + assert_eq!( + Current::parse(&read_bounded(&root.join(POINTER), 1024).unwrap()).unwrap(), + Current::new(bundle) + ); + assert_eq!(scan_generation(&previous_path).unwrap(), previous_nodes); + assert_eq!(scan_generation(&next_path).unwrap(), next_nodes); + } + } +} diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/windows_fs.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/windows_fs.rs index 9967edc4a..b4e7ccfc1 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/windows_fs.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/windows_fs.rs @@ -1046,14 +1046,49 @@ mod tests { #[test] fn native_shared_read_validation_accepts_mapped_image_and_mutation_guard() { - let mapped = open_shared_readonly(&std::env::current_exe().unwrap()) - .expect("shared read validation must admit the mapped native test image"); - assert!(mapped.metadata().unwrap().len() > 0); - drop(mapped); + const PRIVATE_IMAGE: &str = "maple-updater-mapped-fixture.exe"; + const MAPPED_PROOF: &str = "private mapped image shared-read verified"; + const TEST: &str = "installer::windows_bundle::windows_fs::tests::native_shared_read_validation_accepts_mapped_image_and_mutation_guard"; + let current = std::env::current_exe().unwrap(); + if current.file_name() == Some(OsStr::new(PRIVATE_IMAGE)) { + // Re-executing this exact test keeps the private copy mapped while + // the production validator opens and reads its executable image. + let mut mapped = open_shared_readonly(¤t) + .expect("shared read validation must admit the mapped native test image"); + assert!(mapped.metadata().unwrap().len() > 0); + let mut header = [0; 2]; + mapped.read_exact(&mut header).unwrap(); + assert_eq!(&header, b"MZ"); + println!("{MAPPED_PROOF}"); + return; + } let temporary = tempfile::tempdir().unwrap(); let root = temporary.path().join("protected"); private_directory(&root).unwrap(); + let image = root.join(PRIVATE_IMAGE); + // Runner workspace ACLs are not installed-storage fixtures. Copy bytes + // into a new production-created private file, retaining no source ACL. + let mut source = File::open(current).unwrap(); + let mut copied = open_private(&image, false).unwrap(); + io::copy(&mut source, &mut copied).unwrap(); + copied.sync_all().unwrap(); + drop(copied); + drop(source); + let result = std::process::Command::new(&image) + .args(["--exact", TEST, "--nocapture", "--test-threads=1"]) + .output() + .expect("the private copied native test image must load"); + assert!( + result.status.success(), + "mapped image fixture failed: {result:?}" + ); + assert!( + String::from_utf8(result.stdout) + .unwrap() + .contains(MAPPED_PROOF) + ); + let path = root.join("fixture.exe"); let bytes = b"unchanged guarded fixture"; let mut file = open_private(&path, false).unwrap(); @@ -1073,6 +1108,88 @@ mod tests { assert!(!root.join("absent").exists()); } + #[test] + fn native_mapped_private_image_reports_append_and_generic_write_admission() { + use sha2::Digest as _; + + const PRIVATE_IMAGE: &str = "maple-updater-mapped-access-probe.exe"; + const PROOF: &str = "MAPPED_PRIVATE_IMAGE_ACCESS "; + const TEST: &str = "installer::windows_bundle::windows_fs::tests::native_mapped_private_image_reports_append_and_generic_write_admission"; + let current = std::env::current_exe().unwrap(); + if current.file_name() == Some(OsStr::new(PRIVATE_IMAGE)) { + let mapped = open_shared_readonly(¤t) + .expect("access probes require a validated, currently mapped private image"); + assert!(mapped.metadata().unwrap().len() > 0); + let before = sha2::Sha256::digest(fs::read(¤t).unwrap()); + let outcome = |result: io::Result| match result { + Ok(file) => { + drop(file); + serde_json::json!({"opened": true, "os_error": null, "error_kind": null}) + } + Err(error) => serde_json::json!({ + "opened": false, + "os_error": error.raw_os_error(), + "error_kind": format!("{:?}", error.kind()), + }), + }; + // These are the exact prepare and activation opens. Close each + // result before the next probe and never write through either. + let append = outcome(fs::OpenOptions::new().append(true).open(¤t)); + let generic_write = outcome(open_private(¤t, false)); + let after = sha2::Sha256::digest(fs::read(¤t).unwrap()); + assert_eq!(before, after, "access probes must not change image bytes"); + println!( + "{PROOF}{}", + serde_json::json!({ + "schema": "maple-mapped-private-image-access-v1", + "image_mapped": true, + "append": append, + "generic_write": generic_write, + "contents_unchanged": true, + }) + ); + drop(mapped); + return; + } + + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("protected"); + private_directory(&root).unwrap(); + let image = root.join(PRIVATE_IMAGE); + let mut source = File::open(current).unwrap(); + let mut copied = open_private(&image, false).unwrap(); + io::copy(&mut source, &mut copied).unwrap(); + copied.sync_all().unwrap(); + drop(copied); + drop(source); + let result = std::process::Command::new(&image) + .args(["--exact", TEST, "--nocapture", "--test-threads=1"]) + .output() + .expect("the private mapped access probe image must load"); + assert!( + result.status.success(), + "mapped access probe failed: {result:?}" + ); + let output = String::from_utf8(result.stdout).unwrap(); + let proof: serde_json::Value = output + .lines() + .find_map(|line| line.split_once(PROOF).map(|(_, proof)| proof)) + .map(|proof| serde_json::from_str(proof).unwrap()) + .expect("the exact mapped child test must emit its access outcomes"); + assert_eq!(proof["schema"], "maple-mapped-private-image-access-v1"); + assert_eq!(proof["image_mapped"], true); + assert_eq!(proof["contents_unchanged"], true); + assert!(proof["append"]["opened"].as_bool().is_some()); + assert!(proof["generic_write"]["opened"].as_bool().is_some()); + // Direct output keeps this bounded diagnostic in the native Cargo log + // even when libtest captures successful tests' println! output. + std::io::Write::write_all( + &mut io::stdout().lock(), + format!("{PROOF}{proof}\n").as_bytes(), + ) + .unwrap(); + } + #[test] fn rejects_existing_foreign_write_acl_before_truncation() { let temporary = tempfile::tempdir().unwrap(); diff --git a/apps/maple-agent/scripts/test_windows_release.py b/apps/maple-agent/scripts/test_windows_release.py index bc102636b..ed14e2901 100644 --- a/apps/maple-agent/scripts/test_windows_release.py +++ b/apps/maple-agent/scripts/test_windows_release.py @@ -564,6 +564,80 @@ def missing(*_arguments): raise FileNotFoundError return types.SimpleNamespace(KEY_READ=1, KEY_WOW64_32KEY=2, HKEY_CURRENT_USER=3, OpenKey=missing) + def test_native_activation_status_is_bounded_to_known_stages_and_marker(self): + self.assertEqual(set(release.ACTIVATION_FAILURE_STAGES), set(range(1, 11))) + for stage, name in release.ACTIVATION_FAILURE_STAGES.items(): + for raw in (0, 5, 32, 65535): + with self.subTest(stage=stage, raw=raw): + code = 0x4D000000 | (stage << 16) | raw + self.assertEqual(release.decode_activation_failure(code), { + "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): + with self.subTest(code=code): + self.assertIsNone(release.decode_activation_failure(code)) + + def test_nsis_actual_numeric_branch_passes_only_known_activation_failures(self): + # Interpret only the wrapper's small numeric branch, not NSIS/runtime + # behavior. This catches decimal-vs-hex string comparisons and jumps + # that would lose or admit a native status before the Windows rehearsal. + source = (SCRIPTS / "windows-installer.nsi").read_text() + branch = source.split("\nfailed:\n", 1)[1].split("\nactivation_diagnostic_done:\n", 1)[0] + 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)} + index = 0 + def value(token): + return registers.get(token, token) + def number(token): + return int(value(token), 0) + while index < len(instructions): + operation, *args = instructions[index].split() + destination = "0" + if operation == "StrCmp": + destination = args[2] if value(args[0]) == value(args[1]) else args[3] + elif operation == "IntCmp": + left, right = number(args[0]), number(args[1]) + destination = args[2 if left == right else 3 if left < right else 4] + elif operation == "IntOp": + left, right = number(args[1]), number(args[3]) + self.assertIn(args[2], ("&", ">>")) + registers[args[0]] = str(left & right if args[2] == "&" else left >> right) + elif operation == "StrCpy": + registers[args[0]] = value(args[1]) + else: + self.fail("Unexpected instruction in activation diagnostic branch: " + operation) + if destination != "0": + self.assertEqual(destination, "activation_diagnostic_done") + break + index += 1 + return int(registers["$FailureCode"]) + for stage in range(1, 11): + for raw in (0, 5, 32, 65535): + code = 0x4D000000 | (stage << 16) | raw + 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): + self.assertEqual(propagated(41, code), 41) + + def test_actual_child_status_is_reported_without_replay_or_primary_error_change(self): + code = 0x4D090020 + primary = subprocess.CalledProcessError(code, "fixture installer") + diagnostics = io.StringIO() + with patch.dict(sys.modules, winreg=self.registry()), patch.object(release, "smoke_environment", return_value={}), patch.object(release, "capture_preflight", return_value=subprocess.CompletedProcess("checker", 0, b"")), patch.object(release, "run", side_effect=primary) as commands, patch.object(sys, "stderr", diagnostics): + with self.assertRaises(subprocess.CalledProcessError) as raised: + release.smoke_installer(Path("fixture.exe"), info(), False, "12", {}, checker=Path("verified-checker.exe")) + self.assertIs(raised.exception, primary) + self.assertEqual(commands.call_count, 1) + evidence = json.loads(diagnostics.getvalue().split("Windows installer failure evidence: ", 1)[1]) + self.assertEqual(evidence["exit_code"], code) + self.assertEqual(evidence["installer_phase"], "activation-child") + self.assertEqual(evidence["native_stage"], "generation-guards") + self.assertEqual(evidence["native_win32_error"], 32) + def test_direct_preflight_captures_both_streams_and_bounds_output(self): result = release.capture_preflight([sys.executable, "-c", "import sys; print('stdout'); print('stderr', file=sys.stderr)"], dict(release.os.environ)) self.assertEqual(result.returncode, 0) diff --git a/apps/maple-agent/scripts/windows-installer.nsi b/apps/maple-agent/scripts/windows-installer.nsi index 93c0c2b1c..15acf76f6 100644 --- a/apps/maple-agent/scripts/windows-installer.nsi +++ b/apps/maple-agent/scripts/windows-installer.nsi @@ -162,6 +162,18 @@ uninstaller_ready: SetErrorLevel 0 Goto done failed: + ; Native activation diagnostics come from this exact ExecWait child. Keep + ; 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 + IntCmp $1 0x4D000000 0 activation_diagnostic_done activation_diagnostic_done + IntOp $1 $0 >> 16 + IntOp $1 $1 & 0xFF + IntCmp $1 1 0 activation_diagnostic_done 0 + IntCmp $1 10 0 0 activation_diagnostic_done + StrCpy $FailureCode $0 +activation_diagnostic_done: ; The native activation failure leaves the old pointer unchanged. Restore ; registration truth; user data and an earlier generation remain untouched. StrCmp $HadRegistration 1 restore_registration diff --git a/apps/maple-agent/scripts/windows-release.py b/apps/maple-agent/scripts/windows-release.py index 65714a3ef..02210a770 100644 --- a/apps/maple-agent/scripts/windows-release.py +++ b/apps/maple-agent/scripts/windows-release.py @@ -71,6 +71,25 @@ 41: "activation-child", 50: "uninstall-registration", 51: "uninstall-launch", 52: "uninstall-child", 53: "uninstall-files", } +ACTIVATION_FAILURE_MARKER = 0x4D000000 +ACTIVATION_FAILURE_STAGES = { + 1: "activation-input", 2: "root-storage", 3: "registration", + 4: "bootstrap-identity", 5: "install-lock", 6: "intent-identity", + 7: "generation-identity", 8: "generation-verification", + 9: "generation-guards", 10: "pointer-commit", +} + + +def decode_activation_failure(code): + # This fixed status is emitted by the actual native activation child and + # passed through NSIS. No helper replay or arbitrary child output is read. + if type(code) is not int or not 0 <= code <= 0xFFFFFFFF or code & 0xFF000000 != ACTIVATION_FAILURE_MARKER: + return None + stage = (code >> 16) & 0xFF + if stage not in ACTIVATION_FAILURE_STAGES: + return None + return {"native_stage": ACTIVATION_FAILURE_STAGES[stage], + "native_win32_error": (code & 0xFFFF) or None} def module(name): @@ -568,9 +587,15 @@ def state(path): except OSError: registration = "unavailable" code = error.returncode if isinstance(error, subprocess.CalledProcessError) else None + native = decode_activation_failure(code) if phase in ("install", "repeat-install") else None + installer_phase = NSIS_FAILURE_PHASES.get(code, "unknown") if phase in ("install", "repeat-install", "uninstall") else None + if native: + installer_phase = "activation-child" evidence = {"phase": phase, "error": type(error).__name__, "exit_code": code, - "installer_phase": NSIS_FAILURE_PHASES.get(code, "unknown") if phase in ("install", "repeat-install", "uninstall") else None, + "installer_phase": installer_phase, "registration": registration, "owned_paths": paths} + if native: + evidence.update(native) print("Windows installer failure evidence: " + json.dumps(evidence, sort_keys=True), file=sys.stderr) except Exception: # Diagnostics must never replace the failure being diagnosed. diff --git a/scripts/ci/test_windows_updater_workflow.py b/scripts/ci/test_windows_updater_workflow.py index e1304e741..74d1e0865 100644 --- a/scripts/ci/test_windows_updater_workflow.py +++ b/scripts/ci/test_windows_updater_workflow.py @@ -158,6 +158,8 @@ def test_failures_retain_evidence_and_do_not_claim_signed_lifecycle_qualificatio self.assertIn("A required native Windows test group is missing", source) self.assertIn("without skipped tests", source) for case in ( + "native_shared_read_validation_accepts_mapped_image_and_mutation_guard", + "native_mapped_private_image_reports_append_and_generic_write_admission", "native_nsis_inherited_read_acl_accepts_files_but_rejects_directories", "native_foreign_file_mutation_rights_remain_rejected", "native_python_owner_rights_acl_accepts_files_and_directories", @@ -165,6 +167,11 @@ def test_failures_retain_evidence_and_do_not_claim_signed_lifecycle_qualificatio "native_owner_rights_acl_still_rejects_foreign_writers", ): self.assertIn("installer::windows_bundle::windows_fs::tests::" + case, source) + self.assertIn( + "installer::windows_bundle::manual_activation_tests::" + "native_manual_pointer_republication_with_generation_guards_preserves_payloads", + source, + ) for field in ( "official_maple_signature_qualified", "signed_installed_a_to_b_qualified", "visible_gui_upgrade_qualified", "paired_remote_upgrade_qualified", diff --git a/scripts/ci/windows-updater-tests.ps1 b/scripts/ci/windows-updater-tests.ps1 index 5637cae6f..66106fb67 100644 --- a/scripts/ci/windows-updater-tests.ps1 +++ b/scripts/ci/windows-updater-tests.ps1 @@ -115,6 +115,7 @@ try { 'installer::windows_bundle::windows_fs::tests::rejects_file_and_ancestor_reparse_points_when_symlink_privilege_available', 'installer::windows_bundle::windows_fs::tests::native_mutation_guard_blocks_loader_until_released', 'installer::windows_bundle::windows_fs::tests::native_shared_read_validation_accepts_mapped_image_and_mutation_guard', + 'installer::windows_bundle::windows_fs::tests::native_mapped_private_image_reports_append_and_generic_write_admission', 'installer::windows_bundle::windows_fs::tests::native_nsis_inherited_read_acl_accepts_files_but_rejects_directories', 'installer::windows_bundle::windows_fs::tests::native_foreign_file_mutation_rights_remain_rejected', 'installer::windows_bundle::windows_fs::tests::native_python_owner_rights_acl_accepts_files_and_directories', @@ -142,6 +143,7 @@ try { 'installer::windows_bundle::transaction_tests::native_partial_generation_removal_preserves_unknown_files', 'installer::windows_bundle::transaction_tests::native_retained_guard_removes_owned_generation_without_partial_pointer_change', 'installer::windows_bundle::transaction_tests::native_dead_uninstall_journal_allows_only_owned_partial_repair', + 'installer::windows_bundle::manual_activation_tests::native_manual_pointer_republication_with_generation_guards_preserves_payloads', 'tests::downloads_and_reauthenticates_preparation_across_restart', 'tests::rollback_clears_offer_without_lowering_persisted_high_watermark', 'tests::resumes_only_exact_valid_ranges_and_final_digest', From 90e4e8d108cdcbf914a6dc10da771d3098651242 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:27:47 +0000 Subject: [PATCH 2/2] Reuse an authenticated current Windows generation on repeat install --- .../src/installer/windows_bundle.rs | 324 ++++++++++++++++-- .../scripts/test_windows_release.py | 73 +++- apps/maple-agent/scripts/windows-release.py | 43 ++- scripts/ci/test_windows_updater_workflow.py | 5 + scripts/ci/windows-updater-tests.ps1 | 1 + 5 files changed, 417 insertions(+), 29 deletions(-) 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 87836c6d2..3aad522c2 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 @@ -1106,7 +1106,7 @@ pub fn cleanup_current_windows_bundle( Ok(cleaned) } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] struct ManualIntent { format: String, @@ -1315,21 +1315,95 @@ fn recover_completed_manual_intent(root: &Path, current: &Bundle) -> Result<()> #[cfg(windows)] fn generation_write_guards(root: &Path) -> Result> { + generation_activation_guards(root, None) +} + +#[cfg(windows)] +fn generation_activation_guards(root: &Path, current: Option<&str>) -> Result> { let mut guards = Vec::new(); + let mut pinned_current = false; for entry in fs::read_dir(root.join("versions"))?.take(4096) { let entry = entry?; if !entry.file_name().to_str().is_some_and(valid_generation) { return Err(InstallError::Invalid("unknown Windows generation")); } known_partial_generation(&entry.path())?; + if current.is_some_and(|current| entry.file_name() == current) { + // The exact current generation needs no replacement. Pin all its + // bytes against writes/deletion while allowing its mapped image. + for name in FILES { + guards.push(windows_fs::open_readonly(&entry.path().join(name))?); + } + pinned_current = true; + continue; + } let executable = entry.path().join("maple-agent.exe"); if executable.try_exists()? { guards.push(windows_fs::open_private(&executable, false)?); } } + if current.is_some() && !pinned_current { + return Err(InstallError::Invalid( + "Windows current generation disappeared", + )); + } Ok(guards) } +/// Filesystem publication authority only. Full installer identity/admission +/// runs before acquisition and again under the pins for an exact-current retry. +#[cfg(windows)] +struct ManualActivationGuards { + root: PathBuf, + current: Current, + repeat: bool, + _files: Vec, +} + +#[cfg(windows)] +impl ManualActivationGuards { + fn acquire(root: &Path, current: Current) -> Result { + // Recovery can remove other payloads, so it must use the original + // exclusive path rather than an intent-only, no-mutation retry. + if !root.join(UNINSTALL_INTENT).try_exists()? { + match windows_fs::open_readonly(&root.join(POINTER)) { + Ok(mut pointer) => { + let mut bytes = Vec::new(); + (&mut pointer).take(1025).read_to_end(&mut bytes)?; + if Current::parse(&bytes).is_ok_and(|actual| actual == current) { + let mut files = vec![pointer]; + files.extend(generation_activation_guards( + root, + Some(¤t.generation), + )?); + return Ok(Self { + root: root.to_owned(), + current, + repeat: true, + _files: files, + }); + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(Self { + root: root.to_owned(), + current, + repeat: false, + _files: generation_write_guards(root)?, + }) + } + + fn commit(&self) -> Result<()> { + if !self.repeat { + write_private_json(&self.root.join(POINTER), &self.current)?; + } + Ok(()) + } +} + // A partial generation is repairable only when every existing entry is one of // our fixed regular files/directories. Never remove arbitrary files on retry. fn known_partial_generation(path: &Path) -> Result<()> { @@ -1816,6 +1890,46 @@ pub fn prepare_windows_installation( } } +#[cfg(windows)] +fn manual_activation_intent( + root: &Path, + profile: &str, + generation_name: &str, +) -> Result { + let intent: ManualIntent = + serde_json::from_slice(&read_bounded(&root.join(MANUAL_INTENT), DESCRIPTOR_LIMIT)?) + .map_err(|_| InstallError::Invalid("Windows installer intent"))?; + if intent.format != "maple-agent-windows-installer-intent-v1" + || intent.profile != profile + || intent.generation != generation_name + || (intent.owner_pid, intent.owner_created) != installer_owner()? + { + return Err(InstallError::Invalid("Windows installer intent identity")); + } + Ok(intent) +} + +#[cfg(windows)] +fn manual_activation_bundle( + root: &Path, + profile: &str, + generation_name: &str, + intent: &ManualIntent, +) -> Result<(PathBuf, Bundle)> { + let path = root.join("versions").join(generation_name); + let bundle = descriptor(&path, profile)?; + if bundle.generation() != generation_name + || bundle.version != intent.version + || bundle.source_sha != intent.source_sha + || exact_file_digest(&path.join("maple-agent.exe"))? != intent.executable_sha256 + { + return Err(InstallError::Invalid( + "Windows installer activation identity", + )); + } + Ok((path, bundle)) +} + /// Activation keeps the stable bootstrap and authenticates the exact new /// generation against the durable signed-checker intent before publication. pub fn activate_windows_installation( @@ -1858,36 +1972,46 @@ pub fn activate_windows_installation( } })?; stage = WindowsActivationStage::IntentIdentity; - let intent: ManualIntent = - serde_json::from_slice(&read_bounded(&root.join(MANUAL_INTENT), DESCRIPTOR_LIMIT)?) - .map_err(|_| InstallError::Invalid("Windows installer intent"))?; - if intent.format != "maple-agent-windows-installer-intent-v1" - || intent.profile != profile - || intent.generation != generation_name - || (intent.owner_pid, intent.owner_created) != installer_owner()? - { - return Err(InstallError::Invalid("Windows installer intent identity")); - } + let intent = manual_activation_intent(root, profile, generation_name)?; stage = WindowsActivationStage::GenerationIdentity; - let path = root.join("versions").join(generation_name); - let bundle = descriptor(&path, profile)?; - if bundle.generation() != generation_name - || bundle.version != intent.version - || bundle.source_sha != intent.source_sha - || exact_file_digest(&path.join("maple-agent.exe"))? != intent.executable_sha256 - { - return Err(InstallError::Invalid( - "Windows installer activation identity", - )); - } + let (path, bundle) = manual_activation_bundle(root, profile, generation_name, &intent)?; stage = WindowsActivationStage::GenerationVerification; let _ = scan_generation(&path)?; verify_identity(&path, &bundle)?; stage = WindowsActivationStage::GenerationGuards; - let _generation_guards = generation_write_guards(root)?; + let generation_guards = ManualActivationGuards::acquire(root, Current::new(&bundle))?; + if generation_guards.repeat { + // A same-current retry publishes nothing. Authenticate again + // while the pointer and every current payload file are pinned + // read-only; a mapped host can safely keep using those bytes. + stage = WindowsActivationStage::Registration; + registered_root(root, profile)?; + stage = WindowsActivationStage::BootstrapIdentity; + let _ = trusted_installer(&executable, profile)?; + stage = WindowsActivationStage::IntentIdentity; + if manual_activation_intent(root, profile, generation_name)? != intent { + return Err(InstallError::Invalid("Windows installer intent changed")); + } + stage = WindowsActivationStage::GenerationIdentity; + let (path, bundle) = + manual_activation_bundle(root, profile, generation_name, &intent)?; + stage = WindowsActivationStage::GenerationVerification; + let _ = scan_generation(&path)?; + verify_identity(&path, &bundle)?; + if root.join(UNINSTALL_INTENT).try_exists()? { + return Err(InstallError::Busy); + } + stage = WindowsActivationStage::PointerCommit; + generation_guards.commit()?; + // The already-committed pointer stays untouched. Complete only + // this matching live-owner intent, never old payload cleanup. + let _ = fs::remove_file(root.join(MANUAL_INTENT)); + let _ = windows_sync_directory(root); + return Ok(()); + } stage = WindowsActivationStage::PointerCommit; - write_private_json(&root.join(POINTER), &Current::new(&bundle))?; - drop(_generation_guards); + generation_guards.commit()?; + drop(generation_guards); // Publication committed. Cleanup cannot report an uncommitted failure // to NSIS, which would otherwise roll back valid registration. Startup // authenticates this exact generation and retries retained intent. @@ -2075,6 +2199,45 @@ mod transaction_tests; mod manual_activation_tests { use super::*; + fn file_snapshot(path: &Path) -> (u64, String, [u32; 3]) { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + + let mut file = File::open(path).unwrap(); + let mut identity = BY_HANDLE_FILE_INFORMATION::default(); + assert_ne!( + unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut identity) }, + 0 + ); + let size = file.metadata().unwrap().len(); + ( + size, + hex::encode(hash_reader(&mut file, size).unwrap()), + [ + identity.dwVolumeSerialNumber, + identity.nFileIndexHigh, + identity.nFileIndexLow, + ], + ) + } + + fn installation_snapshot(root: &Path) -> BTreeMap { + let mut snapshot = BTreeMap::new(); + snapshot.insert(POINTER.into(), file_snapshot(&root.join(POINTER))); + for entry in fs::read_dir(root.join("versions")).unwrap() { + let entry = entry.unwrap(); + for name in FILES { + snapshot.insert( + format!("versions/{}/{name}", entry.file_name().to_string_lossy()), + file_snapshot(&entry.path().join(name)), + ); + } + } + snapshot + } + fn generation_fixture(root: &Path, version: &str, build: &str) -> Bundle { let bundle = Bundle { format: "maple-agent-windows-bundle-v1".into(), @@ -2137,4 +2300,115 @@ mod manual_activation_tests { assert_eq!(scan_generation(&next_path).unwrap(), next_nodes); } } + + #[test] + fn native_same_current_manual_activation_is_read_only_with_a_mapped_image() { + const TEST: &str = "installer::windows_bundle::manual_activation_tests::native_same_current_manual_activation_is_read_only_with_a_mapped_image"; + const PROOF: &str = "MAPPED_CURRENT_MANUAL_REPEAT_OK"; + let executable = std::env::current_exe().unwrap(); + if executable + .file_name() + .is_some_and(|name| name == "maple-agent.exe") + { + let generation = executable.parent().unwrap(); + let root = generation.parent().unwrap().parent().unwrap(); + let current = + Current::parse(&read_bounded(&root.join(POINTER), 1024).unwrap()).unwrap(); + assert_eq!(current.path(root), generation); + let before = installation_snapshot(root); + // This child really maps the copied PE. Ordinary write guards must + // refuse it; a read pin and an exact-current no-op must permit it. + assert!(generation_write_guards(root).is_err()); + let external_pointer_pin = windows_fs::open_readonly(&root.join(POINTER)).unwrap(); + let guards = ManualActivationGuards::acquire(root, current.clone()).unwrap(); + assert!(guards.repeat); + // The mapped exe alone would be a vacuous write-denial test. + for path in [ + root.join(POINTER), + generation.join("LICENSE.txt"), + generation.join("build-info.json"), + ] { + assert!(windows_fs::open_private(&path, false).is_err()); + } + guards.commit().unwrap(); + assert_eq!(installation_snapshot(root), before); + let sibling = root.join("versions/0.0.10-737/maple-agent.exe"); + assert!( + windows_fs::open_readonly(&sibling).is_err(), + "sibling write guard was relaxed" + ); + drop(guards); + drop(windows_fs::open_readonly(&sibling).unwrap()); + // A held pointer pin would make even an identical republication + // fail. It remains open across the actual production no-op commit. + assert!(external_pointer_pin.metadata().unwrap().is_file()); + drop(windows_fs::open_private(&generation.join("LICENSE.txt"), false).unwrap()); + + let different = Current { + format: POINTER_FORMAT.into(), + generation: "0.0.10-737".into(), + }; + assert!(ManualActivationGuards::acquire(root, different).is_err()); + fs::write(root.join(UNINSTALL_INTENT), b"pending recovery fixture").unwrap(); + assert!(ManualActivationGuards::acquire(root, current.clone()).is_err()); + fs::remove_file(root.join(UNINSTALL_INTENT)).unwrap(); + let unknown = root.join("versions/unknown-generation"); + fs::create_dir(&unknown).unwrap(); + assert!(ManualActivationGuards::acquire(root, current.clone()).is_err()); + fs::remove_dir(unknown).unwrap(); + let foreign = root.join("versions/0.0.10-737/foreign-fixture"); + fs::write(&foreign, b"unknown sibling file").unwrap(); + assert!(ManualActivationGuards::acquire(root, current.clone()).is_err()); + fs::remove_file(foreign).unwrap(); + let license = generation.join("LICENSE.txt"); + let saved_license = root.join("saved-license-fixture"); + fs::rename(&license, &saved_license).unwrap(); + assert!(ManualActivationGuards::acquire(root, current).is_err()); + fs::rename(saved_license, license).unwrap(); + assert_eq!(installation_snapshot(root), before); + drop(external_pointer_pin); + println!("{PROOF}"); + return; + } + + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("installation"); + windows_fs::private_directory(&root.join("versions")).unwrap(); + let current = generation_fixture(&root, "0.0.11", "738"); + generation_fixture(&root, "0.0.10", "737"); + let target = root + .join("versions") + .join(current.generation()) + .join("maple-agent.exe"); + let mut source = File::open(&executable).unwrap(); + let mut copied = windows_fs::open_private(&target, true).unwrap(); + io::copy(&mut source, &mut copied).unwrap(); + copied.sync_all().unwrap(); + drop(copied); + drop(source); + write_private_json(&root.join(POINTER), &Current::new(¤t)).unwrap(); + let before = installation_snapshot(&root); + // Only the shared filesystem helper is under test. The copied updater + // test binary is not presented as an admitted/signed Maple application. + let result = std::process::Command::new(&target) + .args(["--exact", TEST, "--nocapture", "--test-threads=1"]) + .output() + .expect("the copied private updater test image must load"); + assert!( + result.status.success(), + "mapped repeat child failed: {result:?}" + ); + assert_eq!( + String::from_utf8(result.stdout) + .unwrap() + .matches(PROOF) + .count(), + 1, + "the exact mapped child must complete its assertions" + ); + assert_eq!(file_snapshot(&root.join(POINTER)), before[POINTER]); + for (name, expected) in before { + assert_eq!(file_snapshot(&root.join(name)), expected); + } + } } diff --git a/apps/maple-agent/scripts/test_windows_release.py b/apps/maple-agent/scripts/test_windows_release.py index ed14e2901..26440dddd 100644 --- a/apps/maple-agent/scripts/test_windows_release.py +++ b/apps/maple-agent/scripts/test_windows_release.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Windows package identity, extraction and signing-boundary regressions.""" +import ast +import ctypes import hashlib import gzip import importlib.util @@ -14,7 +16,7 @@ import tempfile import types import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch import zipfile @@ -558,6 +560,75 @@ def test_nsis_uninstall_keeps_native_fence_until_all_mutations_are_finished(self self.assertEqual(remaining, []) +class PointerPinTests(unittest.TestCase): + def kernel(self, handle=123): + return types.SimpleNamespace(CreateFileW=Mock(return_value=handle), CloseHandle=Mock(return_value=1)) + + def test_pin_requests_explicit_read_sharing_and_closes_after_its_body(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "current.json" + path.write_bytes(b"fixed pointer fixture") + kernel = self.kernel() + with patch.object(ctypes, "WinDLL", return_value=kernel, create=True) as library: + with release.pin_current_pointer(path): + library.assert_called_once_with("kernel32", use_last_error=True) + kernel.CreateFileW.assert_called_once_with(str(path), 0x80000000, 1, None, 3, 0, None) + self.assertEqual(len(kernel.CreateFileW.argtypes), 7) + self.assertIsNotNone(kernel.CreateFileW.restype) + self.assertEqual(path.read_bytes(), b"fixed pointer fixture") + kernel.CloseHandle.assert_not_called() + kernel.CloseHandle.assert_called_once_with(123) + + def test_pin_open_failure_does_not_run_the_body_or_close_an_invalid_handle(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "current.json" + path.write_bytes(b"fixed pointer fixture") + kernel = self.kernel(ctypes.c_void_p(-1).value) + primary = OSError("fixed native open failure") + with patch.object(ctypes, "WinDLL", return_value=kernel, create=True), patch.object(ctypes, "get_last_error", return_value=5, create=True), patch.object(ctypes, "WinError", return_value=primary, create=True): + with self.assertRaises(OSError) as raised: + with release.pin_current_pointer(path): + self.fail("A failed pointer pin must not admit repeat installation") + self.assertIs(raised.exception, primary) + kernel.CloseHandle.assert_not_called() + + def test_pin_close_failure_preserves_primary_installer_error_and_surfaces_without_one(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "current.json" + path.write_bytes(b"fixed pointer fixture") + kernel = self.kernel() + kernel.CloseHandle.return_value = 0 + secondary = OSError("do not expose close details") + primary = subprocess.CalledProcessError(0x4D090020, "fixture installer") + diagnostics = io.StringIO() + with patch.object(ctypes, "WinDLL", return_value=kernel, create=True), patch.object(ctypes, "get_last_error", return_value=5, create=True), patch.object(ctypes, "WinError", return_value=secondary, create=True), patch.object(sys, "stderr", diagnostics): + with self.assertRaises(subprocess.CalledProcessError) as raised: + with release.pin_current_pointer(path): + raise primary + self.assertIs(raised.exception, primary) + with self.assertRaises(OSError) as raised: + with release.pin_current_pointer(path): + pass + self.assertIs(raised.exception, secondary) + self.assertEqual(kernel.CloseHandle.call_count, 2) + self.assertIn("Windows pointer pin cleanup failure: OSError", diagnostics.getvalue()) + self.assertNotIn("do not expose", diagnostics.getvalue()) + + def test_actual_repeat_and_identity_assertions_are_the_only_pinned_smoke_operations(self): + # Portable scope contract; the actual sharing denial is Windows-only. + tree = ast.parse((SCRIPTS / "windows-release.py").read_text()) + smoke = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "smoke_installer") + pinned = [node for node in ast.walk(smoke) if isinstance(node, ast.With) and + any(isinstance(item.context_expr, ast.Call) and isinstance(item.context_expr.func, ast.Name) and + item.context_expr.func.id == "pin_current_pointer" for item in node.items)] + self.assertEqual(len(pinned), 1) + self.assertEqual([type(node) for node in pinned[0].body], [ast.Expr, ast.If]) + self.assertEqual(pinned[0].body[0].value.func.id, "run") + self.assertEqual(pinned[0].body[1].body[0].exc.func.id, "ValueError") + self.assertIn("current.json", ast.unparse(pinned[0])) + self.assertIn("bootstrap_hash", ast.unparse(pinned[0])) + + class SmokeDiagnosticsTests(unittest.TestCase): def registry(self): def missing(*_arguments): diff --git a/apps/maple-agent/scripts/windows-release.py b/apps/maple-agent/scripts/windows-release.py index 02210a770..ac7e7ba08 100644 --- a/apps/maple-agent/scripts/windows-release.py +++ b/apps/maple-agent/scripts/windows-release.py @@ -555,6 +555,42 @@ def verification_scratch(prefix): raise +@contextmanager +def pin_current_pointer(path): + # The exact-current repeat must not write or replace this pointer. Specify + # Windows sharing explicitly; Python's file mode does not define it. + import ctypes + from ctypes import wintypes + + regular(path) + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + create = kernel.CreateFileW + create.argtypes = (wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, + wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE) + create.restype = wintypes.HANDLE + close = kernel.CloseHandle + close.argtypes, close.restype = (wintypes.HANDLE,), wintypes.BOOL + # GENERIC_READ, FILE_SHARE_READ only, NULL security (noninheritable), + # OPEN_EXISTING. Neither writes nor delete/rename access can be admitted. + handle = create(str(path), 0x80000000, 1, None, 3, 0, None) + if handle == ctypes.c_void_p(-1).value: + raise ctypes.WinError(ctypes.get_last_error()) + primary = None + try: + yield + except BaseException as error: + primary = error + raise + finally: + try: + if not close(handle): + raise ctypes.WinError(ctypes.get_last_error()) + except Exception as error: + print("Windows pointer pin cleanup failure: " + type(error).__name__, file=sys.stderr) + if primary is None: + raise + + def smoke_failure_evidence(phase, error, installed, generation_name, registry, access, winreg): """Presence/type evidence only; no profile/store data or arbitrary walks.""" def state(path): @@ -685,9 +721,10 @@ def smoke_installer(installer, info, unsigned, build, expected_hashes, *, checke # and stable bootstrap rather than rewriting a running payload. bootstrap_hash = hashlib.sha256(executable.read_bytes()).hexdigest() phase = "repeat-install" - run(command_as_windows_args(command), env=environment, timeout=120) - if (installed / "current.json").read_bytes() != pointer_bytes or hashlib.sha256(executable.read_bytes()).hexdigest() != bootstrap_hash: - raise ValueError("Repeated Windows install changed stable bootstrap or active pointer") + with pin_current_pointer(installed / "current.json"): + run(command_as_windows_args(command), env=environment, timeout=120) + if (installed / "current.json").read_bytes() != pointer_bytes or hashlib.sha256(executable.read_bytes()).hexdigest() != bootstrap_hash: + raise ValueError("Repeated Windows install changed stable bootstrap or active pointer") except BaseException as error: primary = error smoke_failure_evidence(phase, error, installed, generation_name, registry, registry_access, winreg) diff --git a/scripts/ci/test_windows_updater_workflow.py b/scripts/ci/test_windows_updater_workflow.py index 74d1e0865..f80a51366 100644 --- a/scripts/ci/test_windows_updater_workflow.py +++ b/scripts/ci/test_windows_updater_workflow.py @@ -172,6 +172,11 @@ def test_failures_retain_evidence_and_do_not_claim_signed_lifecycle_qualificatio "native_manual_pointer_republication_with_generation_guards_preserves_payloads", source, ) + self.assertIn( + "installer::windows_bundle::manual_activation_tests::" + "native_same_current_manual_activation_is_read_only_with_a_mapped_image", + source, + ) for field in ( "official_maple_signature_qualified", "signed_installed_a_to_b_qualified", "visible_gui_upgrade_qualified", "paired_remote_upgrade_qualified", diff --git a/scripts/ci/windows-updater-tests.ps1 b/scripts/ci/windows-updater-tests.ps1 index 66106fb67..15f819a8d 100644 --- a/scripts/ci/windows-updater-tests.ps1 +++ b/scripts/ci/windows-updater-tests.ps1 @@ -144,6 +144,7 @@ try { 'installer::windows_bundle::transaction_tests::native_retained_guard_removes_owned_generation_without_partial_pointer_change', 'installer::windows_bundle::transaction_tests::native_dead_uninstall_journal_allows_only_owned_partial_repair', 'installer::windows_bundle::manual_activation_tests::native_manual_pointer_republication_with_generation_guards_preserves_payloads', + 'installer::windows_bundle::manual_activation_tests::native_same_current_manual_activation_is_read_only_with_a_mapped_image', 'tests::downloads_and_reauthenticates_preparation_across_restart', 'tests::rollback_clears_offer_without_lowering_persisted_high_watermark', 'tests::resumes_only_exact_valid_ranges_and_final_digest',