From e2b3d9bb25882bc6d0b8ffecc0635ed3ab667c7d Mon Sep 17 00:00:00 2001 From: "Swapnil Ashtekar (from Dev Box)" Date: Mon, 20 Jul 2026 16:43:36 -0700 Subject: [PATCH 1/8] Enhance TDH decoder to support manifest-based events and improve schema caching logic --- one_collect/src/etw/tdh.rs | 497 ++++++++++++++++++++++++++++++------- 1 file changed, 411 insertions(+), 86 deletions(-) diff --git a/one_collect/src/etw/tdh.rs b/one_collect/src/etw/tdh.rs index 444a4d99..d5d729a7 100644 --- a/one_collect/src/etw/tdh.rs +++ b/one_collect/src/etw/tdh.rs @@ -4,20 +4,28 @@ //! # TDH-Based Dynamic Event Decoder //! //! This module provides [`TdhDecoder`], a runtime schema decoder for -//! TraceLogging and TraceLoggingDynamic ETW events. It uses the Windows -//! Trace Data Helper (TDH) APIs to discover event schemas on the fly and -//! converts them into the standard [`EventFormat`] / [`EventData`] -//! representation used throughout one_collect. +//! TraceLogging, TraceLoggingDynamic, and manifest-based ETW events. It +//! uses the Windows Trace Data Helper (TDH) APIs to discover event schemas +//! on the fly and converts them into the standard [`EventFormat`] / +//! [`EventData`] representation used throughout one_collect. //! //! ## Design //! -//! The decoder caches the [`EventFormat`] directly, keyed by the raw -//! TraceLogging schema bytes and pointer width. Because the format uses -//! the framework's standard `LocationType` conventions (`StaticString`, -//! `StaticUTF16String`, etc. with `size = 0` for variable-length fields), -//! the cached `EventFormat` is schema-stable: it doesn't depend on any -//! particular event's payload bytes. A cache hit collapses to a hashmap -//! probe + `EventData::new` — effectively zero per-event overhead. +//! The decoder caches the [`EventFormat`] directly. TraceLogging schemas +//! are keyed by their raw inline schema bytes; manifest schemas by their +//! `(Provider GUID, Id, Version)` identity. Both keyspaces are split by +//! pointer width and index into one shared, append-only arena of decoded +//! schemas. Because the format uses the framework's standard `LocationType` +//! conventions (`StaticString`, `StaticUTF16String`, etc. with `size = 0` +//! for variable-length fields), the cached `EventFormat` is schema-stable: +//! it doesn't depend on any particular event's payload bytes. A cache hit +//! collapses to a hashmap probe + `EventData::new` — effectively zero +//! per-event overhead. +//! +//! Source selection is transparent: [`TdhDecoder::decode`] takes the +//! TraceLogging path when an inline SCHEMA_TL item is present and the +//! manifest path otherwise. `TdhGetEventInformation` is the same entry +//! point for both, so the byte-level decoding is entirely source-agnostic. //! //! Variable-length field resolution (scanning for null terminators, reading //! length prefixes) is handled lazily by the framework's existing @@ -26,15 +34,42 @@ //! //! ## Scope //! -//! - **Supported**: TraceLogging and TraceLoggingDynamic events, nested -//! struct fields (flattened with dot-notation names), basic scalar and -//! string property types, 32-bit and 64-bit event payloads. +//! - **Supported**: TraceLogging, TraceLoggingDynamic, and manifest-based +//! events, nested struct fields (flattened with dot-notation names), basic +//! scalar and string property types, 32-bit and 64-bit event payloads. +//! +//! - **Not supported**: classic (MOF/WBEM) and WPP events (fast-rejected / +//! returned as `Unsupported`). +//! +//! - **Not yet supported** (future work): map / enum value resolution, +//! array-typed properties, and properties whose length or count is given +//! by another property. //! -//! - **Not yet supported** (future work): manifest-based event decoding, -//! map / enum value resolution, array-typed properties, and properties -//! whose length or count is given by another property. +//! ## "Manifest" here means *OS-registered*, not EventSource in-band +//! +//! The manifest path resolves schemas exclusively through +//! `TdhGetEventInformation`, which consults manifests registered with the OS +//! (`HKLM\...\WINEVT\Publishers`, the `wevtutil im` model — e.g. +//! `Microsoft-Windows-Kernel-*` and the .NET runtime providers). It does +//! **not** support .NET `EventSource` providers running in their default +//! manifest mode, where the manifest is *not* OS-registered but is emitted +//! **in-band** as special chunked "manifest events" (`EVENT_DESCRIPTOR.Id == +//! 0xFFFE`, `Opcode == 0xFE`). For those providers `TdhGetEventInformation` +//! returns `ERROR_NOT_FOUND`, so `decode()` returns +//! [`TdhDecodeError::NotFound`]. Decoding them requires capturing and +//! reassembling the in-band manifest chunks and parsing the XML — a separate +//! subsystem tracked as future work. Custom `EventSource` providers that use +//! *self-describing* (TraceLogging) mode are decoded via the TraceLogging +//! path and are unaffected. +//! +//! Manifest decoding requires the provider's manifest to be registered on +//! the *decoding* machine; if it isn't, `TdhGetEventInformation` returns +//! `ERROR_NOT_FOUND` and `decode()` returns [`TdhDecodeError::NotFound`]. +//! Negative results are not cached, so a manifest registered after the +//! decoder starts is picked up on the next matching event. use super::abi::{EVENT_RECORD, EventRecordExt}; +use crate::Guid; use crate::event::{EventData, EventField, EventFormat, LocationType}; use std::collections::HashMap; @@ -50,6 +85,11 @@ use windows_sys::Win32::System::Diagnostics::Etw::{ TdhGetEventInformation, EVENT_HEADER_EXT_TYPE_EVENT_SCHEMA_TL, + // Decoding-source discrimination (TraceLogging vs. manifest/WPP). + DECODING_SOURCE, + DecodingSourceTlg, + DecodingSourceXMLFile, + TDH_INTYPE_UNICODESTRING, TDH_INTYPE_ANSISTRING, TDH_INTYPE_INT8, @@ -89,6 +129,15 @@ use windows_sys::Win32::Foundation::{ERROR_INSUFFICIENT_BUFFER, ERROR_NOT_FOUND} /// `EVENT_HEADER_FLAG_32_BIT_HEADER` from the Windows SDK. const EVENT_HEADER_FLAG_32_BIT_HEADER: u16 = 0x0020; +/// `EVENT_HEADER_FLAG_CLASSIC_HEADER` from the Windows SDK. +/// +/// Set for classic (MOF/WBEM) events, whose identity is an event-class +/// GUID + Opcode rather than the `(Provider, Id, Version)` tuple used for +/// manifest events. Such events must never take the manifest path: their +/// `EVENT_DESCRIPTOR.Id` is commonly `0`, so multiple distinct classic +/// events from one provider would collapse onto the same `ManifestKey`. +const EVENT_HEADER_FLAG_CLASSIC_HEADER: u16 = 0x0100; + // Aliases for PROPERTY_FLAGS constants (i32 in windows-sys) to keep // call-site flag checks concise. const PROPERTY_STRUCT: i32 = PropertyStruct; @@ -103,9 +152,19 @@ const PROPERTY_PARAM_COUNT: i32 = PropertyParamCount; /// Errors that can occur during TDH-based schema decoding. #[derive(Debug)] pub enum TdhDecodeError { - /// No `EVENT_HEADER_EXT_TYPE_EVENT_SCHEMA_TL` extended-data item was - /// found on the event. + /// No decodable schema was found for the event. + /// + /// For TraceLogging this means no inline + /// `EVENT_HEADER_EXT_TYPE_EVENT_SCHEMA_TL` extended-data item was + /// present. For manifest events this also covers "manifest not + /// registered on this machine" (`TdhGetEventInformation` returns + /// `ERROR_NOT_FOUND`) and classic (MOF/WBEM) events, which are + /// fast-rejected before the manifest path. NotFound, + /// The event's schema source is recognised but not supported by this + /// decoder (e.g. WPP or WBEM decoding sources reached via the manifest + /// dispatch path). + Unsupported, /// A Win32 error code was returned by `TdhGetEventInformation`. Win32(u32), /// The `TRACE_EVENT_INFO` returned by TDH is structurally invalid. @@ -115,7 +174,8 @@ pub enum TdhDecodeError { impl std::fmt::Display for TdhDecodeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::NotFound => write!(f, "no schema TL extended-data item found on event"), + Self::NotFound => write!(f, "no decodable schema found for event"), + Self::Unsupported => write!(f, "event schema source is not supported"), Self::Win32(code) => write!(f, "TdhGetEventInformation failed with Win32 error {code}"), Self::Malformed(msg) => write!(f, "malformed TRACE_EVENT_INFO: {msg}"), } @@ -130,7 +190,7 @@ impl std::error::Error for TdhDecodeError {} /// framework's `try_get_field_data_closure` can resolve lazily. #[derive(Clone)] struct CachedSchema { - /// The TraceLogging event name. + /// The event name (TraceLogging event name or manifest task name). event_name: String, /// Schema-stable `EventFormat` — field offsets are absolute for /// fixed-size fields, and the framework's skip chain handles @@ -143,50 +203,85 @@ struct CachedSchema { /// Hash builder using XxHash64, matching the rest of the ETW module. type XxBuildHasher = BuildHasherDefault; -/// Schema cache: an append-only `Vec` of decoded schemas plus two maps -/// (32-bit / 64-bit) that key raw TL bytes to an index into that `Vec`. +/// Identity of a manifest-based event's schema. +/// +/// Manifest events do not carry their layout inline; instead the OS holds a +/// registered manifest that pins the `(Provider GUID, Id, Version)` tuple to +/// a fixed layout. That tuple is therefore the natural cache key, mirroring +/// how TraceLogging keys on its inline schema bytes. +#[derive(Copy, Clone, PartialEq, Eq, Hash)] +struct ManifestKey { + /// Provider GUID from `EVENT_HEADER.ProviderId`. + provider: Guid, + /// Event ID from `EVENT_DESCRIPTOR.Id`. + id: u16, + /// Manifest version from `EVENT_DESCRIPTOR.Version`. A layout change + /// bumps this, yielding a distinct key. + version: u8, +} + +/// Schema cache: one append-only `Vec` arena of decoded schemas shared by +/// both schema sources, indexed by per-source, per-pointer-width maps. +/// +/// TraceLogging is keyed by its raw inline schema bytes; manifest events by +/// their `(Provider, Id, Version)` [`ManifestKey`]. The two sources use +/// different key *types*, so they need separate maps (a `HashMap` is +/// monomorphic in its key), but the decoded [`CachedSchema`] is source- +/// agnostic, so storage stays unified in one arena. Because the arena is +/// append-only, an index handed out by any map stays valid for the life of +/// the cache, and TL and manifest entries can never alias the same slot. /// /// Storing a `Copy` index (rather than the schema inline in the map) lets the -/// hot path hash the 50-500 byte key exactly once: the index is copied out, -/// ending the map borrow, and the schema is then read from `schemas`. The -/// previous `get().is_none()` + `get().expect()` pattern hashed the key twice -/// on every cache hit (a borrow-checker workaround for get-or-insert). +/// hot path hash the key exactly once: the index is copied out, ending the +/// map borrow, and the schema is then read from `schemas`. struct SchemaCache { /// Decoded schemas, indexed by the maps below. Append-only: entries are /// never removed, so an index stays valid for the life of the cache. schemas: Vec, - cache_64: HashMap, usize, XxBuildHasher>, - cache_32: HashMap, usize, XxBuildHasher>, + /// TraceLogging schema bytes → arena index (64-bit / 32-bit payloads). + tl_64: HashMap, usize, XxBuildHasher>, + tl_32: HashMap, usize, XxBuildHasher>, + /// Manifest `(Provider, Id, Version)` → arena index (64/32-bit payloads). + manifest_64: HashMap, + manifest_32: HashMap, } impl SchemaCache { fn new() -> Self { Self { schemas: Vec::new(), - cache_64: HashMap::with_hasher(XxBuildHasher::default()), - cache_32: HashMap::with_hasher(XxBuildHasher::default()), + tl_64: HashMap::with_hasher(XxBuildHasher::default()), + tl_32: HashMap::with_hasher(XxBuildHasher::default()), + manifest_64: HashMap::with_hasher(XxBuildHasher::default()), + manifest_32: HashMap::with_hasher(XxBuildHasher::default()), } } - /// Returns the index of a cached schema, or `None` on a miss. - /// The key is hashed exactly once. - fn index_of(&self, key: &[u8], is_32bit: bool) -> Option { - let map = if is_32bit { &self.cache_32 } else { &self.cache_64 }; + /// Returns the index of a cached TraceLogging schema, or `None` on a + /// miss. The key is hashed exactly once. + fn index_of_tl(&self, key: &[u8], is_32bit: bool) -> Option { + let map = if is_32bit { &self.tl_32 } else { &self.tl_64 }; + map.get(key).copied() + } + + /// Returns the index of a cached manifest schema, or `None` on a miss. + fn index_of_manifest(&self, key: &ManifestKey, is_32bit: bool) -> Option { + let map = if is_32bit { &self.manifest_32 } else { &self.manifest_64 }; map.get(key).copied() } - /// Maps `key` to a schema index and returns it. Intended for the cache - /// miss path, where `key` is not yet present. + /// Maps a TraceLogging `key` to a schema index and returns it. Intended + /// for the cache miss path, where `key` is not yet present. /// /// The schema is only pushed onto `schemas` when the key is actually /// vacant, so a (contract-violating) duplicate key returns the existing /// index without orphaning a freshly built schema. The key is hashed /// once via a single `entry` lookup. - fn insert(&mut self, key: Vec, is_32bit: bool, schema: CachedSchema) -> usize { + fn insert_tl(&mut self, key: Vec, is_32bit: bool, schema: CachedSchema) -> usize { use std::collections::hash_map::Entry; let idx = self.schemas.len(); - let map = if is_32bit { &mut self.cache_32 } else { &mut self.cache_64 }; + let map = if is_32bit { &mut self.tl_32 } else { &mut self.tl_64 }; match map.entry(key) { // Already present: return the existing index and drop `schema`. Entry::Occupied(e) => *e.get(), @@ -198,10 +293,28 @@ impl SchemaCache { } } + /// Maps a manifest `key` to a schema index and returns it. See + /// [`insert_tl`] for the vacancy / single-hash semantics. + fn insert_manifest(&mut self, key: ManifestKey, is_32bit: bool, schema: CachedSchema) -> usize { + use std::collections::hash_map::Entry; + + let idx = self.schemas.len(); + let map = if is_32bit { &mut self.manifest_32 } else { &mut self.manifest_64 }; + match map.entry(key) { + Entry::Occupied(e) => *e.get(), + Entry::Vacant(e) => { + let assigned = *e.insert(idx); // ends the map borrow + self.schemas.push(schema); // only push once actually indexed + assigned + } + } + } + /// Reads a cached schema by its index. /// - /// `idx` always comes from `index_of` or `insert`, and `schemas` is - /// append-only (entries are never removed), so the index never dangles. + /// `idx` always comes from an `index_of_*` or `insert_*` call, and + /// `schemas` is append-only (entries are never removed), so the index + /// never dangles. fn get(&self, idx: usize) -> &CachedSchema { &self.schemas[idx] } @@ -211,24 +324,26 @@ impl SchemaCache { /// Result of a successful [`TdhDecoder::decode`] call. /// -/// Contains the decoded [`EventData`], the TraceLogging `event_name`, +/// Contains the decoded [`EventData`], the resolved `event_name`, /// and a monotonic [`SchemaId`] that exporters can use as a cheap /// lookup key for format registration. #[non_exhaustive] pub struct TdhDecodedEvent<'a> { /// The decoded event data. pub event_data: EventData<'a>, - /// The TraceLogging event name, or `None` if the schema has no name. + /// The resolved event name, or `None` if the schema has no name. /// - /// This is the primary identity for TraceLogging events (as opposed - /// to the event ID, which is often 0). Consumers can use this for - /// OTEL log record naming without a second cache probe. + /// For TraceLogging this is the event name (the primary identity, as + /// the event ID is often 0); for manifest events it is the task name. + /// Consumers can use this for OTEL log record naming without a second + /// cache probe. pub event_name: Option<&'a str>, /// Monotonic identifier for this event's schema. /// - /// Each distinct `(schema_tl_bytes, pointer_width)` pair gets a unique - /// `SchemaId` on first insertion into the cache. The same ID is - /// returned on every subsequent cache hit. Exporters can use this as + /// Each distinct schema (identified by its inline TraceLogging bytes or + /// its manifest `(Provider, Id, Version)` tuple, split by pointer width) + /// gets a unique `SchemaId` on first insertion into the cache. The same + /// ID is returned on every subsequent cache hit. Exporters can use this as /// a cheap lookup key to avoid re-registering the `EventFormat`: /// /// ```ignore @@ -247,7 +362,8 @@ pub struct TdhDecodedEvent<'a> { #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub struct SchemaId(u64); -/// Runtime decoder for TraceLogging / TraceLoggingDynamic ETW events. +/// Runtime decoder for TraceLogging, TraceLoggingDynamic, and +/// manifest-based ETW events. /// /// Caches the `EventFormat` directly per schema. Cache hits are a /// hashmap probe + `EventData::new` with no per-event allocation. @@ -271,8 +387,15 @@ impl TdhDecoder { /// Decodes an `EVENT_RECORD` into a [`TdhDecodedEvent`]. /// + /// The schema source is selected automatically: an event carrying an + /// inline `EVENT_HEADER_EXT_TYPE_EVENT_SCHEMA_TL` item takes the + /// TraceLogging path; otherwise the event is treated as manifest-based + /// and resolved through its `(Provider, Id, Version)` identity. Both + /// paths converge on the same `TdhGetEventInformation` walker and the + /// same [`EventData`] construction. + /// /// The returned [`TdhDecodedEvent`] contains the decoded - /// [`EventData`], the TraceLogging `event_name`, and a monotonic + /// [`EventData`], the resolved `event_name`, and a monotonic /// [`SchemaId`] that exporters can use as a cheap lookup key for /// format registration. pub fn decode<'a>( @@ -280,27 +403,13 @@ impl TdhDecoder { record: &'a EVENT_RECORD, ) -> Result, TdhDecodeError> { let is_32bit = (record.EventHeader.Flags & EVENT_HEADER_FLAG_32_BIT_HEADER) != 0; - let schema_tl_bytes = find_schema_tl(record)?; - - // Single hash lookup on the hot path; the miss path builds and inserts, - // returning the index of the new schema. - let idx = match self.cache.index_of(schema_tl_bytes, is_32bit) { - Some(idx) => idx, - None => { - call_tdh_get_event_information(record, &mut self.tei_buf)?; - let mut schema = build_cached_schema(self.tei_buf.as_bytes(), is_32bit)?; - let id = self.next_schema_id; - self.next_schema_id = id.wrapping_add(1); - schema.schema_id = SchemaId(id); - debug!( - event_name = %schema.event_name, - field_count = schema.format.fields().len(), - schema_id = id, - is_32bit, - "TDH schema cache miss — new schema cached" - ); - self.cache.insert(schema_tl_bytes.to_vec(), is_32bit, schema) - } + + // Source selection: inline SCHEMA_TL → TraceLogging; otherwise the + // event is manifest-based. `usize` is `Copy`, so the mutable borrow + // of `self` in each helper ends before `self.cache.get(idx)` below. + let idx = match find_schema_tl(record) { + Some(schema_tl_bytes) => self.decode_tracelogging(record, schema_tl_bytes, is_32bit)?, + None => self.decode_manifest(record, is_32bit)?, }; let schema = self.cache.get(idx); @@ -323,6 +432,97 @@ impl TdhDecoder { schema_id: schema.schema_id, }) } + + /// TraceLogging cache lookup / miss handling. Returns the arena index + /// of the (possibly newly built) schema. + fn decode_tracelogging( + &mut self, + record: &EVENT_RECORD, + schema_tl_bytes: &[u8], + is_32bit: bool, + ) -> Result { + if let Some(idx) = self.cache.index_of_tl(schema_tl_bytes, is_32bit) { + return Ok(idx); + } + call_tdh_get_event_information(record, &mut self.tei_buf)?; + let mut schema = build_cached_schema(self.tei_buf.as_bytes(), is_32bit)?; + schema.schema_id = SchemaId(self.assign_schema_id()); + debug!( + event_name = %schema.event_name, + field_count = schema.format.fields().len(), + schema_id = schema.schema_id.0, + is_32bit, + "TDH schema cache miss — new TraceLogging schema cached" + ); + Ok(self.cache.insert_tl(schema_tl_bytes.to_vec(), is_32bit, schema)) + } + + /// Manifest cache lookup / miss handling. Returns the arena index of + /// the (possibly newly built) schema. + /// + /// Two guards keep non-manifest sources out of the manifest cache: + /// + /// 1. **Classic/MOF fast reject** — if `EVENT_HEADER_FLAG_CLASSIC_HEADER` + /// is set, the event's identity is a class GUID + Opcode, not the + /// `(Provider, Id, Version)` tuple; its `Id` is commonly `0`, so + /// multiple such events would collapse onto one key. Reject before + /// calling TDH. + /// 2. **Decoding-source check** — after `TdhGetEventInformation`, only a + /// `DecodingSourceXMLFile` result is a genuine XML manifest. WPP and + /// WBEM results are returned as [`TdhDecodeError::Unsupported`] rather + /// than cached under a `ManifestKey`. + fn decode_manifest( + &mut self, + record: &EVENT_RECORD, + is_32bit: bool, + ) -> Result { + // Guard 1: classic (MOF/WBEM) events are not manifest events. + if (record.EventHeader.Flags & EVENT_HEADER_FLAG_CLASSIC_HEADER) != 0 { + return Err(TdhDecodeError::NotFound); + } + + let desc = &record.EventHeader.EventDescriptor; + let key = ManifestKey { + provider: record.provider_guid(), + id: desc.Id, + version: desc.Version, + }; + + if let Some(idx) = self.cache.index_of_manifest(&key, is_32bit) { + return Ok(idx); + } + + call_tdh_get_event_information(record, &mut self.tei_buf)?; + + // Guard 2: only cache genuine XML-manifest schemas. A `not + // TraceLogging` event can still be WPP/WBEM, whose identity semantics + // differ from `ManifestKey`; those are unsupported here. + let decoding_source = read_decoding_source(self.tei_buf.as_bytes())?; + if decoding_source != DecodingSourceXMLFile { + debug!(decoding_source, "TDH manifest path — unsupported decoding source"); + return Err(TdhDecodeError::Unsupported); + } + + let mut schema = build_cached_schema(self.tei_buf.as_bytes(), is_32bit)?; + schema.schema_id = SchemaId(self.assign_schema_id()); + debug!( + event_name = %schema.event_name, + id = key.id, + version = key.version, + field_count = schema.format.fields().len(), + schema_id = schema.schema_id.0, + is_32bit, + "TDH schema cache miss — new manifest schema cached" + ); + Ok(self.cache.insert_manifest(key, is_32bit, schema)) + } + + /// Allocates the next monotonic `SchemaId` value. + fn assign_schema_id(&mut self) -> u64 { + let id = self.next_schema_id; + self.next_schema_id = id.wrapping_add(1); + id + } } impl Default for TdhDecoder { @@ -543,19 +743,33 @@ fn walk_properties( // ── Internal helpers ──────────────────────────────────────────────── /// Finds the TraceLogging schema metadata in the event's extended-data. -fn find_schema_tl<'a>(record: &'a EVENT_RECORD) -> Result<&'a [u8], TdhDecodeError> { +/// +/// Returns `None` when the event carries no (or an empty) SCHEMA_TL item, +/// which is the signal for [`TdhDecoder::decode`] to take the manifest path. +fn find_schema_tl(record: &EVENT_RECORD) -> Option<&[u8]> { let item_ptr = record - .find_extended_data(EVENT_HEADER_EXT_TYPE_EVENT_SCHEMA_TL as u16) - .ok_or(TdhDecodeError::NotFound)?; + .find_extended_data(EVENT_HEADER_EXT_TYPE_EVENT_SCHEMA_TL as u16)?; let item = unsafe { &*item_ptr }; if item.DataPtr == 0 || item.DataSize == 0 { - return Err(TdhDecodeError::NotFound); + return None; } - Ok(unsafe { + Some(unsafe { std::slice::from_raw_parts(item.DataPtr as *const u8, item.DataSize as usize) }) } +/// Reads `TRACE_EVENT_INFO.DecodingSource` from a filled TEI buffer. +/// +/// Used by the manifest dispatch path to reject non-XML-manifest sources +/// (WPP / WBEM) before they are cached under a `ManifestKey`. +fn read_decoding_source(tei_buf: &[u8]) -> Result { + if tei_buf.len() < std::mem::size_of::() { + return Err(TdhDecodeError::Malformed("buffer smaller than TRACE_EVENT_INFO")); + } + let tei = unsafe { &*(tei_buf.as_ptr() as *const TRACE_EVENT_INFO) }; + Ok(tei.DecodingSource) +} + /// Aligned buffer for `TRACE_EVENT_INFO`. struct AlignedTeiBuf { storage: Vec, @@ -633,13 +847,24 @@ const _: () = assert!( "TRACE_EVENT_INFO_0 union must remain 4 bytes", ); -/// Reads the TraceLogging event name from `TRACE_EVENT_INFO`. +/// Reads the event name from `TRACE_EVENT_INFO`, selecting the offset by +/// decoding source. +/// +/// TraceLogging carries the name at `EventNameOffset`; manifest and WPP +/// events instead expose it as the task name at `TaskNameOffset`. The +/// dispatch in [`TdhDecoder::decode`] only builds manifest schemas for +/// `DecodingSourceXMLFile`, so in practice this reads `EventNameOffset` for +/// TraceLogging and `TaskNameOffset` for manifest events. fn read_event_name(tei_buf: &[u8], tei: &TRACE_EVENT_INFO) -> String { - // SAFETY: Both arms of `TRACE_EVENT_INFO_0` are `u32` at the same - // offset, so either arm always yields a valid bit pattern. The - // const assertion above guards the 4-byte size against future - // `windows-sys` layout drift. - let name_offset = unsafe { tei.Anonymous1.EventNameOffset } as usize; + let name_offset = if tei.DecodingSource == DecodingSourceTlg { + // SAFETY: Both arms of `TRACE_EVENT_INFO_0` are `u32` at the same + // offset, so either arm always yields a valid bit pattern. The + // const assertion above guards the 4-byte size against future + // `windows-sys` layout drift. + unsafe { tei.Anonymous1.EventNameOffset as usize } + } else { + tei.TaskNameOffset as usize + }; read_utf16_at(tei_buf, name_offset) } @@ -1095,9 +1320,11 @@ mod tests { #[test] fn tdh_decode_not_found_without_schema_tl() { - // An EVENT_RECORD with no extended data items should return - // TdhDecodeError::NotFound — the public-API contract for - // manifest-based events or records without TraceLogging metadata. + // A non-classic EVENT_RECORD with no SCHEMA_TL item takes the + // manifest path. With a zeroed (unregistered) provider GUID, + // TdhGetEventInformation returns ERROR_NOT_FOUND, which surfaces as + // TdhDecodeError::NotFound — the public-API contract for events + // whose schema can't be resolved on this machine. let mut record: EVENT_RECORD = unsafe { std::mem::zeroed() }; record.ExtendedDataCount = 0; record.ExtendedData = std::ptr::null_mut(); @@ -1151,4 +1378,102 @@ mod tests { let r32b = decoder.decode(&record_32b).expect("32-bit cache hit"); assert_eq!(r32b.schema_id, id_32, "32-bit cache hit should return same ID"); } + + // ── Manifest-path tests ───────────────────────────────────────── + + /// Classic (MOF/WBEM) events must be fast-rejected before any TDH call: + /// their identity is a class GUID + Opcode, not `(Provider, Id, Version)`, + /// and their `Id` is commonly 0, so routing them through the manifest + /// cache would collapse distinct events onto one key. + #[test] + fn tdh_decode_classic_header_rejected_before_tdh() { + let mut record: EVENT_RECORD = unsafe { std::mem::zeroed() }; + record.ExtendedDataCount = 0; + record.ExtendedData = std::ptr::null_mut(); + record.EventHeader.Flags |= EVENT_HEADER_FLAG_CLASSIC_HEADER; + + let mut decoder = TdhDecoder::new(); + match decoder.decode(&record) { + Err(TdhDecodeError::NotFound) => {} // expected — rejected by guard 1 + Err(other) => panic!("expected NotFound for classic header, got: {other}"), + Ok(_) => panic!("expected NotFound error, but decode succeeded"), + } + } + + /// `read_event_name` must pick `EventNameOffset` for TraceLogging and + /// `TaskNameOffset` for manifest events, selected by `DecodingSource`. + #[test] + fn read_event_name_selects_offset_by_decoding_source() { + fn push_utf16(buf: &mut Vec, s: &str) { + for u in s.encode_utf16() { + buf.extend_from_slice(&u.to_le_bytes()); + } + buf.extend_from_slice(&[0, 0]); // UTF-16 null terminator + } + + // Layout: [TRACE_EVENT_INFO-sized header][EventName][TaskName]. + let mut buf = vec![0u8; std::mem::size_of::()]; + let event_off = buf.len(); + push_utf16(&mut buf, "EventName"); + let task_off = buf.len(); + push_utf16(&mut buf, "TaskName"); + + let mut tei: TRACE_EVENT_INFO = unsafe { std::mem::zeroed() }; + tei.TaskNameOffset = task_off as u32; + // Writing a union field is safe; only reads are `unsafe`. + tei.Anonymous1.EventNameOffset = event_off as u32; + + tei.DecodingSource = DecodingSourceTlg; + assert_eq!(read_event_name(&buf, &tei), "EventName"); + + tei.DecodingSource = DecodingSourceXMLFile; + assert_eq!(read_event_name(&buf, &tei), "TaskName"); + } + + /// The manifest maps and the TraceLogging maps share one append-only + /// arena but are indexed by different key types. Verify that: entries + /// never alias across sources, the 32-/64-bit split is honoured, and a + /// duplicate manifest insert returns the existing index. + #[test] + fn manifest_cache_shared_arena_and_pointer_split() { + let mk = |name: &str| CachedSchema { + event_name: name.to_string(), + format: EventFormat::new(), + schema_id: SchemaId(0), + }; + let key = ManifestKey { + provider: Guid::from_u128(0x1122_3344_5566_7788_99AA_BBCC_DDEE_FF00), + id: 5, + version: 2, + }; + + let mut cache = SchemaCache::new(); + + // 64-bit insert, then hit. + let idx_a = cache.insert_manifest(key, false, mk("A")); + assert_eq!(cache.index_of_manifest(&key, false), Some(idx_a)); + + // The 32-bit map is a separate keyspace: same key misses there. + assert_eq!(cache.index_of_manifest(&key, true), None); + let idx_b = cache.insert_manifest(key, true, mk("B")); + assert_ne!(idx_a, idx_b, "32-/64-bit split must yield distinct slots"); + + // TraceLogging shares the arena but is indexed separately. + let tl_idx = cache.insert_tl(vec![1, 2, 3], false, mk("C")); + assert_ne!(tl_idx, idx_a); + assert_ne!(tl_idx, idx_b); + assert_eq!(cache.index_of_tl(&[1, 2, 3], false), Some(tl_idx)); + + // Each index reads back its own schema — no cross-source aliasing. + assert_eq!(cache.get(idx_a).event_name, "A"); + assert_eq!(cache.get(idx_b).event_name, "B"); + assert_eq!(cache.get(tl_idx).event_name, "C"); + + // A duplicate manifest insert returns the existing index and drops + // the freshly built schema (no arena orphan). + let before = cache.schemas.len(); + assert_eq!(cache.insert_manifest(key, false, mk("dup")), idx_a); + assert_eq!(cache.schemas.len(), before, "duplicate must not grow arena"); + assert_eq!(cache.get(idx_a).event_name, "A", "original schema preserved"); + } } From b65c4e6c9d8a75a7ef828aa01aaf9f032ec576ec Mon Sep 17 00:00:00 2001 From: "Swapnil Ashtekar (from Dev Box)" Date: Mon, 20 Jul 2026 23:47:40 -0700 Subject: [PATCH 2/8] Add negative caching for permanently unsupported manifest keys in TDH decoder --- one_collect/src/etw/tdh.rs | 82 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/one_collect/src/etw/tdh.rs b/one_collect/src/etw/tdh.rs index d5d729a7..9bdcc1c1 100644 --- a/one_collect/src/etw/tdh.rs +++ b/one_collect/src/etw/tdh.rs @@ -72,7 +72,7 @@ use super::abi::{EVENT_RECORD, EventRecordExt}; use crate::Guid; use crate::event::{EventData, EventField, EventFormat, LocationType}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::hash::BuildHasherDefault; use tracing::{debug, trace, warn}; use twox_hash::XxHash64; @@ -209,6 +209,14 @@ type XxBuildHasher = BuildHasherDefault; /// registered manifest that pins the `(Provider GUID, Id, Version)` tuple to /// a fixed layout. That tuple is therefore the natural cache key, mirroring /// how TraceLogging keys on its inline schema bytes. +/// +/// Note: the derived `Hash` delegates to [`Guid`]'s `Hash` impl, which hashes +/// only the first three GUID fields (`data1`/`data2`/`data3`), not the +/// trailing `data4` bytes. Equality is still full (derived `Eq` compares all +/// fields), so correctness is unaffected — but two provider GUIDs differing +/// only in their last 8 bytes share a hash bucket. Provider GUIDs vary in +/// their leading fields in practice, so this is benign; it is called out here +/// so a future change to `Guid`'s `Hash` isn't made without weighing this key. #[derive(Copy, Clone, PartialEq, Eq, Hash)] struct ManifestKey { /// Provider GUID from `EVENT_HEADER.ProviderId`. @@ -244,6 +252,14 @@ struct SchemaCache { /// Manifest `(Provider, Id, Version)` → arena index (64/32-bit payloads). manifest_64: HashMap, manifest_32: HashMap, + /// Manifest keys whose decoding source is permanently unsupported + /// (WPP / WBEM reached via the manifest dispatch path). Caching these + /// avoids a `TdhGetEventInformation` round trip on every subsequent event + /// from a chatty unsupported provider. Pointer-width independent: the + /// decoding source is a property of the provider's registration, not the + /// payload width. Only *permanent* negatives live here — `NotFound` is + /// never cached, since a manifest can register after the decoder starts. + manifest_unsupported: HashSet, } impl SchemaCache { @@ -254,6 +270,7 @@ impl SchemaCache { tl_32: HashMap::with_hasher(XxBuildHasher::default()), manifest_64: HashMap::with_hasher(XxBuildHasher::default()), manifest_32: HashMap::with_hasher(XxBuildHasher::default()), + manifest_unsupported: HashSet::with_hasher(XxBuildHasher::default()), } } @@ -270,6 +287,16 @@ impl SchemaCache { map.get(key).copied() } + /// Returns `true` if `key` has been recorded as permanently unsupported. + fn is_manifest_unsupported(&self, key: &ManifestKey) -> bool { + self.manifest_unsupported.contains(key) + } + + /// Records `key` as permanently unsupported (WPP / WBEM decoding source). + fn mark_manifest_unsupported(&mut self, key: ManifestKey) { + self.manifest_unsupported.insert(key); + } + /// Maps a TraceLogging `key` to a schema index and returns it. Intended /// for the cache miss path, where `key` is not yet present. /// @@ -469,8 +496,11 @@ impl TdhDecoder { /// calling TDH. /// 2. **Decoding-source check** — after `TdhGetEventInformation`, only a /// `DecodingSourceXMLFile` result is a genuine XML manifest. WPP and - /// WBEM results are returned as [`TdhDecodeError::Unsupported`] rather - /// than cached under a `ManifestKey`. + /// WBEM results are returned as [`TdhDecodeError::Unsupported`] and the + /// key is recorded in a negative cache, so a chatty unsupported + /// provider pays the `TdhGetEventInformation` round trip only once + /// rather than on every event. `NotFound` (unregistered manifest) is + /// *not* negatively cached, since it can flip once a manifest registers. fn decode_manifest( &mut self, record: &EVENT_RECORD, @@ -492,6 +522,12 @@ impl TdhDecoder { return Ok(idx); } + // Negative cache: keys proven permanently unsupported skip the TDH + // call entirely. + if self.cache.is_manifest_unsupported(&key) { + return Err(TdhDecodeError::Unsupported); + } + call_tdh_get_event_information(record, &mut self.tei_buf)?; // Guard 2: only cache genuine XML-manifest schemas. A `not @@ -500,6 +536,7 @@ impl TdhDecoder { let decoding_source = read_decoding_source(self.tei_buf.as_bytes())?; if decoding_source != DecodingSourceXMLFile { debug!(decoding_source, "TDH manifest path — unsupported decoding source"); + self.cache.mark_manifest_unsupported(key); return Err(TdhDecodeError::Unsupported); } @@ -766,6 +803,16 @@ fn read_decoding_source(tei_buf: &[u8]) -> Result() { return Err(TdhDecodeError::Malformed("buffer smaller than TRACE_EVENT_INFO")); } + // SAFETY: the cast to `*const TRACE_EVENT_INFO` requires the buffer to be + // suitably aligned. Callers pass the `AlignedTeiBuf` backing store, which + // is `Vec`-backed and therefore 8-byte aligned (>= the alignment of + // `TRACE_EVENT_INFO`). The debug assert guards against a future caller + // handing in an unaligned slice. + debug_assert_eq!( + tei_buf.as_ptr() as usize % std::mem::align_of::(), + 0, + "tei_buf must be aligned for TRACE_EVENT_INFO" + ); let tei = unsafe { &*(tei_buf.as_ptr() as *const TRACE_EVENT_INFO) }; Ok(tei.DecodingSource) } @@ -1476,4 +1523,33 @@ mod tests { assert_eq!(cache.schemas.len(), before, "duplicate must not grow arena"); assert_eq!(cache.get(idx_a).event_name, "A", "original schema preserved"); } + + /// The negative cache records permanently-unsupported manifest keys and + /// is pointer-width independent (the decoding source doesn't depend on + /// payload width). Verify a marked key reports unsupported regardless of + /// the `is_32bit` probe and that an unmarked key does not. + #[test] + fn manifest_unsupported_negative_cache() { + let key = ManifestKey { + provider: Guid::from_u128(0x0011_2233_4455_6677_8899_AABB_CCDD_EEFF), + id: 7, + version: 1, + }; + let other = ManifestKey { + provider: key.provider, + id: 8, + version: 1, + }; + + let mut cache = SchemaCache::new(); + assert!(!cache.is_manifest_unsupported(&key)); + + cache.mark_manifest_unsupported(key); + assert!(cache.is_manifest_unsupported(&key), "marked key must report unsupported"); + assert!(!cache.is_manifest_unsupported(&other), "distinct key unaffected"); + + // Marking is not a positive-cache entry: index lookups still miss. + assert_eq!(cache.index_of_manifest(&key, false), None); + assert_eq!(cache.index_of_manifest(&key, true), None); + } } From 19ae1fe3d517cc896abe3068c9cbb9962f70c8fb Mon Sep 17 00:00:00 2001 From: "Swapnil Ashtekar (from Dev Box)" Date: Wed, 22 Jul 2026 11:23:39 -0700 Subject: [PATCH 3/8] Refactor TDH decoder comments for clarity and update error handling for unsupported events --- one_collect/src/etw/tdh.rs | 94 ++++++++++++++++++++++++-------------- 1 file changed, 60 insertions(+), 34 deletions(-) diff --git a/one_collect/src/etw/tdh.rs b/one_collect/src/etw/tdh.rs index 9bdcc1c1..bb137c4a 100644 --- a/one_collect/src/etw/tdh.rs +++ b/one_collect/src/etw/tdh.rs @@ -19,7 +19,7 @@ //! conventions (`StaticString`, `StaticUTF16String`, etc. with `size = 0` //! for variable-length fields), the cached `EventFormat` is schema-stable: //! it doesn't depend on any particular event's payload bytes. A cache hit -//! collapses to a hashmap probe + `EventData::new` — effectively zero +//! collapses to a hashmap probe + `EventData::new`, effectively zero //! per-event overhead. //! //! Source selection is transparent: [`TdhDecoder::decode`] takes the @@ -49,7 +49,7 @@ //! //! The manifest path resolves schemas exclusively through //! `TdhGetEventInformation`, which consults manifests registered with the OS -//! (`HKLM\...\WINEVT\Publishers`, the `wevtutil im` model — e.g. +//! (`HKLM\...\WINEVT\Publishers`, the `wevtutil im` model, e.g. //! `Microsoft-Windows-Kernel-*` and the .NET runtime providers). It does //! **not** support .NET `EventSource` providers running in their default //! manifest mode, where the manifest is *not* OS-registered but is emitted @@ -57,7 +57,7 @@ //! 0xFFFE`, `Opcode == 0xFE`). For those providers `TdhGetEventInformation` //! returns `ERROR_NOT_FOUND`, so `decode()` returns //! [`TdhDecodeError::NotFound`]. Decoding them requires capturing and -//! reassembling the in-band manifest chunks and parsing the XML — a separate +//! reassembling the in-band manifest chunks and parsing the XML, a separate //! subsystem tracked as future work. Custom `EventSource` providers that use //! *self-describing* (TraceLogging) mode are decoded via the TraceLogging //! path and are unaffected. @@ -151,6 +151,7 @@ const PROPERTY_PARAM_COUNT: i32 = PropertyParamCount; /// Errors that can occur during TDH-based schema decoding. #[derive(Debug)] +#[non_exhaustive] pub enum TdhDecodeError { /// No decodable schema was found for the event. /// @@ -158,12 +159,12 @@ pub enum TdhDecodeError { /// `EVENT_HEADER_EXT_TYPE_EVENT_SCHEMA_TL` extended-data item was /// present. For manifest events this also covers "manifest not /// registered on this machine" (`TdhGetEventInformation` returns - /// `ERROR_NOT_FOUND`) and classic (MOF/WBEM) events, which are - /// fast-rejected before the manifest path. + /// `ERROR_NOT_FOUND`). NotFound, /// The event's schema source is recognised but not supported by this - /// decoder (e.g. WPP or WBEM decoding sources reached via the manifest - /// dispatch path). + /// decoder (e.g. classic (MOF/WBEM) events, which are fast-rejected + /// before the manifest path, or WPP and WBEM decoding sources reached + /// via the manifest dispatch path). Unsupported, /// A Win32 error code was returned by `TdhGetEventInformation`. Win32(u32), @@ -192,7 +193,7 @@ impl std::error::Error for TdhDecodeError {} struct CachedSchema { /// The event name (TraceLogging event name or manifest task name). event_name: String, - /// Schema-stable `EventFormat` — field offsets are absolute for + /// Schema-stable `EventFormat`: field offsets are absolute for /// fixed-size fields, and the framework's skip chain handles /// variable-length fields lazily via `size = 0`. format: EventFormat, @@ -213,7 +214,7 @@ type XxBuildHasher = BuildHasherDefault; /// Note: the derived `Hash` delegates to [`Guid`]'s `Hash` impl, which hashes /// only the first three GUID fields (`data1`/`data2`/`data3`), not the /// trailing `data4` bytes. Equality is still full (derived `Eq` compares all -/// fields), so correctness is unaffected — but two provider GUIDs differing +/// fields), so correctness is unaffected, but two provider GUIDs differing /// only in their last 8 bytes share a hash bucket. Provider GUIDs vary in /// their leading fields in practice, so this is benign; it is called out here /// so a future change to `Guid`'s `Hash` isn't made without weighing this key. @@ -257,7 +258,7 @@ struct SchemaCache { /// avoids a `TdhGetEventInformation` round trip on every subsequent event /// from a chatty unsupported provider. Pointer-width independent: the /// decoding source is a property of the provider's registration, not the - /// payload width. Only *permanent* negatives live here — `NotFound` is + /// payload width. Only *permanent* negatives live here; `NotFound` is /// never cached, since a manifest can register after the decoder starts. manifest_unsupported: HashSet, } @@ -364,6 +365,12 @@ pub struct TdhDecodedEvent<'a> { /// the event ID is often 0); for manifest events it is the task name. /// Consumers can use this for OTEL log record naming without a second /// cache probe. + /// + /// Note: for manifest events this is the Task name, which is not unique + /// per event (multiple events can share a Task, e.g. Start/Stop/Info + /// under one Task all report the same name). Identity-sensitive + /// consumers (dedup, routing, record identity) should use `schema_id` + /// (or the event's `Id`/`Opcode`) rather than treating the name as unique. pub event_name: Option<&'a str>, /// Monotonic identifier for this event's schema. /// @@ -489,12 +496,12 @@ impl TdhDecoder { /// /// Two guards keep non-manifest sources out of the manifest cache: /// - /// 1. **Classic/MOF fast reject** — if `EVENT_HEADER_FLAG_CLASSIC_HEADER` + /// 1. **Classic/MOF fast reject**: if `EVENT_HEADER_FLAG_CLASSIC_HEADER` /// is set, the event's identity is a class GUID + Opcode, not the /// `(Provider, Id, Version)` tuple; its `Id` is commonly `0`, so - /// multiple such events would collapse onto one key. Reject before - /// calling TDH. - /// 2. **Decoding-source check** — after `TdhGetEventInformation`, only a + /// multiple such events would collapse onto one key. Rejected as + /// [`TdhDecodeError::Unsupported`] before calling TDH. + /// 2. **Decoding-source check**: after `TdhGetEventInformation`, only a /// `DecodingSourceXMLFile` result is a genuine XML manifest. WPP and /// WBEM results are returned as [`TdhDecodeError::Unsupported`] and the /// key is recorded in a negative cache, so a chatty unsupported @@ -508,7 +515,7 @@ impl TdhDecoder { ) -> Result { // Guard 1: classic (MOF/WBEM) events are not manifest events. if (record.EventHeader.Flags & EVENT_HEADER_FLAG_CLASSIC_HEADER) != 0 { - return Err(TdhDecodeError::NotFound); + return Err(TdhDecodeError::Unsupported); } let desc = &record.EventHeader.EventDescriptor; @@ -804,22 +811,29 @@ fn read_decoding_source(tei_buf: &[u8]) -> Result`-backed and therefore 8-byte aligned (>= the alignment of - // `TRACE_EVENT_INFO`). The debug assert guards against a future caller - // handing in an unaligned slice. - debug_assert_eq!( - tei_buf.as_ptr() as usize % std::mem::align_of::(), - 0, - "tei_buf must be aligned for TRACE_EVENT_INFO" - ); + // suitably aligned. Callers pass the `AlignedTeiBuf` backing store, whose + // element type satisfies `TRACE_EVENT_INFO`'s alignment (guaranteed by the + // const assertion next to `AlignedTeiBuf`). let tei = unsafe { &*(tei_buf.as_ptr() as *const TRACE_EVENT_INFO) }; Ok(tei.DecodingSource) } +/// Backing element type for [`AlignedTeiBuf`]. Its alignment must satisfy +/// `TRACE_EVENT_INFO`'s (checked by the const assertion below). +type TeiBufElem = u64; + +// Guarantees every `AlignedTeiBuf` data pointer is suitably aligned for the +// `*const TRACE_EVENT_INFO` casts in `read_decoding_source` and +// `build_cached_schema`, so those reads stay sound. If the backing element +// type ever changes to something under-aligned, the build fails here. +const _: () = assert!( + std::mem::align_of::() >= std::mem::align_of::(), + "AlignedTeiBuf backing element must satisfy TRACE_EVENT_INFO alignment" +); + /// Aligned buffer for `TRACE_EVENT_INFO`. struct AlignedTeiBuf { - storage: Vec, + storage: Vec, len: usize, } @@ -829,9 +843,10 @@ impl AlignedTeiBuf { } fn ensure_capacity(&mut self, byte_count: usize) { - let u64_count = (byte_count + 7) / 8; - if self.storage.len() < u64_count { - self.storage.resize(u64_count, 0u64); + let elem_size = std::mem::size_of::(); + let elem_count = (byte_count + elem_size - 1) / elem_size; + if self.storage.len() < elem_count { + self.storage.resize(elem_count, 0); } } @@ -887,11 +902,22 @@ fn call_tdh_get_event_information( // `TRACE_EVENT_INFO_0` is a union with two `u32` arms // (`EventNameOffset` and `ActivityIDNameOffset`) at the same offset. // Either arm always yields a valid bit pattern for a `u32`. +// +// Pin the union's layout to `u32`'s (both size *and* alignment) so the +// `tei.Anonymous1.EventNameOffset` read stays sound: a future `windows-sys` +// bump that grows the union or changes its alignment fails the build here +// rather than silently mis-reading the field. const _: () = assert!( std::mem::size_of::< windows_sys::Win32::System::Diagnostics::Etw::TRACE_EVENT_INFO_0 - >() == 4, - "TRACE_EVENT_INFO_0 union must remain 4 bytes", + >() == std::mem::size_of::(), + "TRACE_EVENT_INFO_0 union must stay the size of a u32", +); +const _: () = assert!( + std::mem::align_of::< + windows_sys::Win32::System::Diagnostics::Etw::TRACE_EVENT_INFO_0 + >() == std::mem::align_of::(), + "TRACE_EVENT_INFO_0 union must stay u32-aligned", ); /// Reads the event name from `TRACE_EVENT_INFO`, selecting the offset by @@ -1019,7 +1045,7 @@ mod tests { assert_eq!(intype_to_type_name(999), "unsupported"); } - /// `TDH_INTYPE_BOOLEAN` is a Win32 `BOOL` — a 32-bit value + /// `TDH_INTYPE_BOOLEAN` is a Win32 `BOOL`, a 32-bit value /// (TraceLogging `bool32`). Mapping it as `u8` (its previous /// behaviour) would mis-size every subsequent field in the same /// event. TraceLogging encodes 1-byte booleans as @@ -1441,9 +1467,9 @@ mod tests { let mut decoder = TdhDecoder::new(); match decoder.decode(&record) { - Err(TdhDecodeError::NotFound) => {} // expected — rejected by guard 1 - Err(other) => panic!("expected NotFound for classic header, got: {other}"), - Ok(_) => panic!("expected NotFound error, but decode succeeded"), + Err(TdhDecodeError::Unsupported) => {} // expected — rejected by guard 1 + Err(other) => panic!("expected Unsupported for classic header, got: {other}"), + Ok(_) => panic!("expected Unsupported error, but decode succeeded"), } } From 46aa0dff6aca16a8adebb757371371af1b71fe9a Mon Sep 17 00:00:00 2001 From: "Swapnil Ashtekar (from Dev Box)" Date: Thu, 23 Jul 2026 18:00:11 -0700 Subject: [PATCH 4/8] Rename ManifestKey to ManifestEventKey for clarity and update related references --- one_collect/src/etw/tdh.rs | 226 ++++++++++++++++++++++++------------- 1 file changed, 146 insertions(+), 80 deletions(-) diff --git a/one_collect/src/etw/tdh.rs b/one_collect/src/etw/tdh.rs index bb137c4a..911e2328 100644 --- a/one_collect/src/etw/tdh.rs +++ b/one_collect/src/etw/tdh.rs @@ -135,7 +135,7 @@ const EVENT_HEADER_FLAG_32_BIT_HEADER: u16 = 0x0020; /// GUID + Opcode rather than the `(Provider, Id, Version)` tuple used for /// manifest events. Such events must never take the manifest path: their /// `EVENT_DESCRIPTOR.Id` is commonly `0`, so multiple distinct classic -/// events from one provider would collapse onto the same `ManifestKey`. +/// events from one provider would collapse onto the same `ManifestEventKey`. const EVENT_HEADER_FLAG_CLASSIC_HEADER: u16 = 0x0100; // Aliases for PROPERTY_FLAGS constants (i32 in windows-sys) to keep @@ -219,7 +219,7 @@ type XxBuildHasher = BuildHasherDefault; /// their leading fields in practice, so this is benign; it is called out here /// so a future change to `Guid`'s `Hash` isn't made without weighing this key. #[derive(Copy, Clone, PartialEq, Eq, Hash)] -struct ManifestKey { +struct ManifestEventKey { /// Provider GUID from `EVENT_HEADER.ProviderId`. provider: Guid, /// Event ID from `EVENT_DESCRIPTOR.Id`. @@ -233,7 +233,7 @@ struct ManifestKey { /// both schema sources, indexed by per-source, per-pointer-width maps. /// /// TraceLogging is keyed by its raw inline schema bytes; manifest events by -/// their `(Provider, Id, Version)` [`ManifestKey`]. The two sources use +/// their `(Provider, Id, Version)` [`ManifestEventKey`]. The two sources use /// different key *types*, so they need separate maps (a `HashMap` is /// monomorphic in its key), but the decoded [`CachedSchema`] is source- /// agnostic, so storage stays unified in one arena. Because the arena is @@ -251,8 +251,8 @@ struct SchemaCache { tl_64: HashMap, usize, XxBuildHasher>, tl_32: HashMap, usize, XxBuildHasher>, /// Manifest `(Provider, Id, Version)` → arena index (64/32-bit payloads). - manifest_64: HashMap, - manifest_32: HashMap, + manifest_64: HashMap, + manifest_32: HashMap, /// Manifest keys whose decoding source is permanently unsupported /// (WPP / WBEM reached via the manifest dispatch path). Caching these /// avoids a `TdhGetEventInformation` round trip on every subsequent event @@ -260,7 +260,7 @@ struct SchemaCache { /// decoding source is a property of the provider's registration, not the /// payload width. Only *permanent* negatives live here; `NotFound` is /// never cached, since a manifest can register after the decoder starts. - manifest_unsupported: HashSet, + manifest_unsupported: HashSet, } impl SchemaCache { @@ -283,18 +283,18 @@ impl SchemaCache { } /// Returns the index of a cached manifest schema, or `None` on a miss. - fn index_of_manifest(&self, key: &ManifestKey, is_32bit: bool) -> Option { + fn index_of_manifest(&self, key: &ManifestEventKey, is_32bit: bool) -> Option { let map = if is_32bit { &self.manifest_32 } else { &self.manifest_64 }; map.get(key).copied() } /// Returns `true` if `key` has been recorded as permanently unsupported. - fn is_manifest_unsupported(&self, key: &ManifestKey) -> bool { + fn is_manifest_unsupported(&self, key: &ManifestEventKey) -> bool { self.manifest_unsupported.contains(key) } /// Records `key` as permanently unsupported (WPP / WBEM decoding source). - fn mark_manifest_unsupported(&mut self, key: ManifestKey) { + fn mark_manifest_unsupported(&mut self, key: ManifestEventKey) { self.manifest_unsupported.insert(key); } @@ -323,7 +323,7 @@ impl SchemaCache { /// Maps a manifest `key` to a schema index and returns it. See /// [`insert_tl`] for the vacancy / single-hash semantics. - fn insert_manifest(&mut self, key: ManifestKey, is_32bit: bool, schema: CachedSchema) -> usize { + fn insert_manifest(&mut self, key: ManifestEventKey, is_32bit: bool, schema: CachedSchema) -> usize { use std::collections::hash_map::Entry; let idx = self.schemas.len(); @@ -396,6 +396,19 @@ pub struct TdhDecodedEvent<'a> { #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub struct SchemaId(u64); +/// The schema source of an event, paired with the cache key used to look it +/// up and (on a miss) insert it. +/// +/// Classifying the source up front lets [`TdhDecoder::decode`] own the entire +/// cache interaction — lookup, miss dispatch, and insert — in one place, so +/// the `decode_*` helpers are reached only when a *real* decode is required. +enum SchemaSource<'a> { + /// Inline TraceLogging schema, keyed by its raw schema bytes. + TraceLogging(&'a [u8]), + /// Manifest event, keyed by its `(Provider, Id, Version)` identity. + Manifest(ManifestEventKey), +} + /// Runtime decoder for TraceLogging, TraceLoggingDynamic, and /// manifest-based ETW events. /// @@ -428,6 +441,14 @@ impl TdhDecoder { /// paths converge on the same `TdhGetEventInformation` walker and the /// same [`EventData`] construction. /// + /// All cache interaction is centralized here: the source is classified, + /// the cache is probed, and only on a miss is a `decode_*` helper called + /// to perform a real decode. The freshly built schema is then assigned a + /// `SchemaId` and inserted — so the helpers never touch the positive + /// cache themselves. This keeps a single insertion point, which future + /// sources (e.g. injected EventSource manifests) can reuse without going + /// through a `decode_*` path. + /// /// The returned [`TdhDecodedEvent`] contains the decoded /// [`EventData`], the resolved `event_name`, and a monotonic /// [`SchemaId`] that exporters can use as a cheap lookup key for @@ -438,12 +459,44 @@ impl TdhDecoder { ) -> Result, TdhDecodeError> { let is_32bit = (record.EventHeader.Flags & EVENT_HEADER_FLAG_32_BIT_HEADER) != 0; - // Source selection: inline SCHEMA_TL → TraceLogging; otherwise the - // event is manifest-based. `usize` is `Copy`, so the mutable borrow - // of `self` in each helper ends before `self.cache.get(idx)` below. - let idx = match find_schema_tl(record) { - Some(schema_tl_bytes) => self.decode_tracelogging(record, schema_tl_bytes, is_32bit)?, - None => self.decode_manifest(record, is_32bit)?, + // Classify the schema source and compute its cache key. This borrows + // only `record`, never `self`, so the mutable `self` borrows below are + // unconstrained. + // + // Classic (MOF/WBEM) events are rejected here rather than in the + // manifest path: their identity is a class GUID + Opcode, not the + // `(Provider, Id, Version)` tuple (their `Id` is commonly `0`, so + // multiple such events would collapse onto one key), so they can never + // form a valid `ManifestEventKey`. + let source = match find_schema_tl(record) { + Some(schema_tl_bytes) => SchemaSource::TraceLogging(schema_tl_bytes), + None => { + if (record.EventHeader.Flags & EVENT_HEADER_FLAG_CLASSIC_HEADER) != 0 { + return Err(TdhDecodeError::Unsupported); + } + let desc = &record.EventHeader.EventDescriptor; + SchemaSource::Manifest(ManifestEventKey { + provider: record.provider_guid(), + id: desc.Id, + version: desc.Version, + }) + } + }; + + // Centralized caching: a hit reuses the arena slot; a miss performs a + // real decode, assigns a `SchemaId`, and inserts under the key. The + // `usize` index is `Copy`, so every `self` borrow ends before the + // `self.cache.get(idx)` read below. + let idx = match self.lookup_cached(&source, is_32bit)? { + Some(idx) => idx, + None => { + let mut schema = match &source { + SchemaSource::TraceLogging(_) => self.decode_tracelogging(record, is_32bit)?, + SchemaSource::Manifest(key) => self.decode_manifest(record, is_32bit, key)?, + }; + schema.schema_id = SchemaId(self.assign_schema_id()); + self.insert_cached(source, is_32bit, schema) + } }; let schema = self.cache.get(idx); @@ -467,98 +520,111 @@ impl TdhDecoder { }) } - /// TraceLogging cache lookup / miss handling. Returns the arena index - /// of the (possibly newly built) schema. + /// Looks up a classified schema `source` in the cache. + /// + /// Returns `Ok(Some(idx))` on a hit, `Ok(None)` on a miss (the caller + /// should perform a real decode), or `Err(Unsupported)` when a manifest + /// key is present in the negative cache. The negative cache is checked + /// here so a chatty unsupported provider skips the `TdhGetEventInformation` + /// round trip on every event after the first. + fn lookup_cached( + &self, + source: &SchemaSource, + is_32bit: bool, + ) -> Result, TdhDecodeError> { + match source { + SchemaSource::TraceLogging(bytes) => Ok(self.cache.index_of_tl(bytes, is_32bit)), + SchemaSource::Manifest(key) => { + if let Some(idx) = self.cache.index_of_manifest(key, is_32bit) { + Ok(Some(idx)) + } else if self.cache.is_manifest_unsupported(key) { + Err(TdhDecodeError::Unsupported) + } else { + Ok(None) + } + } + } + } + + /// Inserts a freshly decoded `schema` under its `source` key and returns + /// the assigned arena index. This is the single positive-cache insertion + /// point shared by every schema source. + fn insert_cached( + &mut self, + source: SchemaSource, + is_32bit: bool, + schema: CachedSchema, + ) -> usize { + match source { + SchemaSource::TraceLogging(bytes) => { + self.cache.insert_tl(bytes.to_vec(), is_32bit, schema) + } + SchemaSource::Manifest(key) => self.cache.insert_manifest(key, is_32bit, schema), + } + } + + /// Performs a real TraceLogging decode after a cache miss, returning the + /// freshly built (but not yet cached) schema. + /// + /// The caller assigns the `SchemaId` and inserts the schema; this helper + /// only walks the event via `TdhGetEventInformation`. fn decode_tracelogging( &mut self, record: &EVENT_RECORD, - schema_tl_bytes: &[u8], is_32bit: bool, - ) -> Result { - if let Some(idx) = self.cache.index_of_tl(schema_tl_bytes, is_32bit) { - return Ok(idx); - } + ) -> Result { call_tdh_get_event_information(record, &mut self.tei_buf)?; - let mut schema = build_cached_schema(self.tei_buf.as_bytes(), is_32bit)?; - schema.schema_id = SchemaId(self.assign_schema_id()); + let schema = build_cached_schema(self.tei_buf.as_bytes(), is_32bit)?; debug!( event_name = %schema.event_name, field_count = schema.format.fields().len(), - schema_id = schema.schema_id.0, is_32bit, - "TDH schema cache miss — new TraceLogging schema cached" + "TDH schema cache miss — decoded new TraceLogging schema" ); - Ok(self.cache.insert_tl(schema_tl_bytes.to_vec(), is_32bit, schema)) + Ok(schema) } - /// Manifest cache lookup / miss handling. Returns the arena index of - /// the (possibly newly built) schema. + /// Performs a real manifest decode after a cache miss, returning the + /// freshly built (but not yet cached) schema. /// - /// Two guards keep non-manifest sources out of the manifest cache: + /// The caller assigns the `SchemaId` and inserts the schema; this helper + /// only walks the event via `TdhGetEventInformation` and enforces the + /// decoding-source guard: /// - /// 1. **Classic/MOF fast reject**: if `EVENT_HEADER_FLAG_CLASSIC_HEADER` - /// is set, the event's identity is a class GUID + Opcode, not the - /// `(Provider, Id, Version)` tuple; its `Id` is commonly `0`, so - /// multiple such events would collapse onto one key. Rejected as - /// [`TdhDecodeError::Unsupported`] before calling TDH. - /// 2. **Decoding-source check**: after `TdhGetEventInformation`, only a - /// `DecodingSourceXMLFile` result is a genuine XML manifest. WPP and - /// WBEM results are returned as [`TdhDecodeError::Unsupported`] and the - /// key is recorded in a negative cache, so a chatty unsupported - /// provider pays the `TdhGetEventInformation` round trip only once - /// rather than on every event. `NotFound` (unregistered manifest) is - /// *not* negatively cached, since it can flip once a manifest registers. + /// After `TdhGetEventInformation`, only a `DecodingSourceXMLFile` result + /// is a genuine XML manifest. WPP and WBEM results are returned as + /// [`TdhDecodeError::Unsupported`] and the key is recorded in the negative + /// cache, so a chatty unsupported provider pays the round trip only once. + /// `NotFound` (unregistered manifest) is *not* negatively cached, since it + /// can flip once a manifest registers. fn decode_manifest( &mut self, record: &EVENT_RECORD, is_32bit: bool, - ) -> Result { - // Guard 1: classic (MOF/WBEM) events are not manifest events. - if (record.EventHeader.Flags & EVENT_HEADER_FLAG_CLASSIC_HEADER) != 0 { - return Err(TdhDecodeError::Unsupported); - } - - let desc = &record.EventHeader.EventDescriptor; - let key = ManifestKey { - provider: record.provider_guid(), - id: desc.Id, - version: desc.Version, - }; - - if let Some(idx) = self.cache.index_of_manifest(&key, is_32bit) { - return Ok(idx); - } - - // Negative cache: keys proven permanently unsupported skip the TDH - // call entirely. - if self.cache.is_manifest_unsupported(&key) { - return Err(TdhDecodeError::Unsupported); - } - + key: &ManifestEventKey, + ) -> Result { call_tdh_get_event_information(record, &mut self.tei_buf)?; - // Guard 2: only cache genuine XML-manifest schemas. A `not - // TraceLogging` event can still be WPP/WBEM, whose identity semantics - // differ from `ManifestKey`; those are unsupported here. + // Only genuine XML-manifest schemas are cached here. A "not + // TraceLogging" event can still be WPP/WBEM, whose identity semantics + // differ from `ManifestEventKey`; those are unsupported. let decoding_source = read_decoding_source(self.tei_buf.as_bytes())?; if decoding_source != DecodingSourceXMLFile { debug!(decoding_source, "TDH manifest path — unsupported decoding source"); - self.cache.mark_manifest_unsupported(key); + self.cache.mark_manifest_unsupported(*key); return Err(TdhDecodeError::Unsupported); } - let mut schema = build_cached_schema(self.tei_buf.as_bytes(), is_32bit)?; - schema.schema_id = SchemaId(self.assign_schema_id()); + let schema = build_cached_schema(self.tei_buf.as_bytes(), is_32bit)?; debug!( event_name = %schema.event_name, id = key.id, version = key.version, field_count = schema.format.fields().len(), - schema_id = schema.schema_id.0, is_32bit, - "TDH schema cache miss — new manifest schema cached" + "TDH schema cache miss — decoded new manifest schema" ); - Ok(self.cache.insert_manifest(key, is_32bit, schema)) + Ok(schema) } /// Allocates the next monotonic `SchemaId` value. @@ -805,7 +871,7 @@ fn find_schema_tl(record: &EVENT_RECORD) -> Option<&[u8]> { /// Reads `TRACE_EVENT_INFO.DecodingSource` from a filled TEI buffer. /// /// Used by the manifest dispatch path to reject non-XML-manifest sources -/// (WPP / WBEM) before they are cached under a `ManifestKey`. +/// (WPP / WBEM) before they are cached under a `ManifestEventKey`. fn read_decoding_source(tei_buf: &[u8]) -> Result { if tei_buf.len() < std::mem::size_of::() { return Err(TdhDecodeError::Malformed("buffer smaller than TRACE_EVENT_INFO")); @@ -1467,7 +1533,7 @@ mod tests { let mut decoder = TdhDecoder::new(); match decoder.decode(&record) { - Err(TdhDecodeError::Unsupported) => {} // expected — rejected by guard 1 + Err(TdhDecodeError::Unsupported) => {} // expected — rejected during source classification Err(other) => panic!("expected Unsupported for classic header, got: {other}"), Ok(_) => panic!("expected Unsupported error, but decode succeeded"), } @@ -1514,7 +1580,7 @@ mod tests { format: EventFormat::new(), schema_id: SchemaId(0), }; - let key = ManifestKey { + let key = ManifestEventKey { provider: Guid::from_u128(0x1122_3344_5566_7788_99AA_BBCC_DDEE_FF00), id: 5, version: 2, @@ -1556,12 +1622,12 @@ mod tests { /// the `is_32bit` probe and that an unmarked key does not. #[test] fn manifest_unsupported_negative_cache() { - let key = ManifestKey { + let key = ManifestEventKey { provider: Guid::from_u128(0x0011_2233_4455_6677_8899_AABB_CCDD_EEFF), id: 7, version: 1, }; - let other = ManifestKey { + let other = ManifestEventKey { provider: key.provider, id: 8, version: 1, From 9f62d86cdb2070f87e7b2d113a63fc360242f6ce Mon Sep 17 00:00:00 2001 From: "Swapnil Ashtekar (from Dev Box)" Date: Tue, 21 Jul 2026 01:08:45 -0700 Subject: [PATCH 5/8] Add integration test for manifest decoding with live OS provider --- one_collect/tests/etw_tdh_integration.rs | 121 ++++++++++++++++++++++- 1 file changed, 119 insertions(+), 2 deletions(-) diff --git a/one_collect/tests/etw_tdh_integration.rs b/one_collect/tests/etw_tdh_integration.rs index d192e58c..88d9d42e 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::>() + ); +} + From 695d481f4137f4e0ae35f3a6ab2f60a794bb24fa Mon Sep 17 00:00:00 2001 From: "Swapnil Ashtekar (from Dev Box)" Date: Thu, 23 Jul 2026 20:22:13 -0700 Subject: [PATCH 6/8] Add providers module for enumerating registered ETW providers --- one_collect/src/etw/mod.rs | 3 + one_collect/src/etw/providers.rs | 172 +++++++++++++++++++++++++++++++ one_collect/src/etw/tdh.rs | 67 +++++++++++- 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 one_collect/src/etw/providers.rs diff --git a/one_collect/src/etw/mod.rs b/one_collect/src/etw/mod.rs index 5cc598a4..2f8d3c27 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 00000000..0c55193b --- /dev/null +++ b/one_collect/src/etw/providers.rs @@ -0,0 +1,172 @@ +// 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; + } + let Some(provider_name) = read_utf16z(bytes, name_offset) 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 911e2328..12fbabe8 100644 --- a/one_collect/src/etw/tdh.rs +++ b/one_collect/src/etw/tdh.rs @@ -151,7 +151,6 @@ const PROPERTY_PARAM_COUNT: i32 = PropertyParamCount; /// Errors that can occur during TDH-based schema decoding. #[derive(Debug)] -#[non_exhaustive] pub enum TdhDecodeError { /// No decodable schema was found for the event. /// @@ -1644,4 +1643,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); + } } From ce7ce5507bdc1e222273ed669c13dc0325002f50 Mon Sep 17 00:00:00 2001 From: "Swapnil Ashtekar (from Dev Box)" Date: Thu, 23 Jul 2026 20:22:13 -0700 Subject: [PATCH 7/8] Add providers module for enumerating registered ETW providers --- one_collect/src/etw/mod.rs | 3 + one_collect/src/etw/providers.rs | 175 +++++++++++++++++++++++++++++++ one_collect/src/etw/tdh.rs | 67 +++++++++++- 3 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 one_collect/src/etw/providers.rs diff --git a/one_collect/src/etw/mod.rs b/one_collect/src/etw/mod.rs index 5cc598a4..2f8d3c27 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 00000000..202b43eb --- /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 911e2328..12fbabe8 100644 --- a/one_collect/src/etw/tdh.rs +++ b/one_collect/src/etw/tdh.rs @@ -151,7 +151,6 @@ const PROPERTY_PARAM_COUNT: i32 = PropertyParamCount; /// Errors that can occur during TDH-based schema decoding. #[derive(Debug)] -#[non_exhaustive] pub enum TdhDecodeError { /// No decodable schema was found for the event. /// @@ -1644,4 +1643,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); + } } From 56311d02b8f339b2867304dc8dab1a1a09ce35cf Mon Sep 17 00:00:00 2001 From: "Swapnil Ashtekar (from Dev Box)" Date: Thu, 23 Jul 2026 23:21:53 -0700 Subject: [PATCH 8/8] Add guid_from_tracelogging_name function for EventSource/TraceLogging GUID resolution --- one_collect/src/etw/mod.rs | 2 +- one_collect/src/etw/providers.rs | 80 ++++++++++++++++++++++ one_collect/src/helpers/dotnet/provider.rs | 52 ++++++++++---- one_collect/src/lib.rs | 49 +++++++++++++ 4 files changed, 168 insertions(+), 15 deletions(-) diff --git a/one_collect/src/etw/mod.rs b/one_collect/src/etw/mod.rs index 2f8d3c27..f547cabb 100644 --- a/one_collect/src/etw/mod.rs +++ b/one_collect/src/etw/mod.rs @@ -18,7 +18,7 @@ mod events; pub mod providers; pub mod tdh; -pub use providers::{RegisteredProvider, registered_providers}; +pub use providers::{RegisteredProvider, registered_providers, guid_from_tracelogging_name}; use abi::{ EVENT_RECORD, diff --git a/one_collect/src/etw/providers.rs b/one_collect/src/etw/providers.rs index 202b43eb..9904e396 100644 --- a/one_collect/src/etw/providers.rs +++ b/one_collect/src/etw/providers.rs @@ -172,4 +172,84 @@ fn read_utf16z(bytes: &[u8], byte_offset: usize) -> Option { i += 2; } Some(String::from_utf16_lossy(&units)) +} + +/// Resolve a self-describing (EventSource / TraceLogging) provider *name* to +/// its ETW control GUID. +/// +/// Where [`registered_providers`] answers "which GUID is *registered* for this +/// name?" by consulting the OS provider database, this answers the complement: +/// self-describing providers are **not** registered — their control GUID is a +/// deterministic hash of the name. Together the two cover name → GUID +/// resolution for every provider model, letting a caller pick a GUID and hand +/// it to `EtwSession::enable_provider`. +/// +/// Accepts either: +/// * a literal `{GUID}` string (returned as-is after parsing), or +/// * a provider name, hashed via the EventSource convention (see +/// [`Guid::from_eventsource_name`], the shared source of truth for the +/// namespace seed and encoding). +/// +/// # Errors +/// +/// Returns an error only when a `{...}` literal is not valid GUID hex. A plain +/// name never fails: it always hashes to some GUID. +pub fn guid_from_tracelogging_name(provider_name: &str) -> anyhow::Result { + if provider_name.starts_with('{') { + // Direct `{GUID}` literal: strip braces/dashes and parse the hex. + let hex = provider_name.replace(['-', '{', '}'], ""); + return u128::from_str_radix(hex.trim(), 16) + .map(Guid::from_u128) + .map_err(|_| anyhow::anyhow!("invalid provider GUID literal: {provider_name}")); + } + + // Self-describing provider: name-hash to the control GUID. + Ok(Guid::from_eventsource_name(provider_name)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Ground-truth vector for the EventSource name-hash, independently + /// computed (SHA-1 over the fixed namespace + upper-cased UTF-16BE name, + /// version nibble forced to 5, fields read little-endian as + /// `Guid::v5_from_name` does). Locks the namespace seed + encoding so a + /// drift in either is caught here. + const KNOWN_NAME: &str = "OneCollect-Test-Provider"; + const KNOWN_GUID: u128 = 0xB03CBD70_B6F7_5552_04A1_A17A1FE5F1A4; + + #[test] + fn tracelogging_name_hashes_to_known_guid() { + let guid = guid_from_tracelogging_name(KNOWN_NAME).unwrap(); + assert_eq!(guid.to_bytes(), Guid::from_u128(KNOWN_GUID).to_bytes()); + } + + #[test] + fn tracelogging_name_is_case_insensitive() { + // The convention upper-cases before hashing, so any casing of the same + // name resolves to the same GUID. + let upper = guid_from_tracelogging_name(&KNOWN_NAME.to_uppercase()).unwrap(); + let lower = guid_from_tracelogging_name(&KNOWN_NAME.to_lowercase()).unwrap(); + assert_eq!(upper.to_bytes(), Guid::from_u128(KNOWN_GUID).to_bytes()); + assert_eq!(lower.to_bytes(), Guid::from_u128(KNOWN_GUID).to_bytes()); + } + + #[test] + fn tracelogging_guid_literal_is_parsed_verbatim() { + // A `{GUID}` literal is parsed directly, not hashed. + let guid = guid_from_tracelogging_name( + "{E13C0D23-CCBC-4E12-931B-D9CC2EEE27E4}", + ) + .unwrap(); + assert_eq!( + guid.to_bytes(), + Guid::from_u128(0xE13C0D23_CCBC_4E12_931B_D9CC2EEE27E4).to_bytes()); + } + + #[test] + fn tracelogging_invalid_guid_literal_errors() { + // A `{...}` that isn't valid hex is a real error, not a silent hash. + assert!(guid_from_tracelogging_name("{not-a-guid}").is_err()); + } } \ No newline at end of file diff --git a/one_collect/src/helpers/dotnet/provider.rs b/one_collect/src/helpers/dotnet/provider.rs index 6be1791d..4e242251 100644 --- a/one_collect/src/helpers/dotnet/provider.rs +++ b/one_collect/src/helpers/dotnet/provider.rs @@ -54,20 +54,10 @@ pub(crate) fn guid_from_provider(provider_name: &str) -> anyhow::Result { Err(_) => { anyhow::bail!("Invalid provider format."); } } } else { - /* Event Source */ - let namespace_bytes: [u8; 16] = [ - 0x48, 0x2C, 0x2D, 0xB2, - 0xC3, 0x90, 0x47, 0xC8, - 0x87, 0xF8, 0x1A, 0x15, - 0xBF, 0xC1, 0x30, 0xFB]; - - /* EventSource encodes the name as upper-case UTF-16BE */ - let mut name_bytes = Vec::with_capacity(provider_name.len() * 2); - for c in provider_name.to_uppercase().chars() { - name_bytes.extend_from_slice(&(c as u16).to_be_bytes()); - } - - Ok(Guid::v5_from_name(&namespace_bytes, &name_bytes)) + /* Event Source: name-hashed control GUID. Delegates to the + * shared `Guid::from_eventsource_name` so the namespace seed + * and encoding live in exactly one place. */ + Ok(Guid::from_eventsource_name(provider_name)) } } } @@ -100,3 +90,37 @@ impl DotNetProviderFlags { #[allow(dead_code)] pub(crate) fn callstack_keywords(&self) -> u64 { self.callstack_keywords } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The `.NET` runtime special-cases must keep returning their fixed legacy + /// GUIDs (they intentionally override the name-hash, so this guards the + /// match arms after the refactor to delegate the generic branch). + #[test] + fn dotnet_runtime_special_case_is_unchanged() { + assert_eq!( + guid_from_provider("Microsoft-Windows-DotNETRuntime").unwrap().to_bytes(), + Guid::from_u128(0xe13c0d23_ccbc_4e12_931b_d9cc2eee27e4).to_bytes()); + } + + /// A non-special name now delegates to `Guid::from_eventsource_name`; verify + /// the result is byte-identical to that shared primitive (the refactor must + /// not change any resolved GUID). + #[test] + fn generic_name_delegates_to_eventsource_hash() { + let name = "OneCollect-Test-Provider"; + assert_eq!( + guid_from_provider(name).unwrap().to_bytes(), + Guid::from_eventsource_name(name).to_bytes()); + } + + /// A `{GUID}` literal is still parsed directly. + #[test] + fn guid_literal_is_parsed() { + assert_eq!( + guid_from_provider("{E13C0D23-CCBC-4E12-931B-D9CC2EEE27E4}").unwrap().to_bytes(), + Guid::from_u128(0xE13C0D23_CCBC_4E12_931B_D9CC2EEE27E4).to_bytes()); + } +} diff --git a/one_collect/src/lib.rs b/one_collect/src/lib.rs index fdbf9c3f..30a64d93 100644 --- a/one_collect/src/lib.rs +++ b/one_collect/src/lib.rs @@ -72,6 +72,36 @@ impl Guid { ], } } + + /// Derive the ETW control GUID of a self-describing (EventSource / + /// TraceLogging) provider from its *name*. + /// + /// Unlike manifest / classic providers (whose GUID must be looked up in the + /// OS provider database), a self-describing provider's GUID is a + /// deterministic hash of its name: the name is upper-cased, encoded as + /// UTF-16BE, and v5-hashed over the fixed 16-byte `EventSource` namespace. + /// + /// This is the single source of truth for that namespace seed and encoding + /// convention; the .NET helper and the ETW provider-name resolver both + /// delegate here rather than re-embedding the seed (a duplicated seed would + /// silently mis-resolve every provider if the copies ever drifted). + pub fn from_eventsource_name(name: &str) -> Self { + // The fixed namespace `EventSource` uses to hash provider names. + const EVENTSOURCE_NAMESPACE: [u8; 16] = [ + 0x48, 0x2C, 0x2D, 0xB2, + 0xC3, 0x90, 0x47, 0xC8, + 0x87, 0xF8, 0x1A, 0x15, + 0xBF, 0xC1, 0x30, 0xFB, + ]; + + // `EventSource` encodes the (upper-cased) name as UTF-16BE. + let mut name_bytes = Vec::with_capacity(name.len() * 2); + for c in name.to_uppercase().chars() { + name_bytes.extend_from_slice(&(c as u16).to_be_bytes()); + } + + Self::v5_from_name(&EVENTSOURCE_NAMESPACE, &name_bytes) + } } pub mod event; @@ -156,5 +186,24 @@ mod tests { let guid = Guid::v5_from_name(NS, b"record-trace"); assert_eq!(guid.data3 & 0xF000, 0x5000); } + + /// Ground-truth vector for the EventSource name-hash, independently + /// computed (SHA-1 over the fixed EventSource namespace + upper-cased + /// UTF-16BE name, version nibble forced to 5, fields read little-endian as + /// `v5_from_name` does). Locks the namespace seed and encoding. + #[test] + fn from_eventsource_name_matches_known_vector() { + assert_eq!( + Guid::from_eventsource_name("OneCollect-Test-Provider").to_bytes(), + Guid::from_u128(0xB03CBD70_B6F7_5552_04A1_A17A1FE5F1A4).to_bytes()); + } + + #[test] + fn from_eventsource_name_is_case_insensitive() { + // The convention upper-cases before hashing, so casing is irrelevant. + assert_eq!( + Guid::from_eventsource_name("onecollect-test-provider").to_bytes(), + Guid::from_eventsource_name("OneCollect-Test-Provider").to_bytes()); + } }