From 06ab58c3e0c957e90582186d39699d38c34ddd47 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 27 Aug 2026 15:43:58 -0700 Subject: [PATCH] fix(cua-driver): honor disabled UIA ancestors --- .../src/browser_consent_ui.rs | 16 +- .../platform-windows/src/browser_setup_ui.rs | 35 +-- .../rust/crates/platform-windows/src/msaa.rs | 85 +++--- .../platform-windows/src/recording_hooks.rs | 2 +- .../platform-windows/src/tools/impl_.rs | 10 +- .../crates/platform-windows/src/uia/cache.rs | 79 +++--- .../src/uia/cache_uaf_repro.rs | 45 ++- .../crates/platform-windows/src/uia/mod.rs | 266 +++++++++++++----- 8 files changed, 324 insertions(+), 214 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-windows/src/browser_consent_ui.rs b/libs/cua-driver/rust/crates/platform-windows/src/browser_consent_ui.rs index 11dd682235..fc6f38e62f 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/browser_consent_ui.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/browser_consent_ui.rs @@ -27,12 +27,6 @@ fn refusal(code: BrowserRefusalCode, message: impl Into) -> BrowserRefus BrowserRefusal::new(code, message) } -fn release_nodes(nodes: &[UiaNode]) { - for node in nodes.iter().filter(|node| node.element_ptr != 0) { - unsafe { drop(IUIAutomationElement::from_raw(node.element_ptr as *mut _)) }; - } -} - fn is_in_web_content(nodes: &[UiaNode], node: &UiaNode) -> bool { let mut parent = node.parent_element_index; for _ in 0..nodes.len() { @@ -390,7 +384,6 @@ pub fn dismiss(pid: u32, hwnd: u64) -> Result { let tree = crate::uia::walk_tree(hwnd, None); let prompt_present = native_prompt_surface_present(&tree.nodes); if !prompt_present { - release_nodes(&tree.nodes); return Ok(dismissed); } let cancel = exact_cancel_button(&tree.nodes); @@ -402,7 +395,6 @@ pub fn dismiss(pid: u32, hwnd: u64) -> Result { )), Err(error) => Err(error), }; - release_nodes(&tree.nodes); invoked?; dismissed = true; if Instant::now() >= deadline { @@ -443,15 +435,11 @@ pub async fn handle( match exact_allow_button(&tree.nodes) { Ok(Some(element)) => { let invoked = unsafe { invoke(element) }; - release_nodes(&tree.nodes); invoked?; return Ok(BrowserConsentOutcome::Accepted); } - Ok(None) => release_nodes(&tree.nodes), - Err(error) => { - release_nodes(&tree.nodes); - return Err(error); - } + Ok(None) => {} + Err(error) => return Err(error), } if saw_prompt && !prompt_present { return Err(refusal( diff --git a/libs/cua-driver/rust/crates/platform-windows/src/browser_setup_ui.rs b/libs/cua-driver/rust/crates/platform-windows/src/browser_setup_ui.rs index 7f42fa7a9e..c0cd76db32 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/browser_setup_ui.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/browser_setup_ui.rs @@ -31,12 +31,6 @@ fn refusal(code: BrowserRefusalCode, message: impl Into) -> BrowserRefus BrowserRefusal::new(code, message) } -fn release_nodes(nodes: &[UiaNode]) { - for node in nodes.iter().filter(|node| node.element_ptr != 0) { - unsafe { drop(IUIAutomationElement::from_raw(node.element_ptr as *mut _)) }; - } -} - fn unique_web_actionable( nodes: &[UiaNode], control_type: &str, @@ -178,7 +172,6 @@ fn stable_native_tab_count(hwnd: u64, initial_count: usize) -> Result 0 && current == previous { return Ok(current); } @@ -393,7 +386,6 @@ impl SetupUiHandle { }, _ => false, }; - release_nodes(&tree.nodes); if restored { self.enabled_remote_debugging = false; } @@ -430,7 +422,6 @@ impl SetupUiHandle { } let tree = crate::uia::walk_tree(self.hwnd, None); let proven = setup_page_proven(&tree.nodes, self.descriptor); - release_nodes(&tree.nodes); if !proven { let error = refusal( BrowserRefusalCode::BrowserWrongTargetRefused, @@ -456,7 +447,6 @@ impl SetupUiHandle { } let tree = crate::uia::walk_tree(self.hwnd, None); let proven = setup_page_proven(&tree.nodes, self.descriptor); - release_nodes(&tree.nodes); Some( proven && crate::input::keyboard::send_key_synthesized(self.hwnd, "w", &["ctrl"]).is_ok(), @@ -525,7 +515,6 @@ fn set_remote_debugging( }, Ok(None) => { let initial_tab_count = native_tab_count(&initial.nodes); - release_nodes(&initial.nodes); let tab_count_before = stable_native_tab_count(hwnd, initial_tab_count)?; let mut handle = SetupUiHandle { @@ -542,7 +531,6 @@ fn set_remote_debugging( let new_tab_button = match exact_native_new_tab_button(&tab_tree.nodes) { Ok(Some(element)) => element, Ok(None) => { - release_nodes(&tab_tree.nodes); return Err(handle.abort(refusal( BrowserRefusalCode::BrowserWrongTargetRefused, format!( @@ -552,15 +540,12 @@ fn set_remote_debugging( ))); } Err(error) => { - release_nodes(&tab_tree.nodes); return Err(handle.abort(error)); } }; if let Err(error) = unsafe { invoke(new_tab_button, "native new-tab button") } { - release_nodes(&tab_tree.nodes); return Err(handle.abort(error)); } - release_nodes(&tab_tree.nodes); handle.opened_setup_page = true; let deadline = Instant::now() + Duration::from_secs(3); @@ -570,7 +555,6 @@ fn set_remote_debugging( if tab_count_after == tab_count_before + 1 { break tree; } - release_nodes(&tree.nodes); if Instant::now() >= deadline { return Err(handle.abort(refusal( BrowserRefusalCode::BrowserWrongTargetRefused, @@ -585,7 +569,6 @@ fn set_remote_debugging( let omnibox = match unique_native_actionable(&created.nodes, "Edit", "set_value") { Ok(Some(element)) => element, Ok(None) => { - release_nodes(&created.nodes); return Err(handle.abort(refusal( BrowserRefusalCode::BrowserWrongTargetRefused, format!( @@ -595,15 +578,12 @@ fn set_remote_debugging( ))); } Err(error) => { - release_nodes(&created.nodes); return Err(handle.abort(error)); } }; if let Err(error) = unsafe { set_value(omnibox, descriptor.setup_url) } { - release_nodes(&created.nodes); return Err(handle.abort(error)); } - release_nodes(&created.nodes); created = crate::uia::walk_tree(hwnd, None); let refreshed_omnibox = match unique_native_actionable(&created.nodes, "Edit", "set_value") { @@ -618,14 +598,12 @@ fn set_remote_debugging( element } Ok(_) => { - release_nodes(&created.nodes); return Err(handle.abort(refusal( BrowserRefusalCode::BrowserWrongTargetRefused, "the unique native address field did not retain the exact setup URL", ))); } Err(error) => { - release_nodes(&created.nodes); return Err(handle.abort(error)); } }; @@ -636,21 +614,14 @@ fn set_remote_debugging( &mut handle.injected_global_input, &mut handle.focused_setup_address_field, ) { - release_nodes(&created.nodes); return Err(handle.abort(error)); } - release_nodes(&created.nodes); handle } Err(error) => { - release_nodes(&initial.nodes); return Err(error); } }; - if !handle.opened_setup_page { - release_nodes(&initial.nodes); - } - let deadline = Instant::now() + EXISTING_PROFILE_SETUP_READY_TIMEOUT; loop { let tree = crate::uia::walk_tree(hwnd, None); @@ -671,16 +642,14 @@ fn set_remote_debugging( Ok(_) => Ok(false), Err(error) => Err(error), }; - release_nodes(&tree.nodes); match outcome { Ok(true) => return Ok(handle), Ok(false) => handle.enable_attempted = true, Err(error) => return Err(handle.abort(error)), } } - Ok(None) => release_nodes(&tree.nodes), + Ok(None) => {} Err(error) => { - release_nodes(&tree.nodes); return Err(handle.abort(error)); } } @@ -732,7 +701,7 @@ mod tests { actions: actions.iter().map(|value| (*value).to_owned()).collect(), enabled: None, selected: None, - element_ptr: 7, + element_ptr: if actions.is_empty() { 0 } else { 7 }, center_x: 0, center_y: 0, rect: None, diff --git a/libs/cua-driver/rust/crates/platform-windows/src/msaa.rs b/libs/cua-driver/rust/crates/platform-windows/src/msaa.rs index a1ed48da86..31f56edccf 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/msaa.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/msaa.rs @@ -170,60 +170,45 @@ unsafe fn walk( let has_content = name.is_some(); if is_actionable || has_content { - // Retain the IAccessible pointer for the cache. Mirror what the UIA - // walker does: clone, get raw, forget the local — the cache's Drop - // releases via IUnknown. - let retained: IAccessible = acc.clone(); - let ptr = retained.as_raw() as usize; - std::mem::forget(retained); - - let (center_x, center_y) = rect - .map(|(l, t, r, b)| ((l + r) / 2, (t + b) / 2)) - .unwrap_or((0, 0)); - - let node = if is_actionable { + let element_index = if is_actionable { let idx = *counter; *counter += 1; - UiaNode { - element_index: Some(idx), - control_type: control_type.clone(), - name: name.clone(), - value: None, - automation_id: None, - help_text: None, - actions: actions.clone(), - enabled: None, - selected: None, - element_ptr: ptr, - center_x, - center_y, - rect, - msaa_role: role_int, - depth, - parent_element_index: parent_index, - in_web_content: false, - } + Some(idx) } else { - UiaNode { - element_index: None, - control_type: control_type.clone(), - name: name.clone(), - value: None, - automation_id: None, - help_text: None, - actions: Vec::new(), - enabled: None, - selected: None, - element_ptr: ptr, - center_x: 0, - center_y: 0, - rect, - msaa_role: role_int, - depth, - parent_element_index: parent_index, - in_web_content: false, - } + None + }; + let (element_ptr, center_x, center_y) = if element_index.is_some() { + // The cache adopts this reference when the walk becomes a snapshot. + let retained: IAccessible = acc.clone(); + let ptr = retained.as_raw() as usize; + std::mem::forget(retained); + let (center_x, center_y) = rect + .map(|(l, t, r, b)| ((l + r) / 2, (t + b) / 2)) + .unwrap_or((0, 0)); + (ptr, center_x, center_y) + } else { + (0, 0, 0) + }; + let node = UiaNode { + element_index, + control_type: control_type.clone(), + name: name.clone(), + value: None, + automation_id: None, + help_text: None, + actions: actions.clone(), + enabled: None, + selected: None, + element_ptr, + center_x, + center_y, + rect, + msaa_role: role_int, + depth, + parent_element_index: parent_index, + in_web_content: false, }; + debug_assert_eq!(node.element_ptr != 0, node.element_index.is_some()); // Track this node as the parent_index for its descendants only when // it received an element_index (mirrors what the markdown shows: // only indexed rows are addressable). diff --git a/libs/cua-driver/rust/crates/platform-windows/src/recording_hooks.rs b/libs/cua-driver/rust/crates/platform-windows/src/recording_hooks.rs index aa29319b4e..4c2c1f205d 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/recording_hooks.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/recording_hooks.rs @@ -107,7 +107,7 @@ pub fn app_state_json_for(window_id: Option, pid: Option) -> Option = - nodes.iter().filter(|n| n.element_index.is_some()).collect(); - let elements: Vec = actionable.iter().map(|n| n.element_ptr).collect(); - let centers: Vec<(i32, i32)> = actionable - .iter() - .map(|n| (n.center_x, n.center_y)) - .collect(); - let rects: Vec> = actionable.iter().map(|n| n.rect).collect(); - let msaa_roles: Vec> = actionable.iter().map(|n| n.msaa_role).collect(); - self.core.insert( - CacheKey { pid, hwnd }, - CachedSnapshot { - kind, - elements, - centers, - rects, - msaa_roles, - }, + fn adopt_nodes(&self, pid: u32, hwnd: u64, nodes: &mut [UiaNode], kind: SnapshotKind) { + debug_assert!( + nodes + .iter() + .all(|node| (node.element_ptr != 0) == node.element_index.is_some()), + "UiaNode element pointer/index invariant violated" ); + let actionable_count = nodes + .iter() + .filter(|node| node.element_index.is_some()) + .count(); + let mut snapshot = CachedSnapshot { + kind, + elements: Vec::with_capacity(actionable_count), + centers: Vec::with_capacity(actionable_count), + rects: Vec::with_capacity(actionable_count), + msaa_roles: Vec::with_capacity(actionable_count), + }; + for node in nodes.iter_mut().filter(|node| node.element_index.is_some()) { + snapshot + .elements + .push(std::mem::take(&mut node.element_ptr)); + snapshot.centers.push((node.center_x, node.center_y)); + snapshot.rects.push(node.rect); + snapshot.msaa_roles.push(node.msaa_role); + } + self.core.insert(CacheKey { pid, hwnd }, snapshot); } /// Look up + COM-`AddRef` the element for `element_index` in (pid, hwnd), /// returning a guard that `Release`s on drop. The `AddRef` happens **under - /// the cache lock**, so a concurrent [`update`](Self::update) (which + /// the cache lock**, so a concurrent [`adopt_walk`](Self::adopt_walk) (which /// replaces the snapshot and `Release`s its pointers in /// `CachedSnapshot::drop`) cannot free the element between the lookup and /// the `AddRef`. Hold the returned guard for the entire UIA/MSAA action — diff --git a/libs/cua-driver/rust/crates/platform-windows/src/uia/cache_uaf_repro.rs b/libs/cua-driver/rust/crates/platform-windows/src/uia/cache_uaf_repro.rs index 16f29d4198..c52dee397b 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/uia/cache_uaf_repro.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/uia/cache_uaf_repro.rs @@ -3,7 +3,7 @@ //! //! THE RACE (what these tests model): //! Two concurrent sessions drive the same (pid, hwnd). -//! - Session A: `get_window_state` → `ElementCache::update` → `core.insert` +//! - Session A: `get_window_state` → `ElementCache::adopt_walk` → `core.insert` //! replaces the snapshot → old `CachedSnapshot::drop` → COM `Release` on //! every cached `IUIAutomationElement`. //! - Session B: `click` / `type_text` / `set_value` looked the element up out @@ -36,6 +36,7 @@ //! BEFORE deterministically trips and AFTER deterministically survives. use super::{CacheKey, CachedSnapshot, ElementCache, SnapshotKind}; +use crate::uia::{tests::node, UiaNode, UiaTreeResult}; use std::ffi::c_void; use std::sync::atomic::{AtomicIsize, AtomicUsize, Ordering}; use std::sync::mpsc; @@ -132,6 +133,20 @@ fn snapshot_with(ptrs: Vec) -> CachedSnapshot { } } +fn walk_with(ptr: usize) -> UiaTreeResult { + UiaTreeResult { + tree_markdown: String::new(), + nodes: vec![UiaNode { + element_ptr: ptr, + ..node(Some(0), "Button", "", None, Some(true), None, &["invoke"]) + }], + } +} + +fn fake_refcount(ptr: usize) -> isize { + unsafe { (*(ptr as *const FakeObj)).refcount.load(Ordering::SeqCst) } +} + /// Pre-fix accessor, restored verbatim: a bare `usize` copy with NO AddRef /// under the lock. This is exactly the `get_element_ptr` body deleted by /// d95b89a1 — the vulnerable path. @@ -217,6 +232,34 @@ fn run_forced_interleave(use_retained: bool, poison_on_zero: bool) -> usize { // ---- Regression guards (run in the normal suite) ---------------------------- +#[test] +fn walk_result_releases_unadopted_references_on_drop() { + let uaf_hits: &'static AtomicUsize = Box::leak(Box::new(AtomicUsize::new(0))); + let ptr = make_fake(uaf_hits, false); + + drop(walk_with(ptr)); + + assert_eq!(fake_refcount(ptr), 0); + assert_eq!(uaf_hits.load(Ordering::SeqCst), 0); +} + +#[test] +fn cache_adoption_transfers_reference_ownership() { + let uaf_hits: &'static AtomicUsize = Box::leak(Box::new(AtomicUsize::new(0))); + let ptr = make_fake(uaf_hits, false); + let cache = ElementCache::new(); + let mut walk = walk_with(ptr); + + cache.adopt_walk(PID, HWND, &mut walk); + assert_eq!(walk.nodes[0].element_ptr, 0); + drop(walk); + assert_eq!(fake_refcount(ptr), 1); + + drop(cache); + assert_eq!(fake_refcount(ptr), 0); + assert_eq!(uaf_hits.load(Ordering::SeqCst), 0); +} + /// AFTER: the fixed `get_element_retained` path survives the exact dangerous /// interleave with zero use-after-free touches. #[test] diff --git a/libs/cua-driver/rust/crates/platform-windows/src/uia/mod.rs b/libs/cua-driver/rust/crates/platform-windows/src/uia/mod.rs index 193d89809d..c4c6abc9e0 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/uia/mod.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/uia/mod.rs @@ -66,8 +66,9 @@ pub struct UiaNode { /// Toggle/selection state when the element exposes one of those patterns. pub selected: Option, /// Raw COM pointer (IUIAutomationElement for UIA path, IAccessible for - /// MSAA path) as usize. Retained — `ElementCache` Drop releases it via - /// the `kind`-appropriate vtable. + /// MSAA path) as usize. Non-zero if and only if `element_index` is `Some`. + /// `UiaTreeResult` owns the retained reference until a stored snapshot + /// explicitly adopts it. pub element_ptr: usize, /// Screen-coordinate center, captured at walk time to avoid later COM calls. pub center_x: i32, @@ -98,6 +99,26 @@ pub struct UiaTreeResult { pub nodes: Vec, } +impl Drop for UiaTreeResult { + fn drop(&mut self) { + use windows::Win32::UI::Accessibility::IAccessible; + + for node in &mut self.nodes { + let element_ptr = std::mem::take(&mut node.element_ptr); + if element_ptr == 0 { + continue; + } + unsafe { + if node.msaa_role.is_some() { + drop(IAccessible::from_raw(element_ptr as *mut _)); + } else { + drop(IUIAutomationElement::from_raw(element_ptr as *mut _)); + } + } + } + } +} + /// Walk the UIA tree for the window with the given HWND. pub fn walk_tree(hwnd: u64, query: Option<&str>) -> UiaTreeResult { walk_tree_bounded(hwnd, query, DEFAULT_MAX_TOTAL_ELEMENTS, DEFAULT_MAX_DEPTH) @@ -146,20 +167,6 @@ fn exact_menu_path_matches(nodes: &[UiaNode], path: &[String]) -> Vec { .collect() } -unsafe fn release_walk_nodes(nodes: Vec) { - use windows::Win32::UI::Accessibility::IAccessible; - for node in nodes { - if node.element_ptr == 0 { - continue; - } - if node.msaa_role.is_some() { - drop(IAccessible::from_raw(node.element_ptr as *mut _)); - } else { - drop(IUIAutomationElement::from_raw(node.element_ptr as *mut _)); - } - } -} - unsafe fn invoke_menu_element(element_ptr: usize, final_segment: bool) -> Result<(), String> { let element = std::mem::ManuallyDrop::new(IUIAutomationElement::from_raw(element_ptr as *mut _)); @@ -199,14 +206,8 @@ pub fn invoke_menu_path(hwnd: u64, path: &[String]) -> Result<(), String> { let matches = exact_menu_path_matches(&result.nodes, &path[..=depth]); let target_index = match matches.as_slice() { [index] => *index, - [] => { - unsafe { release_walk_nodes(result.nodes) }; - return Err(format!("menu path segment {depth} was not found")); - } - _ => { - unsafe { release_walk_nodes(result.nodes) }; - return Err(format!("menu path segment {depth} is ambiguous")); - } + [] => return Err(format!("menu path segment {depth} was not found")), + _ => return Err(format!("menu path segment {depth} is ambiguous")), }; let target = &result.nodes[target_index]; let error = if target.enabled == Some(false) { @@ -218,7 +219,6 @@ pub fn invoke_menu_path(hwnd: u64, path: &[String]) -> Result<(), String> { } else { unsafe { invoke_menu_element(target.element_ptr, depth + 1 == path.len()) }.err() }; - unsafe { release_walk_nodes(result.nodes) }; if let Some(error) = error { return Err(format!("menu path segment {depth}: {error}")); } @@ -394,6 +394,7 @@ unsafe fn walk_tree_unsafe( 0, None, false, + true, &mut nodes, &mut lines, &mut counter, @@ -615,6 +616,7 @@ unsafe fn walk_root_by_pid( 0, None, false, + true, nodes, lines, counter, @@ -639,6 +641,7 @@ unsafe fn walk_cached( depth, None, false, + true, nodes, lines, counter, @@ -654,6 +657,7 @@ unsafe fn walk_cached_bounded( depth: usize, parent_index: Option, in_web_content: bool, + ancestors_enabled: bool, nodes: &mut Vec, lines: &mut Vec<(usize, String)>, counter: &mut usize, @@ -671,10 +675,12 @@ unsafe fn walk_cached_bounded( let value = read_cached_bstr_value(element); let automation_id = read_cached_bstr(element, UIA_AutomationIdPropertyId); let help_text = read_cached_bstr(element, UIA_HelpTextPropertyId); - let enabled = read_cached_bool(element, UIA_IsEnabledPropertyId); - // Missing UIA state must remain unknown on the structured observation - // surface. Action discovery keeps its historical best-effort assumption. - let is_enabled = enabled.unwrap_or(true); + let reported_enabled = read_cached_bool(element, UIA_IsEnabledPropertyId); + // UIA providers may report enabled children beneath a disabled parent. + // Those children cannot actually be acted on, so expose and index them + // according to their effective state. Missing state remains unknown only + // while every known ancestor is enabled. + let (enabled, is_enabled) = effective_enabled(reported_enabled, ancestors_enabled); let selected = read_cached_selected(element); let actions = detect_cached_actions(element, &control_type, is_enabled); let is_actionable = !actions.is_empty() && is_enabled; @@ -689,55 +695,43 @@ unsafe fn walk_cached_bounded( let mut emitted_parent: Option = parent_index; if is_actionable || has_content { - let retained: IUIAutomationElement = element.clone(); - let ptr = retained.as_raw() as usize; - std::mem::forget(retained); - - let node = if is_actionable { + let element_index = if is_actionable { let idx = *counter; *counter += 1; - let (center_x, center_y, rect) = read_cached_bounding_rect_full(element); emitted_parent = Some(idx); - UiaNode { - element_index: Some(idx), - control_type: control_type.clone(), - name: name.clone(), - value: value.clone(), - automation_id: automation_id.clone(), - help_text: help_text.clone(), - actions: actions.clone(), - enabled, - selected, - element_ptr: ptr, - center_x, - center_y, - rect, - msaa_role: None, - depth, - parent_element_index: parent_index, - in_web_content, - } + Some(idx) } else { - UiaNode { - element_index: None, - control_type: control_type.clone(), - name: name.clone(), - value: value.clone(), - automation_id: automation_id.clone(), - help_text: help_text.clone(), - actions: vec![], - enabled, - selected, - element_ptr: ptr, - center_x: 0, - center_y: 0, - rect: None, - msaa_role: None, - depth, - parent_element_index: parent_index, - in_web_content, - } + None }; + let (element_ptr, center_x, center_y, rect) = if element_index.is_some() { + let retained: IUIAutomationElement = element.clone(); + let ptr = retained.as_raw() as usize; + std::mem::forget(retained); + let (center_x, center_y, rect) = read_cached_bounding_rect_full(element); + (ptr, center_x, center_y, rect) + } else { + (0, 0, 0, None) + }; + let node = UiaNode { + element_index, + control_type: control_type.clone(), + name: name.clone(), + value: value.clone(), + automation_id: automation_id.clone(), + help_text: help_text.clone(), + actions: actions.clone(), + enabled, + selected, + element_ptr, + center_x, + center_y, + rect, + msaa_role: None, + depth, + parent_element_index: parent_index, + in_web_content, + }; + debug_assert_eq!(node.element_ptr != 0, node.element_index.is_some()); lines.push((depth, format_node_line(&node))); nodes.push(node); @@ -753,6 +747,7 @@ unsafe fn walk_cached_bounded( depth + 1, emitted_parent, in_web_content || control_type.eq_ignore_ascii_case("Document"), + is_enabled, nodes, lines, counter, @@ -765,6 +760,16 @@ unsafe fn walk_cached_bounded( } } +fn effective_enabled( + reported_enabled: Option, + ancestors_enabled: bool, +) -> (Option, bool) { + if !ancestors_enabled { + return (Some(false), false); + } + (reported_enabled, reported_enabled.unwrap_or(true)) +} + fn read_cached_control_type(element: &IUIAutomationElement) -> String { unsafe { element @@ -984,6 +989,7 @@ pub(crate) fn format_node_line(node: &UiaNode) -> String { if let Some(h) = &node.help_text { attrs.push(format!("help=\"{}\"", h)); } + attrs.extend(format_state_attrs(node)); if !node.actions.is_empty() { attrs.push(format!("actions=[{}]", node.actions.join(","))); } @@ -998,10 +1004,30 @@ pub(crate) fn format_node_line(node: &UiaNode) -> String { if let Some(v) = &node.value { s.push_str(&format!(" = \"{}\"", v)); } + let attrs = format_state_attrs(node); + if !attrs.is_empty() { + s.push_str(&format!(" [{}]", attrs.join(" "))); + } } s } +fn format_state_attrs(node: &UiaNode) -> Vec { + let mut attrs = Vec::new(); + if node.enabled == Some(false) { + attrs.push("enabled=false".into()); + } + if let Some(selected) = node.selected { + let state = if node.control_type.eq_ignore_ascii_case("CheckBox") { + "checked" + } else { + "selected" + }; + attrs.push(format!("{state}={selected}")); + } + attrs +} + fn render_lines(lines: &[(usize, String)]) -> String { let mut out = String::new(); for (depth, line) in lines { @@ -1055,3 +1081,97 @@ fn filter_tree(markdown: &str, query: &str) -> String { r.push('\n'); r } + +#[cfg(test)] +mod tests { + use super::*; + + pub(super) fn node( + element_index: Option, + control_type: &str, + name: &str, + value: Option<&str>, + enabled: Option, + selected: Option, + actions: &[&str], + ) -> UiaNode { + UiaNode { + element_index, + control_type: control_type.into(), + name: Some(name.into()), + value: value.map(Into::into), + automation_id: None, + help_text: None, + actions: actions.iter().map(|action| (*action).into()).collect(), + enabled, + selected, + element_ptr: usize::from(element_index.is_some()), + center_x: 0, + center_y: 0, + rect: None, + msaa_role: None, + depth: 0, + parent_element_index: None, + in_web_content: false, + } + } + + #[test] + fn disabled_ancestor_overrides_enabled_and_unknown_descendants() { + let (parent_state, parent_is_enabled) = effective_enabled(Some(false), true); + assert_eq!(parent_state, Some(false)); + assert!(!parent_is_enabled); + + let (enabled_child_state, enabled_child_is_enabled) = + effective_enabled(Some(true), parent_is_enabled); + assert_eq!(enabled_child_state, Some(false)); + assert!(!enabled_child_is_enabled); + + let (unknown_grandchild_state, unknown_grandchild_is_enabled) = + effective_enabled(None, enabled_child_is_enabled); + assert_eq!(unknown_grandchild_state, Some(false)); + assert!(!unknown_grandchild_is_enabled); + } + + #[test] + fn markdown_preserves_nesting_and_explains_disabled_selection_state() { + let parent = node(None, "Group", "Graphics", None, Some(false), None, &[]); + let child = node( + None, + "ListItem", + "Very High", + None, + Some(false), + Some(true), + &[], + ); + + let markdown = render_lines(&[ + (0, format_node_line(&parent)), + (1, format_node_line(&child)), + ]); + + assert_eq!( + markdown, + "- Group \"Graphics\" [enabled=false]\n - ListItem \"Very High\" [enabled=false selected=true]\n" + ); + } + + #[test] + fn markdown_labels_checkbox_toggle_state_as_checked() { + let checkbox = node( + Some(4), + "CheckBox", + "VSync", + None, + Some(true), + Some(false), + &["toggle"], + ); + + assert_eq!( + format_node_line(&checkbox), + "- [4] CheckBox \"VSync\" [checked=false actions=[toggle]]" + ); + } +}