diff --git a/one_collect/src/etw/mod.rs b/one_collect/src/etw/mod.rs index 5cc598a..2f8d3c2 100644 --- a/one_collect/src/etw/mod.rs +++ b/one_collect/src/etw/mod.rs @@ -15,8 +15,11 @@ use crate::Guid; #[allow(dead_code)] mod abi; mod events; +pub mod providers; pub mod tdh; +pub use providers::{RegisteredProvider, registered_providers}; + use abi::{ EVENT_RECORD, TraceSession, diff --git a/one_collect/src/etw/providers.rs b/one_collect/src/etw/providers.rs new file mode 100644 index 0000000..202b43e --- /dev/null +++ b/one_collect/src/etw/providers.rs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! # Registered ETW provider enumeration +//! +//! This module wraps the Windows Trace Data Helper (TDH) +//! `TdhEnumerateProviders` API to enumerate every provider registered in the +//! system provider database, returning a case-insensitive +//! `lowercased-name -> RegisteredProvider` map. +//! +//! The database is enumerated in a single pass, so resolving N provider names +//! costs one `TdhEnumerateProviders` call rather than N. Both manifest-based +//! providers (`SchemaSource == 0`) and classic MOF providers +//! (`SchemaSource == 1`) are included. + +use std::collections::HashMap; + +use crate::Guid; + +use windows_sys::Win32::System::Diagnostics::Etw::{ + PROVIDER_ENUMERATION_INFO, TRACE_PROVIDER_INFO, TdhEnumerateProviders, +}; + +/// A provider found in the OS-registered provider database. +#[derive(Clone, Copy)] +pub struct RegisteredProvider { + /// Control GUID of the registered provider. + pub guid: Guid, + /// TDH schema source: `0` = XML manifest, `1` = classic WMI/MOF. Retained + /// so callers can report which decoding model a matched provider uses. + pub schema_source: u32, +} + +/// Convert a Win32 `GUID` (from `windows-sys`) into a [`Guid`]. +/// +/// Both types are `#[repr(C)]` with identical `data1/2/3/4` fields, so this is +/// a straight field copy. +fn win32_guid_to_guid(g: &windows_sys::core::GUID) -> Guid { + Guid { + data1: g.data1, + data2: g.data2, + data3: g.data3, + data4: g.data4, + } +} + +/// Enumerate every provider registered in the system provider database via the +/// Windows TDH `TdhEnumerateProviders` API, returning a case-insensitive +/// `lowercased-name -> RegisteredProvider` map. +/// +/// The whole database is read **once**; callers should memoize the result so +/// resolving N configured names costs a single enumeration rather than N. +/// Includes both manifest-based providers (`SchemaSource == 0`) and classic MOF +/// providers (`SchemaSource == 1`). On a duplicate name the first entry wins. +/// +/// # Errors +/// +/// Returns an error only when the TDH API itself fails unexpectedly, so a +/// genuine lookup miss is never silently misresolved. +#[allow(unsafe_code)] +pub fn registered_providers() -> anyhow::Result> { + const ERROR_SUCCESS: u32 = 0; + const ERROR_INSUFFICIENT_BUFFER: u32 = 122; + + // First call with a null buffer to discover the required size. + let mut buffer_size: u32 = 0; + // SAFETY: The documented size-probe form; TDH writes only through + // `p_buffer_size` when the buffer pointer is null. + let status = unsafe { TdhEnumerateProviders(std::ptr::null_mut(), &mut buffer_size) }; + if status != ERROR_INSUFFICIENT_BUFFER && status != ERROR_SUCCESS { + anyhow::bail!("TdhEnumerateProviders (size probe) failed with Win32 error {status}"); + } + if (buffer_size as usize) < std::mem::size_of::() { + // Empty or header-less result: nothing to enumerate. + return Ok(HashMap::new()); + } + + // Allocate a `u32`-aligned buffer: `PROVIDER_ENUMERATION_INFO` and + // `TRACE_PROVIDER_INFO` both have 4-byte alignment (`u32` / `GUID` fields), + // which a `Vec` satisfies. `Vec` would only be byte-aligned, which + // is undefined behaviour to reinterpret as these structs. + let elem_count = (buffer_size as usize).div_ceil(std::mem::size_of::()); + let mut buffer: Vec = vec![0u32; elem_count]; + let byte_len = buffer.len() * std::mem::size_of::(); + + // SAFETY: `buffer` is at least `buffer_size` bytes and 4-byte aligned; TDH + // fills it with a `PROVIDER_ENUMERATION_INFO` header followed by the + // provider array. + let status = unsafe { + TdhEnumerateProviders( + buffer.as_mut_ptr() as *mut PROVIDER_ENUMERATION_INFO, + &mut buffer_size, + ) + }; + if status != ERROR_SUCCESS { + // A second `ERROR_INSUFFICIENT_BUFFER` can happen if providers were + // registered between the two calls; treat any non-success as a lookup + // failure so we never read a partially populated buffer. + anyhow::bail!("TdhEnumerateProviders (fill) failed with Win32 error {status}"); + } + + // Byte view for reading UTF-16 provider names by their buffer offset. + // SAFETY: `buffer` owns `byte_len` valid, initialized bytes. + let bytes: &[u8] = + unsafe { std::slice::from_raw_parts(buffer.as_ptr() as *const u8, byte_len) }; + + // SAFETY: the buffer starts with a valid `PROVIDER_ENUMERATION_INFO` + // (guaranteed at least header-sized above) and is 4-byte aligned. + let header = unsafe { &*(buffer.as_ptr() as *const PROVIDER_ENUMERATION_INFO) }; + let count = header.NumberOfProviders as usize; + + // Defensive bound: the provider array must fit within the buffer TDH + // reported. `count` comes from the OS-populated header; clamp it to what + // the buffer can actually hold so the `array_ptr.add(i)` reads below stay + // in-bounds even if the header and buffer size were ever inconsistent. + let array_offset = std::mem::size_of::() + .saturating_sub(std::mem::size_of::()); + let max_entries = + byte_len.saturating_sub(array_offset) / std::mem::size_of::(); + let count = count.min(max_entries); + + // Base pointer of the trailing flexible `TRACE_PROVIDER_INFO` array. The + // binding types it as `[_; 1]`; take a raw pointer to the field and index + // from it rather than materializing an out-of-bounds `&[_; 1]`. Forming a + // raw pointer to a place is safe; the later dereferences below are not. + let array_ptr = (&raw const header.TraceProviderInfoArray).cast::(); + + let mut map: HashMap = HashMap::with_capacity(count); + for i in 0..count { + // SAFETY: `i < count == NumberOfProviders`, so element `i` lies within + // the buffer TDH populated. + let info = unsafe { &*array_ptr.add(i) }; + let name_offset = info.ProviderNameOffset as usize; + // A zero or out-of-range offset means the entry has no usable name. + if name_offset == 0 || name_offset >= byte_len { + continue; + } + // Skip empty names: a `ProviderNameOffset` that points straight at a + // UTF-16 NUL decodes to `""`, which would otherwise insert a bogus + // empty-key lookup target from a malformed/partial TDH entry. + let Some(provider_name) = read_utf16z(bytes, name_offset).filter(|s| !s.is_empty()) else { + continue; + }; + let provider = RegisteredProvider { + guid: win32_guid_to_guid(&info.ProviderGuid), + schema_source: info.SchemaSource, + }; + // First entry wins on duplicate names. + let _ = map + .entry(provider_name.to_ascii_lowercase()) + .or_insert(provider); + } + + Ok(map) +} + +/// Read a null-terminated UTF-16 (little-endian) string starting at +/// `byte_offset` within `bytes`, stopping at the terminator or the end of the +/// buffer. Returns `None` if the offset leaves no room for even one code unit. +fn read_utf16z(bytes: &[u8], byte_offset: usize) -> Option { + if byte_offset + 1 >= bytes.len() { + return None; + } + let mut units = Vec::new(); + let mut i = byte_offset; + while i + 1 < bytes.len() { + let unit = u16::from_le_bytes([bytes[i], bytes[i + 1]]); + if unit == 0 { + break; + } + units.push(unit); + i += 2; + } + Some(String::from_utf16_lossy(&units)) +} \ No newline at end of file diff --git a/one_collect/src/etw/tdh.rs b/one_collect/src/etw/tdh.rs index 911e232..6089d42 100644 --- a/one_collect/src/etw/tdh.rs +++ b/one_collect/src/etw/tdh.rs @@ -1644,4 +1644,70 @@ mod tests { assert_eq!(cache.index_of_manifest(&key, false), None); assert_eq!(cache.index_of_manifest(&key, true), None); } + + /// `read_decoding_source` must reject a buffer smaller than + /// `TRACE_EVENT_INFO` (its bounds check guards the subsequent + /// `*const TRACE_EVENT_INFO` cast) and, for a well-formed buffer, + /// return the exact `DecodingSource` value stored in it. + #[test] + fn read_decoding_source_bounds_and_value() { + // Under-sized buffer → Malformed, and (crucially) the cast is never + // reached, so an unaligned `Vec` here is fine. + let too_small = vec![0u8; std::mem::size_of::() - 1]; + match read_decoding_source(&too_small) { + Err(TdhDecodeError::Malformed(_)) => {} // expected + other => panic!("expected Malformed for under-sized buffer, got: {other:?}"), + } + + // Value path: build a real, properly-aligned `TRACE_EVENT_INFO` on the + // stack, then view it as bytes. Deriving the slice from the struct's + // own address guarantees the alignment `read_decoding_source` requires. + fn tei_bytes(tei: &TRACE_EVENT_INFO) -> &[u8] { + // SAFETY: `tei` is a live `TRACE_EVENT_INFO`, so the pointer is + // valid, aligned, and spans exactly `size_of::()` + // initialized bytes for the borrow's lifetime. + unsafe { + std::slice::from_raw_parts( + tei as *const TRACE_EVENT_INFO as *const u8, + std::mem::size_of::(), + ) + } + } + + let mut tei: TRACE_EVENT_INFO = unsafe { std::mem::zeroed() }; + + tei.DecodingSource = DecodingSourceXMLFile; + assert_eq!(read_decoding_source(tei_bytes(&tei)).unwrap(), DecodingSourceXMLFile); + + tei.DecodingSource = DecodingSourceTlg; + assert_eq!(read_decoding_source(tei_bytes(&tei)).unwrap(), DecodingSourceTlg); + } + + /// TraceLogging inserts mirror the manifest single-hash vacancy semantics: + /// a duplicate `insert_tl` returns the existing index without growing the + /// arena (no orphaned schema), and the 32-/64-bit maps stay separate + /// keyspaces for identical bytes. + #[test] + fn insert_tl_duplicate_key_returns_existing_index() { + let mk = |name: &str| CachedSchema { + event_name: name.to_string(), + format: EventFormat::new(), + schema_id: SchemaId(0), + }; + + let mut cache = SchemaCache::new(); + + let idx = cache.insert_tl(vec![9, 8, 7], false, mk("first")); + assert_eq!(cache.index_of_tl(&[9, 8, 7], false), Some(idx)); + + // Duplicate insert returns the existing index and drops the freshly + // built schema — the arena must not grow. + let before = cache.schemas.len(); + assert_eq!(cache.insert_tl(vec![9, 8, 7], false, mk("dup")), idx); + assert_eq!(cache.schemas.len(), before, "duplicate must not grow arena"); + assert_eq!(cache.get(idx).event_name, "first", "original schema preserved"); + + // The 32-bit map is a separate keyspace: the same bytes miss there. + assert_eq!(cache.index_of_tl(&[9, 8, 7], true), None); + } } diff --git a/one_collect/tests/etw_tdh_integration.rs b/one_collect/tests/etw_tdh_integration.rs index d192e58..88d9d42 100644 --- a/one_collect/tests/etw_tdh_integration.rs +++ b/one_collect/tests/etw_tdh_integration.rs @@ -3,13 +3,21 @@ //! End-to-end integration tests for the runtime TDH decoder. //! -//! These tests register a real TraceLogging ETW provider (using the +//! Most tests register a real TraceLogging ETW provider (using the //! `tracelogging` and `tracelogging_dynamic` crates), emit known events //! through ETW, capture them inside an [`EtwSession`], decode them with //! [`TdhDecoder`], and assert that the decoded field values match what //! was written. //! -//! Both tests are marked `#[ignore]` because the consumer side of ETW +//! The final test ([`tdh_decodes_manifest_kernel_process_events`]) +//! instead exercises the *manifest* decode path against a live, +//! always-registered OS provider (`Microsoft-Windows-Kernel-Process`). +//! It cannot fabricate a registered manifest, so it triggers real +//! process-lifetime events by spawning short-lived child processes and +//! asserts that they decode through `TdhGetEventInformation` into a real +//! `EventFormat` with a Task-derived event name. +//! +//! All tests are marked `#[ignore]` because the consumer side of ETW //! requires administrative privileges (`SeSystemProfilePrivilege` / //! `SeDebugPrivilege`). Run manually from an elevated shell with: //! @@ -46,6 +54,21 @@ use tracelogging_dynamic as tld; /// event subscribes to via `MatchAnyKeyword`). const TEST_KEYWORD: u64 = 0x1; +/// GUID of the always-registered `Microsoft-Windows-Kernel-Process` +/// manifest provider, `{22FB2CD6-0E7B-422B-A0C7-2FAD1FD0E716}`. +/// +/// Used by the manifest-path integration test: it is present on every +/// supported Windows SKU, is a normal (non-legacy-kernel-logger) manifest +/// provider that a user-mode session can enable, and emits Task-bearing +/// `ProcessStart` / `ProcessStop` events that the test triggers on demand +/// by spawning child processes. +const KERNEL_PROCESS_PROVIDER: u128 = 0x22FB2CD6_0E7B_422B_A0C7_2FAD1FD0E716; + +/// Task names the Kernel-Process manifest assigns to process-lifetime +/// events (Ids 1 and 2). Both carry a Task, so the decoder's +/// `TaskNameOffset` path must surface them as the event name. +const KERNEL_PROCESS_TASKS: &[&str] = &["ProcessStart", "ProcessStop"]; + /// Hard upper bound on a single test run — should never be reached when /// running on a healthy ETW subsystem. const TEST_TIMEOUT: Duration = Duration::from_secs(30); @@ -982,3 +1005,97 @@ fn tdh_decodes_tracelogging_static_nested_struct() { assert_nested_event(find_event(&captured, "EventNested")); } + +// ── Test E: manifest decoding (live OS provider) ───────────────────── + +/// Verifies the TDH decoder resolves and decodes *manifest-based* events +/// end-to-end against a live, OS-registered provider. +/// +/// Unlike the TraceLogging tests, a manifest schema cannot be fabricated +/// in-process — it lives in an XML manifest registered with the OS. This +/// test therefore uses `Microsoft-Windows-Kernel-Process`, which is +/// always registered, and triggers real `ProcessStart` / `ProcessStop` +/// events by spawning short-lived child processes. +/// +/// The capture sink only records events that `TdhDecoder::decode` +/// returns `Ok` for, so every captured event is proof the manifest path +/// (classic-header reject → `TdhGetEventInformation` → `DecodingSource == +/// DecodingSourceXMLFile` → schema build) succeeded. The assertions +/// additionally prove: +/// +/// * the manifest resolved to a real field layout (non-empty fields), and +/// * the `TaskNameOffset` event-name path works (a `ProcessStart` / +/// `ProcessStop` Task name is surfaced). +#[ignore] +#[test] +fn tdh_decodes_manifest_kernel_process_events() { + let provider_guid = Guid::from_u128(KERNEL_PROCESS_PROVIDER); + + let (captured, counter, callback) = make_capture_sink(); + + let mut session = build_capturing_session( + provider_guid, + "OneCollect.TdhIntegration.Manifest.Wide", + callback, + ); + + // Trigger process-lifetime events once the session has enabled the + // provider. `started_callback` fires on the parse worker thread + // after `EnableTraceEx2` returns for the Kernel-Process provider, so + // any child process spawned afterwards is guaranteed to be observed. + // + // Kernel-Process emits (among others): + // + // ProcessStart (Id 1, Task "ProcessStart") — manifest event with + // fields such as ProcessID, ImageName, ... + // ProcessStop (Id 2, Task "ProcessStop") + // + // Each `cmd /c exit` produces one start and one stop, so a small + // spawn loop comfortably clears the capture threshold. + session.add_started_callback(move |_ctx| { + std::thread::spawn(move || { + let deadline = Instant::now() + TEST_TIMEOUT; + while Instant::now() < deadline { + let _ = std::process::Command::new("cmd.exe") + .args(["/c", "exit"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + std::thread::sleep(Duration::from_millis(25)); + } + }); + }); + + // Collect a batch so the sample is very likely to include Task-bearing + // ProcessStart / ProcessStop events (rather than only Task-less ones). + let captured = drive_to_completion( + session, + "one_collect_tdh_manifest", + &captured, + counter, + 10, + ); + + // Every captured event already proves an `Ok` manifest decode; assert + // at least one resolved to a real manifest field layout. + assert!( + captured.iter().any(|e| !e.field_names.is_empty()), + "expected at least one manifest event with a resolved field layout; \ + saw names: {:?}", + captured.iter().map(|e| &e.name).collect::>() + ); + + // Assert the `TaskNameOffset` path surfaced a Task-derived name for a + // known process-lifetime event. This is the manifest-specific + // event-name branch (`DecodingSource != DecodingSourceTlg`). + assert!( + captured + .iter() + .any(|e| KERNEL_PROCESS_TASKS.contains(&e.name.as_str())), + "expected a Task-named ProcessStart/ProcessStop manifest event; \ + saw names: {:?}", + captured.iter().map(|e| &e.name).collect::>() + ); +} +