diff --git a/src/EventLogExpert.Eventing/EventLogExpert.Eventing.csproj b/src/EventLogExpert.Eventing/EventLogExpert.Eventing.csproj index b896afabf..a4c4a5669 100644 --- a/src/EventLogExpert.Eventing/EventLogExpert.Eventing.csproj +++ b/src/EventLogExpert.Eventing/EventLogExpert.Eventing.csproj @@ -17,6 +17,7 @@ + diff --git a/src/EventLogExpert.Eventing/Interop/NativeMethods.Evt.cs b/src/EventLogExpert.Eventing/Interop/NativeMethods.Evt.cs index f0d7d3a3e..c26810fd4 100644 --- a/src/EventLogExpert.Eventing/Interop/NativeMethods.Evt.cs +++ b/src/EventLogExpert.Eventing/Interop/NativeMethods.Evt.cs @@ -71,17 +71,7 @@ internal static partial class NativeMethods case (int)EvtVariantType.FileTime: return DateTime.FromFileTimeUtc((long)variant.FileTimeVal); case (int)EvtVariantType.SysTime: - var sysTime = Marshal.PtrToStructure(variant.SysTimeVal); - - return new DateTime( - sysTime.Year, - sysTime.Month, - sysTime.Day, - sysTime.Hour, - sysTime.Minute, - sysTime.Second, - sysTime.Milliseconds, - DateTimeKind.Utc); + return ReadSysTime(variant); case (int)EvtVariantType.Sid: return variant.SidVal == IntPtr.Zero ? null : new SecurityIdentifier(variant.SidVal); case (int)EvtVariantType.HexInt32: @@ -129,6 +119,47 @@ internal static partial class NativeMethods } } + internal static EventProperty ConvertVariantToProperty(EvtVariant variant) + { + switch (variant.Type) + { + case (int)EvtVariantType.SByte: + return variant.SByteVal; + case (int)EvtVariantType.Byte: + return variant.ByteVal; + case (int)EvtVariantType.Int16: + return variant.Int16Val; + case (int)EvtVariantType.UInt16: + return variant.UInt16Val; + case (int)EvtVariantType.Int32: + case (int)EvtVariantType.HexInt32: + return variant.Int32Val; + case (int)EvtVariantType.UInt32: + return variant.UInt32Val; + case (int)EvtVariantType.Int64: + return variant.Int64Val; + case (int)EvtVariantType.UInt64: + case (int)EvtVariantType.HexInt64: + return variant.UInt64Val; + case (int)EvtVariantType.Single: + return variant.SingleVal; + case (int)EvtVariantType.Double: + return variant.DoubleVal; + case (int)EvtVariantType.Boolean: + return variant.BooleanVal != 0; + case (int)EvtVariantType.SizeT: + return variant.SizeTVal; + case (int)EvtVariantType.FileTime: + return DateTime.FromFileTimeUtc((long)variant.FileTimeVal); + case (int)EvtVariantType.SysTime: + return ReadSysTime(variant); + default: + // Reference shapes reuse the boxing converter (reference types add no allocation; the rare boxed + // Guid is acceptable). Null / unsupported types throw, preserving the boxed path's ?? throw contract. + return EventProperty.FromReference(ConvertVariant(variant) ?? throw new InvalidDataException()); + } + } + /// Closes an open handle [LibraryImport(EventLogApi, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] @@ -621,7 +652,7 @@ internal static EventRecord RenderEvent(EvtHandle eventHandle) } } - internal static IReadOnlyList RenderEventProperties(EvtHandle eventHandle) + internal static IReadOnlyList RenderEventProperties(EvtHandle eventHandle) { bool success = EvtRender( EventLogSession.GlobalSession.UserRenderContext, @@ -671,7 +702,7 @@ internal static IReadOnlyList RenderEventProperties(EvtHandle eventHandl if (propertyCount <= 0) { return []; } - var properties = new object[propertyCount]; + var properties = new EventProperty[propertyCount]; unsafe { @@ -681,7 +712,7 @@ internal static IReadOnlyList RenderEventProperties(EvtHandle eventHandl for (int i = 0; i < propertyCount; i++) { - properties[i] = ConvertVariant(variants[i]) ?? throw new InvalidDataException(); + properties[i] = ConvertVariantToProperty(variants[i]); } } } @@ -870,4 +901,19 @@ private static unsafe T[] ReadBlittableArray(IntPtr reference, uint count, Ev throw new InvalidDataException($"Expected EVT_VARIANT type UInt16, got {variant.Type}.") : variant.UInt16Val; } + + private static DateTime ReadSysTime(EvtVariant variant) + { + var sysTime = Marshal.PtrToStructure(variant.SysTimeVal); + + return new DateTime( + sysTime.Year, + sysTime.Month, + sysTime.Day, + sysTime.Hour, + sysTime.Minute, + sysTime.Second, + sysTime.Milliseconds, + DateTimeKind.Utc); + } } diff --git a/src/EventLogExpert.Eventing/Interop/NativeMethods.cs b/src/EventLogExpert.Eventing/Interop/NativeMethods.cs index cdd674dd9..85944903d 100644 --- a/src/EventLogExpert.Eventing/Interop/NativeMethods.cs +++ b/src/EventLogExpert.Eventing/Interop/NativeMethods.cs @@ -69,6 +69,14 @@ internal static partial IntPtr FindResourceExA( int lpName, ushort wLanguage = 0); + // String-typed resource lookup (FindResourceExA only handles integer-typed resources). Used to locate the + // "WEVT_TEMPLATE" resource by name. Returns an HRSRC, a non-owning pointer; see FindResourceExA above. + [LibraryImport(Kernel32Api, StringMarshalling = StringMarshalling.Utf16, SetLastError = true)] + internal static partial IntPtr FindResourceW( + LibraryHandle hModule, + string lpName, + string lpType); + [LibraryImport(Kernel32Api, StringMarshalling = StringMarshalling.Utf16, SetLastError = true)] internal static partial int FormatMessageW( uint dwFlags, diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/EventMessageProvider.cs b/src/EventLogExpert.Eventing/PublisherMetadata/EventMessageProvider.cs index 5ba74a26c..620ee9226 100644 --- a/src/EventLogExpert.Eventing/PublisherMetadata/EventMessageProvider.cs +++ b/src/EventLogExpert.Eventing/PublisherMetadata/EventMessageProvider.cs @@ -28,6 +28,43 @@ public sealed class EventMessageProvider( public ProviderDetails LoadProviderDetails() => LoadProviderDetailsCore(null); + internal static string InjectMapAttribute(string template, string fieldName, string mapName) + { + string nameAttribute = $"name=\"{fieldName}\""; + int searchStart = 0; + + while (true) + { + int dataIndex = template.IndexOf(" node. + if (delimiter is not (' ' or '\t' or '\r' or '\n' or '>' or '/')) + { + searchStart = afterTag; + + continue; + } + + int elementEnd = template.IndexOf('>', afterTag); + + if (elementEnd < 0) { return template; } + + int nameIndex = template.IndexOf(nameAttribute, dataIndex, StringComparison.OrdinalIgnoreCase); + + if (nameIndex >= 0 && nameIndex < elementEnd) + { + return template.Insert(nameIndex + nameAttribute.Length, $" map=\"{mapName}\""); + } + + searchStart = elementEnd + 1; + } + } + internal static List LoadMessagesFromFiles( IEnumerable legacyProviderFiles, string providerName, @@ -48,6 +85,38 @@ internal static List LoadMessagesFromFiles( return messages; } + private static void InjectMapAttributes( + IReadOnlyList events, + IReadOnlyDictionary> eventFieldMaps, + IReadOnlyDictionary decodedMaps) + { + if (eventFieldMaps.Count == 0) { return; } + + foreach (EventModel model in events) + { + if (string.IsNullOrEmpty(model.Template)) { continue; } + + if (!eventFieldMaps.TryGetValue( + new WevtEventKey((uint)model.Id, model.Version), + out IReadOnlyDictionary? fieldMaps)) + { + continue; + } + + string template = model.Template; + + foreach ((string fieldName, string mapName) in fieldMaps) + { + if (decodedMaps.ContainsKey(mapName)) + { + template = InjectMapAttribute(template, fieldName, mapName); + } + } + + model.Template = template; + } + } + private LegacyMessageFileSource? BuildLazySource(IReadOnlyList files) { if (files.Count == 0) { return null; } @@ -75,10 +144,6 @@ internal static List LoadMessagesFromFiles( return total > 0 ? new LegacyMessageFileSource(walkable, _providerName, total, _logger) : null; } - /// - /// Loads the messages for a modern provider. This info is stored at - /// Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\WINEVT - /// private ProviderDetails LoadMessagesFromModernProvider(ProviderMetadata providerMetadata) { _logger?.Debug($"{nameof(LoadMessagesFromModernProvider)} called for provider {_providerName}"); @@ -139,6 +204,8 @@ private ProviderDetails LoadMessagesFromModernProvider(ProviderMetadata provider _logger?.Debug($"Failed to load Tasks for modern provider: {_providerName}. Exception:\n{ex}"); } + PopulateValueMaps(provider, providerMetadata); + _logger?.Debug($"Returning {provider.Events.Count} events for provider {_providerName}"); return provider; @@ -192,13 +259,80 @@ private ProviderDetails LoadProviderDetailsCore(HashSet? visited) return provider; } - /// - /// Final fallback when neither modern publisher metadata nor a legacy registry entry exists for the configured - /// provider name. Some events (notably modern channel-named providers like - /// "Microsoft-Windows-AppXDeploymentServer/Operational") carry a channel path in the ProviderName slot; the real - /// publisher must be looked up through the channel config's OwningPublisher property and resolved separately. Produces - /// no result on failure. - /// + private void PopulateValueMaps(ProviderDetails provider, ProviderMetadata providerMetadata) + { + try + { + Guid publisherGuid = providerMetadata.PublisherGuid; + + if (publisherGuid == Guid.Empty) { return; } + + string resourceFilePath = providerMetadata.ResourceFilePath; + + if (string.IsNullOrEmpty(resourceFilePath)) { return; } + + WevtTemplateData? templateData = WevtTemplateReader.TryRead(resourceFilePath, publisherGuid, _logger); + + if (templateData is null || templateData.Maps.Count == 0) { return; } + + Dictionary decodedMaps = new(StringComparer.Ordinal); + + foreach ((string mapName, WevtRawMap rawMap) in templateData.Maps) + { + ValueMapDefinition? definition = ResolveMap(rawMap, providerMetadata); + + if (definition is not null) + { + decodedMaps[mapName] = definition; + } + } + + if (decodedMaps.Count == 0) { return; } + + provider.Maps = decodedMaps; + + InjectMapAttributes(provider.Events, templateData.EventFieldMaps, decodedMaps); + } + catch (Exception ex) when (ex is not OutOfMemoryException + and not StackOverflowException + and not AccessViolationException) + { + _logger?.Debug($"Failed to populate value maps for modern provider: {_providerName}. Exception:\n{ex}"); + } + } + + private ValueMapDefinition? ResolveMap(WevtRawMap rawMap, ProviderMetadata providerMetadata) + { + List entries = new(rawMap.Entries.Count); + + foreach (WevtRawMapEntry entry in rawMap.Entries) + { + if (entry.MessageId == uint.MaxValue) { continue; } + + string name; + + try + { + name = providerMetadata.FormatMessageById(entry.MessageId); + } + catch (Exception ex) when (ex is not OutOfMemoryException + and not StackOverflowException + and not AccessViolationException) + { + _logger?.Debug( + $"Failed to resolve map message {entry.MessageId} for provider {_providerName}: {ex.Message}"); + + continue; + } + + if (string.IsNullOrEmpty(name)) { continue; } + + entries.Add(new ValueMapEntry(entry.Value, name.TrimEnd('\0', '\r', '\n', '\t', ' '))); + } + + return entries.Count > 0 ? new ValueMapDefinition(rawMap.IsBitMap, entries) : null; + } + private void TryFallbackToOwningPublisher(ProviderDetails target, HashSet? visited) { // Bound the fallback in case channel/publisher misconfiguration creates a chain. @@ -253,6 +387,7 @@ private void TryFallbackToOwningPublisher(ProviderDetails target, HashSet Tasks internal bool IsLocaleMetadata { get; private init; } + internal Guid PublisherGuid + { + get + { + try + { + return GetPublisherMetadataObject(EvtPublisherMetadataPropertyId.PublisherGuid) as Guid? ?? Guid.Empty; + } + catch (Exception ex) when (ex is not OutOfMemoryException + and not StackOverflowException + and not AccessViolationException) + { + return Guid.Empty; + } + } + } + + internal string ResourceFilePath + { + get + { + try + { + return Environment.ExpandEnvironmentVariables( + GetPublisherMetadataObject(EvtPublisherMetadataPropertyId.ResourceFilePath) as string ?? string.Empty); + } + catch (Exception ex) when (ex is not OutOfMemoryException + and not StackOverflowException + and not AccessViolationException) + { + return string.Empty; + } + } + } + internal static ProviderMetadata? Create( string providerName, IReadOnlyList? metadataPath = null, @@ -327,6 +362,9 @@ public IDictionary Tasks return null; } + internal string FormatMessageById(uint messageId) => + NativeMethods.FormatMessage(_publisherMetadataHandle, messageId); + private static object GetEventMetadataProperty(EvtHandle metadataHandle, EvtEventMetadataPropertyId propertyId) { IntPtr buffer = IntPtr.Zero; @@ -377,6 +415,54 @@ private static object GetEventMetadataProperty(EvtHandle metadataHandle, EvtEven return null; } + private object? GetPublisherMetadataObject(EvtPublisherMetadataPropertyId propertyId) + { + IntPtr buffer = IntPtr.Zero; + + try + { + bool success = NativeMethods.EvtGetPublisherMetadataProperty( + _publisherMetadataHandle, + propertyId, + 0, + 0, + IntPtr.Zero, + out int bufferUsed); + + int error = Marshal.GetLastWin32Error(); + + if (!success && error != Win32ErrorCodes.ERROR_INSUFFICIENT_BUFFER) + { + NativeMethods.ThrowEventLogException(error); + } + + buffer = Marshal.AllocHGlobal(bufferUsed); + + success = NativeMethods.EvtGetPublisherMetadataProperty( + _publisherMetadataHandle, + propertyId, + 0, + bufferUsed, + buffer, + out bufferUsed); + + error = Marshal.GetLastWin32Error(); + + if (!success) + { + NativeMethods.ThrowEventLogException(error); + } + + var variant = Marshal.PtrToStructure(buffer); + + return NativeMethods.ConvertVariant(variant); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + private string GetPublisherMetadataProperty(EvtPublisherMetadataPropertyId propertyId) { IntPtr buffer = IntPtr.Zero; diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/WevtTemplateReader.cs b/src/EventLogExpert.Eventing/PublisherMetadata/WevtTemplateReader.cs new file mode 100644 index 000000000..efeabd26d --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/WevtTemplateReader.cs @@ -0,0 +1,515 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Logging.Abstractions; +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using System.Text; + +namespace EventLogExpert.Eventing.PublisherMetadata; + +internal readonly record struct WevtRawMapEntry(uint Value, uint MessageId); + +internal sealed record WevtRawMap(bool IsBitMap, IReadOnlyList Entries); + +internal readonly record struct WevtEventKey(uint Id, byte Version); + +internal sealed class WevtTemplateData +{ + public required IReadOnlyDictionary> EventFieldMaps { get; init; } + + public required IReadOnlyDictionary Maps { get; init; } +} + +/// +/// Reads the binary WEVT_TEMPLATE resource embedded in a provider DLL and extracts its valueMap / bitMap +/// definitions and per-event field-to-map associations. +/// +/// +/// EvtOpenPublisherMetadata exposes no valueMap / bitMap tables and strips the map attribute from the +/// template XML, so the decoded names (for example a bus type of 10 shown as SAS) are recovered by +/// parsing the compiled resource directly. Every offset is bounds-checked; a malformed resource yields null. +/// +internal static class WevtTemplateReader +{ + private const string BmapSignature = "BMAP"; + private const int CrimProviderCountOffset = 12; + private const int CrimProviderDescriptorArrayOffset = 16; + private const int CrimProviderDescriptorSize = 20; + private const string CrimSignature = "CRIM"; + private const int EventDefinitionSize = 48; + private const int EventDefinitionTemplateOffset = 20; + private const int EventDefinitionVersionOffset = 2; + private const int EventTableArrayOffset = 16; + private const int EventTableCountOffset = 8; + private const string EvntSignature = "EVNT"; + private const int MapEntryArrayOffset = 20; + private const int MapEntrySize = 8; + private const int MapNameOffset = 8; + private const int MapValueCountOffset = 16; + private const uint MaxElementCount = 4096; + private const uint MaxEventCount = 65536; + private const uint MaxMapEntryCount = 65536; + private const uint MaxNameByteLength = 4096; + private const uint MaxProviderCount = 4096; + private const long MaxResourceSize = 64L * 1024 * 1024; + private const uint MaxTemplateItemCount = 4096; + private const int MinResourceSize = 16; + private const int TemplateItemCountOffset = 8; + private const int TemplateItemMapOffset = 8; + private const int TemplateItemNameOffset = 16; + private const int TemplateItemSize = 20; + private const int TemplateItemsPointerOffset = 16; + private const string TempSignature = "TEMP"; + private const string VmapSignature = "VMAP"; + private const int WevtElementArrayOffset = 20; + private const int WevtElementCountOffset = 12; + private const int WevtElementDescriptorSize = 8; + private const string WevtResourceName = "#1"; + private const string WevtResourceType = "WEVT_TEMPLATE"; + private const string WevtSignature = "WEVT"; + + internal static WevtTemplateData? TryParse(byte[] data, Guid publisherGuid, ITraceLogger? logger) + { + if (!TryReadSignature(data, 0, out string signature) || signature != CrimSignature) + { + return null; + } + + if (!TryReadUInt32(data, CrimProviderCountOffset, out uint providerCount)) + { + return null; + } + + if (!TryFindProviderOffset(data, providerCount, publisherGuid, out uint providerOffset)) + { + logger?.Debug($"{nameof(WevtTemplateReader)}: provider {publisherGuid} not found in WEVT_TEMPLATE."); + + return null; + } + + return ParseProvider(data, providerOffset, logger); + } + + internal static WevtTemplateData? TryRead(string resourceFilePath, Guid publisherGuid, ITraceLogger? logger) + { + if (string.IsNullOrEmpty(resourceFilePath) || + !Path.IsPathFullyQualified(resourceFilePath) || + !File.Exists(resourceFilePath)) + { + return null; + } + + byte[]? resourceBytes = TryLoadWevtResource(resourceFilePath, logger); + + if (resourceBytes is null || resourceBytes.Length < MinResourceSize) + { + return null; + } + + try + { + return TryParse(resourceBytes, publisherGuid, logger); + } + catch (Exception ex) when (ex is not OutOfMemoryException + and not StackOverflowException + and not AccessViolationException) + { + logger?.Debug( + $"{nameof(WevtTemplateReader)}: failed to parse WEVT_TEMPLATE from {resourceFilePath}: {ex.Message}"); + + return null; + } + } + + private static WevtTemplateData? ParseProvider(byte[] data, uint providerOffset, ITraceLogger? logger) + { + if (!TryFindEventTableOffset(data, providerOffset, out uint eventTableOffset)) + { + return null; + } + + if (!TryReadUInt32(data, (int)eventTableOffset + EventTableCountOffset, out uint eventCount) || + eventCount > MaxEventCount) + { + return null; + } + + Dictionary maps = new(StringComparer.Ordinal); + Dictionary mapNamesByOffset = []; + Dictionary?> fieldMapsByTemplateOffset = []; + Dictionary> eventFieldMaps = []; + + for (uint eventIndex = 0; eventIndex < eventCount; eventIndex++) + { + int eventDefinitionOffset = + (int)eventTableOffset + EventTableArrayOffset + (int)(eventIndex * EventDefinitionSize); + + if (!TryReadUInt16(data, eventDefinitionOffset, out ushort eventId) || + !TryReadByte(data, eventDefinitionOffset + EventDefinitionVersionOffset, out byte version) || + !TryReadUInt32(data, eventDefinitionOffset + EventDefinitionTemplateOffset, out uint templateOffset)) + { + continue; + } + + if (templateOffset == 0) + { + continue; + } + + if (!fieldMapsByTemplateOffset.TryGetValue(templateOffset, out Dictionary? fieldMaps)) + { + fieldMaps = ParseTemplate(data, templateOffset, maps, mapNamesByOffset, logger); + fieldMapsByTemplateOffset[templateOffset] = fieldMaps; + } + + if (fieldMaps is { Count: > 0 }) + { + eventFieldMaps[new WevtEventKey(eventId, version)] = fieldMaps; + } + } + + return maps.Count == 0 ? null : new WevtTemplateData { Maps = maps, EventFieldMaps = eventFieldMaps }; + } + + private static Dictionary? ParseTemplate( + byte[] data, + uint templateOffset, + Dictionary maps, + Dictionary mapNamesByOffset, + ITraceLogger? logger) + { + if (!TryReadSignature(data, (int)templateOffset, out string signature) || signature != TempSignature) + { + return null; + } + + if (!TryReadUInt32(data, (int)templateOffset + TemplateItemCountOffset, out uint itemCount) || + !TryReadUInt32(data, (int)templateOffset + TemplateItemsPointerOffset, out uint itemsOffset) || + itemCount > MaxTemplateItemCount) + { + return null; + } + + Dictionary? fieldMaps = null; + + for (uint itemIndex = 0; itemIndex < itemCount; itemIndex++) + { + int itemOffset = (int)itemsOffset + (int)(itemIndex * TemplateItemSize); + + if (!TryReadUInt32(data, itemOffset + TemplateItemMapOffset, out uint mapOffset) || + !TryReadUInt32(data, itemOffset + TemplateItemNameOffset, out uint nameOffset)) + { + continue; + } + + if (mapOffset == 0) + { + continue; + } + + if (!TryReadName(data, nameOffset, out string fieldName) || fieldName.Length == 0) + { + continue; + } + + string? mapName = ResolveMapName(data, mapOffset, maps, mapNamesByOffset, logger); + + if (mapName is null) + { + continue; + } + + fieldMaps ??= new Dictionary(StringComparer.Ordinal); + fieldMaps[fieldName] = mapName; + } + + return fieldMaps; + } + + private static string? ResolveMapName( + byte[] data, + uint mapOffset, + Dictionary maps, + Dictionary mapNamesByOffset, + ITraceLogger? logger) + { + if (mapNamesByOffset.TryGetValue(mapOffset, out string? cachedName)) + { + return cachedName; + } + + if (!TryParseMap(data, mapOffset, out string mapName, out WevtRawMap? rawMap) || rawMap is null) + { + logger?.Debug($"{nameof(WevtTemplateReader)}: failed to parse map at offset {mapOffset}."); + + return null; + } + + mapNamesByOffset[mapOffset] = mapName; + maps.TryAdd(mapName, rawMap); + + return mapName; + } + + private static bool TryFindEventTableOffset(byte[] data, uint providerOffset, out uint eventTableOffset) + { + eventTableOffset = 0; + + if (!TryReadSignature(data, (int)providerOffset, out string signature) || signature != WevtSignature) + { + return false; + } + + if (!TryReadUInt32(data, (int)providerOffset + WevtElementCountOffset, out uint elementCount) || + elementCount > MaxElementCount) + { + return false; + } + + for (uint elementIndex = 0; elementIndex < elementCount; elementIndex++) + { + int descriptorOffset = + (int)providerOffset + WevtElementArrayOffset + (int)(elementIndex * WevtElementDescriptorSize); + + if (!TryReadUInt32(data, descriptorOffset, out uint elementOffset)) + { + return false; + } + + if (TryReadSignature(data, (int)elementOffset, out string elementSignature) && + elementSignature == EvntSignature) + { + eventTableOffset = elementOffset; + + return true; + } + } + + return false; + } + + private static bool TryFindProviderOffset(byte[] data, uint providerCount, Guid publisherGuid, out uint providerOffset) + { + providerOffset = 0; + + if (providerCount > MaxProviderCount) + { + return false; + } + + for (uint providerIndex = 0; providerIndex < providerCount; providerIndex++) + { + int descriptorOffset = + CrimProviderDescriptorArrayOffset + (int)(providerIndex * CrimProviderDescriptorSize); + + if (!TryReadGuid(data, descriptorOffset, out Guid guid) || + !TryReadUInt32(data, descriptorOffset + 16, out uint dataOffset)) + { + return false; + } + + if (guid == publisherGuid) + { + providerOffset = dataOffset; + + return true; + } + } + + return false; + } + + private static byte[]? TryLoadWevtResource(string resourceFilePath, ITraceLogger? logger) + { + LibraryHandle module = NativeMethods.LoadLibraryExW( + resourceFilePath, + IntPtr.Zero, + LoadLibraryFlags.LOAD_LIBRARY_AS_DATAFILE); + + if (module.IsInvalid) + { + logger?.Debug($"{nameof(WevtTemplateReader)}: LoadLibraryExW failed for {resourceFilePath}."); + + return null; + } + + try + { + IntPtr resourceInfo = NativeMethods.FindResourceW(module, WevtResourceName, WevtResourceType); + + if (resourceInfo == IntPtr.Zero) + { + return null; + } + + IntPtr resourceData = NativeMethods.LoadResource(module, resourceInfo); + + if (resourceData == IntPtr.Zero) + { + return null; + } + + IntPtr resourcePointer = NativeMethods.LockResource(resourceData); + + if (resourcePointer == IntPtr.Zero) + { + return null; + } + + uint resourceSize = NativeMethods.SizeofResource(module, resourceInfo); + + if (resourceSize is 0 || resourceSize > MaxResourceSize) + { + return null; + } + + byte[] buffer = new byte[resourceSize]; + Marshal.Copy(resourcePointer, buffer, 0, (int)resourceSize); + + return buffer; + } + finally + { + module.Dispose(); + } + } + + private static bool TryParseMap(byte[] data, uint mapOffset, out string mapName, out WevtRawMap? rawMap) + { + mapName = string.Empty; + rawMap = null; + + if (!TryReadSignature(data, (int)mapOffset, out string signature) || + (signature != VmapSignature && signature != BmapSignature)) + { + return false; + } + + if (!TryReadUInt32(data, (int)mapOffset + MapNameOffset, out uint nameOffset) || + !TryReadUInt32(data, (int)mapOffset + MapValueCountOffset, out uint valueCount) || + valueCount > MaxMapEntryCount) + { + return false; + } + + if (!TryReadName(data, nameOffset, out mapName) || mapName.Length == 0) + { + return false; + } + + List entries = new((int)valueCount); + + for (uint entryIndex = 0; entryIndex < valueCount; entryIndex++) + { + int entryOffset = (int)mapOffset + MapEntryArrayOffset + (int)(entryIndex * MapEntrySize); + + if (!TryReadUInt32(data, entryOffset, out uint value) || + !TryReadUInt32(data, entryOffset + 4, out uint messageId)) + { + return false; + } + + entries.Add(new WevtRawMapEntry(value, messageId)); + } + + rawMap = new WevtRawMap(signature == BmapSignature, entries); + + return true; + } + + private static bool TryReadByte(byte[] data, int offset, out byte value) + { + if (offset < 0 || offset >= data.Length) + { + value = 0; + + return false; + } + + value = data[offset]; + + return true; + } + + private static bool TryReadGuid(byte[] data, int offset, out Guid value) + { + if (offset < 0 || offset + 16 > data.Length) + { + value = Guid.Empty; + + return false; + } + + value = new Guid(data.AsSpan(offset, 16)); + + return true; + } + + private static bool TryReadName(byte[] data, uint nameOffset, out string name) + { + name = string.Empty; + + if (!TryReadUInt32(data, (int)nameOffset, out uint totalByteSize) || + totalByteSize < 4 || + totalByteSize > MaxNameByteLength) + { + return false; + } + + int stringByteLength = (int)totalByteSize - 4; + int stringStart = (int)nameOffset + 4; + + if (stringStart < 0 || stringStart + stringByteLength > data.Length) + { + return false; + } + + name = Encoding.Unicode.GetString(data, stringStart, stringByteLength).TrimEnd('\0'); + + return true; + } + + private static bool TryReadSignature(byte[] data, int offset, out string signature) + { + if (offset < 0 || offset + 4 > data.Length) + { + signature = string.Empty; + + return false; + } + + signature = Encoding.ASCII.GetString(data, offset, 4); + + return true; + } + + private static bool TryReadUInt16(byte[] data, int offset, out ushort value) + { + if (offset < 0 || offset + 2 > data.Length) + { + value = 0; + + return false; + } + + value = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(offset, 2)); + + return true; + } + + private static bool TryReadUInt32(byte[] data, int offset, out uint value) + { + if (offset < 0 || offset + 4 > data.Length) + { + value = 0; + + return false; + } + + value = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(offset, 4)); + + return true; + } +} diff --git a/src/EventLogExpert.Eventing/Readers/EventProperty.cs b/src/EventLogExpert.Eventing/Readers/EventProperty.cs new file mode 100644 index 000000000..887d25081 --- /dev/null +++ b/src/EventLogExpert.Eventing/Readers/EventProperty.cs @@ -0,0 +1,167 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Security.Principal; + +namespace EventLogExpert.Eventing.Readers; + +internal enum EventPropertyKind : byte +{ + SByte, + Byte, + Int16, + UInt16, + Int32, + UInt32, + Int64, + UInt64, + Single, + Double, + Boolean, + DateTime, + SizeT, + Reference +} + +/// +/// A single rendered event-data property stored without boxing value types: numeric / bool / DateTime kinds pack +/// into a 64-bit field tagged by a shared per-kind sentinel, while reference shapes (string, byte[], Guid, SID, +/// arrays, handle) live in the object slot. +/// +internal readonly struct EventProperty : IEquatable +{ + private static readonly NumericKind[] s_numericKinds = + [ + new(EventPropertyKind.SByte), + new(EventPropertyKind.Byte), + new(EventPropertyKind.Int16), + new(EventPropertyKind.UInt16), + new(EventPropertyKind.Int32), + new(EventPropertyKind.UInt32), + new(EventPropertyKind.Int64), + new(EventPropertyKind.UInt64), + new(EventPropertyKind.Single), + new(EventPropertyKind.Double), + new(EventPropertyKind.Boolean), + new(EventPropertyKind.DateTime), + new(EventPropertyKind.SizeT) + ]; + + private readonly object? _tagOrRef; + private readonly long _bits; + + private EventProperty(EventPropertyKind kind, long bits) + { + _tagOrRef = s_numericKinds[(int)kind]; + _bits = bits; + } + + private EventProperty(object? reference) + { + _tagOrRef = reference; + _bits = 0; + } + + public EventPropertyKind Kind => _tagOrRef is NumericKind numericKind ? numericKind.Kind : EventPropertyKind.Reference; + + internal object? Reference => _tagOrRef is NumericKind ? null : _tagOrRef; + + internal bool AsBoolean => _bits != 0; + + internal sbyte AsSByte => (sbyte)_bits; + + internal byte AsByte => (byte)_bits; + + internal short AsInt16 => (short)_bits; + + internal ushort AsUInt16 => (ushort)_bits; + + internal int AsInt32 => (int)_bits; + + internal uint AsUInt32 => (uint)_bits; + + internal long AsInt64 => _bits; + + internal ulong AsUInt64 => (ulong)_bits; + + internal float AsSingle => BitConverter.Int32BitsToSingle((int)_bits); + + internal double AsDouble => BitConverter.Int64BitsToDouble(_bits); + + internal nuint AsSizeT => (nuint)(ulong)_bits; + + internal DateTime AsDateTime => DateTime.FromBinary(_bits); + + public static implicit operator EventProperty(sbyte value) => new(EventPropertyKind.SByte, value); + + public static implicit operator EventProperty(byte value) => new(EventPropertyKind.Byte, value); + + public static implicit operator EventProperty(short value) => new(EventPropertyKind.Int16, value); + + public static implicit operator EventProperty(ushort value) => new(EventPropertyKind.UInt16, value); + + public static implicit operator EventProperty(int value) => new(EventPropertyKind.Int32, value); + + public static implicit operator EventProperty(uint value) => new(EventPropertyKind.UInt32, value); + + public static implicit operator EventProperty(long value) => new(EventPropertyKind.Int64, value); + + public static implicit operator EventProperty(ulong value) => new(EventPropertyKind.UInt64, unchecked((long)value)); + + public static implicit operator EventProperty(float value) => new(EventPropertyKind.Single, BitConverter.SingleToInt32Bits(value)); + + public static implicit operator EventProperty(double value) => new(EventPropertyKind.Double, BitConverter.DoubleToInt64Bits(value)); + + public static implicit operator EventProperty(bool value) => new(EventPropertyKind.Boolean, value ? 1L : 0L); + + public static implicit operator EventProperty(DateTime value) => new(EventPropertyKind.DateTime, value.ToBinary()); + + public static implicit operator EventProperty(nuint value) => new(EventPropertyKind.SizeT, unchecked((long)value)); + + public static implicit operator EventProperty(string? value) => new(value); + + public static implicit operator EventProperty(byte[]? value) => new(value); + + public static implicit operator EventProperty(string[]? value) => new(value); + + public static implicit operator EventProperty(Guid value) => new((object)value); + + public static implicit operator EventProperty(SecurityIdentifier? value) => new(value); + + public static bool operator ==(EventProperty left, EventProperty right) => left.Equals(right); + + public static bool operator !=(EventProperty left, EventProperty right) => !left.Equals(right); + + public bool Equals(EventProperty other) => Equals(_tagOrRef, other._tagOrRef) && _bits == other._bits; + + public override bool Equals(object? obj) => obj is EventProperty other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(_tagOrRef, _bits); + + /// + /// Extracts the native-width unsigned value for valueMap / bitMap decoding, matching the manifest's integral + /// types exactly: returns false for float, double, SizeT, and every reference shape. + /// + internal bool TryGetUnsignedBits(out ulong bits) + { + switch (Kind) + { + case EventPropertyKind.Byte: bits = (byte)_bits; return true; + case EventPropertyKind.SByte: bits = (byte)(sbyte)_bits; return true; + case EventPropertyKind.UInt16: bits = (ushort)_bits; return true; + case EventPropertyKind.Int16: bits = (ushort)(short)_bits; return true; + case EventPropertyKind.UInt32: bits = (uint)_bits; return true; + case EventPropertyKind.Int32: bits = (uint)(int)_bits; return true; + case EventPropertyKind.UInt64: bits = (ulong)_bits; return true; + case EventPropertyKind.Int64: bits = (ulong)_bits; return true; + default: bits = 0; return false; + } + } + + internal static EventProperty FromReference(object? reference) => new(reference); + + private sealed class NumericKind(EventPropertyKind kind) + { + internal EventPropertyKind Kind { get; } = kind; + } +} diff --git a/src/EventLogExpert.Eventing/Readers/EventRecord.cs b/src/EventLogExpert.Eventing/Readers/EventRecord.cs index edd30aee4..78c7963ad 100644 --- a/src/EventLogExpert.Eventing/Readers/EventRecord.cs +++ b/src/EventLogExpert.Eventing/Readers/EventRecord.cs @@ -30,7 +30,7 @@ public sealed record EventRecord public long? RecordId { get; set; } - public IReadOnlyList Properties { get; set; } = []; + internal IReadOnlyList Properties { get; set; } = []; public string ProviderName { get; set; } = string.Empty; diff --git a/src/EventLogExpert.Eventing/Resolvers/DescriptionFormatter.cs b/src/EventLogExpert.Eventing/Resolvers/DescriptionFormatter.cs index 34196f819..36812a4f2 100644 --- a/src/EventLogExpert.Eventing/Resolvers/DescriptionFormatter.cs +++ b/src/EventLogExpert.Eventing/Resolvers/DescriptionFormatter.cs @@ -79,7 +79,7 @@ public string Resolve( return DefaultNoProviderDescription; } - var properties = GetFormattedProperties(modernEvent?.Template, eventRecord.Properties); + var properties = GetFormattedProperties(modernEvent?.Template, eventRecord.Properties, descriptionDetails.Maps); var descriptionFromSupplemental = supplemental is not null && ReferenceEquals(descriptionDetails, supplemental); @@ -125,7 +125,7 @@ public string Resolve( { _logger?.Debug($"{nameof(Resolve)}: Disambiguated via supplemental modern event - Provider={eventRecord.ProviderName}, EventId={eventRecord.Id}"); - var supplementalProperties = GetFormattedProperties(supplementalModernEvent!.Template, eventRecord.Properties); + var supplementalProperties = GetFormattedProperties(supplementalModernEvent!.Template, eventRecord.Properties, supplemental.Maps); // Description came from supplemental, so resolve %%n parameter substitutions // against supplemental's parameter table first. @@ -255,6 +255,113 @@ private static void CleanupFormatting(ReadOnlySpan unformattedString, ref } } + private static string FormatDisplayAsHex(EventProperty property) => property.Kind switch + { + EventPropertyKind.Byte => $"0x{property.AsByte:X}", + EventPropertyKind.SByte => $"0x{property.AsSByte:X}", + EventPropertyKind.Int16 => $"0x{property.AsInt16:X}", + EventPropertyKind.UInt16 => $"0x{property.AsUInt16:X}", + EventPropertyKind.Int32 => $"0x{property.AsInt32:X}", + EventPropertyKind.UInt32 => $"0x{property.AsUInt32:X}", + EventPropertyKind.Int64 => $"0x{property.AsInt64:X}", + EventPropertyKind.UInt64 => $"0x{property.AsUInt64:X}", + _ => FormatNumericToString(property) + }; + + private static string FormatNtStatus(EventProperty property) + { + uint statusCode; + + switch (property.Kind) + { + case EventPropertyKind.UInt32: statusCode = property.AsUInt32; break; + case EventPropertyKind.Int32: statusCode = (uint)property.AsInt32; break; + case EventPropertyKind.UInt64: statusCode = (uint)property.AsUInt64; break; + case EventPropertyKind.Int64: statusCode = (uint)property.AsInt64; break; + case EventPropertyKind.UInt16: statusCode = property.AsUInt16; break; + case EventPropertyKind.Int16: statusCode = (uint)property.AsInt16; break; + case EventPropertyKind.Byte: statusCode = property.AsByte; break; + default: return FormatNumericToString(property); + } + + return NativeErrorResolver.GetNtStatusMessage(statusCode); + } + + private static string FormatNumericProperty( + EventProperty property, + string? outType, + string? mapName, + IReadOnlyDictionary maps) + { + if (!string.IsNullOrEmpty(mapName) && + maps.TryGetValue(mapName, out ValueMapDefinition? mapDefinition) && + property.TryGetUnsignedBits(out ulong bits) && + mapDefinition.TryDecodeBits(bits, out string decodedValue)) + { + return decodedValue; + } + + if (string.IsNullOrEmpty(outType)) + { + return FormatNumericToString(property); + } + + if (s_displayAsHexTypes.Contains(outType)) + { + return FormatDisplayAsHex(property); + } + + if (string.Equals(outType, "win:HResult", StringComparison.OrdinalIgnoreCase) && + property.Kind == EventPropertyKind.Int32) + { + return NativeErrorResolver.GetErrorMessage((uint)property.AsInt32); + } + + if (string.Equals(outType, "win:NTStatus", StringComparison.OrdinalIgnoreCase)) + { + return FormatNtStatus(property); + } + + return FormatNumericToString(property); + } + + private static string FormatNumericToString(EventProperty property) => property.Kind switch + { + EventPropertyKind.SByte => property.AsSByte.ToString(), + EventPropertyKind.Byte => property.AsByte.ToString(), + EventPropertyKind.Int16 => property.AsInt16.ToString(), + EventPropertyKind.UInt16 => property.AsUInt16.ToString(), + EventPropertyKind.Int32 => property.AsInt32.ToString(), + EventPropertyKind.UInt32 => property.AsUInt32.ToString(), + EventPropertyKind.Int64 => property.AsInt64.ToString(), + EventPropertyKind.UInt64 => property.AsUInt64.ToString(), + EventPropertyKind.Single => property.AsSingle.ToString(), + EventPropertyKind.Double => property.AsDouble.ToString(), + EventPropertyKind.SizeT => property.AsSizeT.ToString(), + _ => string.Empty + }; + + private static string FormatProperty( + EventProperty property, + string? outType, + string? mapName, + IReadOnlyDictionary maps) => property.Kind switch + { + EventPropertyKind.Boolean => property.AsBoolean ? "true" : "false", + EventPropertyKind.DateTime => property.AsDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffffff00K"), + EventPropertyKind.Reference => FormatReferenceProperty(property.Reference), + _ => FormatNumericProperty(property, outType, mapName, maps) + }; + + private static string FormatReferenceProperty(object? reference) => reference switch + { + byte[] bytes => Convert.ToHexString(bytes), + SecurityIdentifier sid => sid.Value, + // Match Windows EvtFormatMessage, which renders GUID properties wrapped in braces. + Guid guidValue => guidValue.ToString("B"), + _ => reference?.ToString() ?? string.Empty + }; + private static void ResizeBuffer(ref char[] buffer, ref Span source, int sizeToAdd) { char[] newBuffer = ArrayPool.Shared.Rent(source.Length + sizeToAdd); @@ -482,9 +589,13 @@ private string FormatDescription( } } - private List GetFormattedProperties(ReadOnlySpan template, IReadOnlyList properties) + private List GetFormattedProperties( + ReadOnlySpan template, + IReadOnlyList properties, + IReadOnlyDictionary maps) { ImmutableArray dataNodes = default; + ImmutableArray mapNodes = default; List formattedValues = new(properties.Count); if (!template.IsEmpty) @@ -497,87 +608,23 @@ private List GetFormattedProperties(ReadOnlySpan template, IReadOn if (meta.VisibleOutTypes.Length == properties.Count) { dataNodes = meta.VisibleOutTypes; + mapNodes = meta.VisibleMaps; } else if (meta.AllOutTypes.Length == properties.Count) { dataNodes = meta.AllOutTypes; + mapNodes = meta.AllMaps; } } int index = 0; - foreach (object property in properties) + foreach (EventProperty property in properties) { string? outType = !dataNodes.IsDefault && index < dataNodes.Length ? dataNodes[index] : null; + string? mapName = !mapNodes.IsDefault && index < mapNodes.Length ? mapNodes[index] : null; - switch (property) - { - case bool boolValue: - formattedValues.Add(boolValue ? "true" : "false"); - - break; - case DateTime eventTime: - formattedValues.Add(eventTime.ToString("yyyy-MM-ddTHH:mm:ss.fffffff00K")); - - break; - case byte[] bytes: - formattedValues.Add(Convert.ToHexString(bytes)); - - break; - case SecurityIdentifier sid: - formattedValues.Add(sid.Value); - - break; - default: - if (string.IsNullOrEmpty(outType)) - { - formattedValues.Add(property?.ToString() ?? string.Empty); - } - else if (s_displayAsHexTypes.Contains(outType)) - { - formattedValues.Add(property switch - { - byte b => $"0x{b:X}", - sbyte sb => $"0x{sb:X}", - short s => $"0x{s:X}", - ushort us => $"0x{us:X}", - int i => $"0x{i:X}", - uint ui => $"0x{ui:X}", - long l => $"0x{l:X}", - ulong ul => $"0x{ul:X}", - _ => property?.ToString() ?? string.Empty - }); - } - else if (string.Equals(outType, "win:HResult", StringComparison.OrdinalIgnoreCase) && property is int hResult) - { - formattedValues.Add(NativeErrorResolver.GetErrorMessage((uint)hResult)); - } - else if (string.Equals(outType, "win:NTStatus", StringComparison.OrdinalIgnoreCase)) - { - uint statusCode = property switch - { - uint ui => ui, - int i => (uint)i, - ulong ul => (uint)ul, - long l => (uint)l, - ushort us => us, - short s => (uint)s, - byte b => b, - _ => 0 - }; - - formattedValues.Add(property is uint or int or ulong or long or ushort or short or byte - ? NativeErrorResolver.GetNtStatusMessage(statusCode) - : property?.ToString() ?? string.Empty); - } - else - { - formattedValues.Add(property?.ToString() ?? string.Empty); - } - - break; - } - + formattedValues.Add(FormatProperty(property, outType, mapName, maps)); index++; } diff --git a/src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs b/src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs index 8df397bae..13a0b7586 100644 --- a/src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs +++ b/src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs @@ -27,7 +27,12 @@ namespace EventLogExpert.Eventing.Resolvers; /// internal sealed class TemplateAnalyzer { - private static readonly TemplateMetadata s_empty = new(0, ImmutableArray.Empty, ImmutableArray.Empty); + private static readonly TemplateMetadata s_empty = new( + 0, + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty); private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); @@ -120,31 +125,34 @@ public bool StrictlyMatchesPropertyCount(ReadOnlySpan template, int eventP } private static TemplateMetadata BuildMetadata( - List<(string name, string outType)> elements, + List<(string name, string outType, string map)> elements, HashSet lengthProviderNames) { var allOutTypesArray = new string[elements.Count]; + var allMapsArray = new string[elements.Count]; for (int i = 0; i < elements.Count; i++) { allOutTypesArray[i] = elements[i].outType; + allMapsArray[i] = elements[i].map; } // Zero-alloc wrap: takes ownership of the existing array as an ImmutableArray. // Safe because allOutTypesArray is a fresh local with no other references. var allOutTypes = ImmutableCollectionsMarshal.AsImmutableArray(allOutTypesArray); + var allMaps = ImmutableCollectionsMarshal.AsImmutableArray(allMapsArray); if (lengthProviderNames.Count == 0) { - // No hidden length-provider elements — visible and all are identical. + // No hidden length-provider elements - visible and all are identical. // ImmutableArray is a struct wrapping the same backing array; // both fields share the wrap so consumers cannot mutate the cache. - return new TemplateMetadata(elements.Count, allOutTypes, allOutTypes); + return new TemplateMetadata(elements.Count, allOutTypes, allOutTypes, allMaps, allMaps); } int visibleCount = 0; - foreach (var (name, _) in elements) + foreach (var (name, _, _) in elements) { if (string.IsNullOrEmpty(name) || !lengthProviderNames.Contains(name)) { @@ -153,19 +161,23 @@ private static TemplateMetadata BuildMetadata( } var visibleOutTypesArray = new string[visibleCount]; + var visibleMapsArray = new string[visibleCount]; int write = 0; - foreach (var (name, outType) in elements) + foreach (var (name, outType, map) in elements) { if (string.IsNullOrEmpty(name) || !lengthProviderNames.Contains(name)) { - visibleOutTypesArray[write++] = outType; + visibleOutTypesArray[write] = outType; + visibleMapsArray[write] = map; + write++; } } var visibleOutTypes = ImmutableCollectionsMarshal.AsImmutableArray(visibleOutTypesArray); + var visibleMaps = ImmutableCollectionsMarshal.AsImmutableArray(visibleMapsArray); - return new TemplateMetadata(visibleCount, allOutTypes, visibleOutTypes); + return new TemplateMetadata(visibleCount, allOutTypes, visibleOutTypes, allMaps, visibleMaps); } private static string? ExtractAttribute(ReadOnlySpan element, ReadOnlySpan attributePrefix) @@ -182,13 +194,14 @@ private static TemplateMetadata BuildMetadata( private static TemplateMetadata Parse(ReadOnlySpan template) { - List<(string name, string outType)> elements = []; + List<(string name, string outType, string map)> elements = []; HashSet lengthProviderNames = new(StringComparer.OrdinalIgnoreCase); ReadOnlySpan dataTag = " nameAttr = "name=\""; ReadOnlySpan outTypeAttr = "outType=\""; ReadOnlySpan lengthAttr = "length=\""; + ReadOnlySpan mapAttr = "map=\""; int searchStart = 0; @@ -231,7 +244,8 @@ private static TemplateMetadata Parse(ReadOnlySpan template) string name = ExtractAttribute(element, nameAttr) ?? string.Empty; string outType = ExtractAttribute(element, outTypeAttr) ?? string.Empty; - elements.Add((name, outType)); + string map = ExtractAttribute(element, mapAttr) ?? string.Empty; + elements.Add((name, outType, map)); string? lengthRef = ExtractAttribute(element, lengthAttr); diff --git a/src/EventLogExpert.Eventing/Resolvers/TemplateMetadata.cs b/src/EventLogExpert.Eventing/Resolvers/TemplateMetadata.cs index 9cd6ff0a4..6babcc492 100644 --- a/src/EventLogExpert.Eventing/Resolvers/TemplateMetadata.cs +++ b/src/EventLogExpert.Eventing/Resolvers/TemplateMetadata.cs @@ -22,7 +22,17 @@ namespace EventLogExpert.Eventing.Resolvers; /// The outType attribute strings restricted to the visible nodes (length-provider nodes /// filtered out), in document order. Equals when no length-provider nodes are present. /// +/// +/// The map attribute string (manifest valueMap / bitMap symbolic name) for every <data> node +/// in the template, in document order. Empty string when a node has no map attribute. +/// +/// +/// The map attribute strings restricted to the visible nodes, in document order. Equals +/// when no length-provider nodes are present. +/// internal readonly record struct TemplateMetadata( int VisiblePropertyCount, ImmutableArray AllOutTypes, - ImmutableArray VisibleOutTypes); + ImmutableArray VisibleOutTypes, + ImmutableArray AllMaps, + ImmutableArray VisibleMaps); diff --git a/src/EventLogExpert.Provider.Database/Context/ProviderDbContext.cs b/src/EventLogExpert.Provider.Database/Context/ProviderDbContext.cs index bd39a5024..3a175f401 100644 --- a/src/EventLogExpert.Provider.Database/Context/ProviderDbContext.cs +++ b/src/EventLogExpert.Provider.Database/Context/ProviderDbContext.cs @@ -283,6 +283,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .Property(e => e.Tasks) .HasConversion>>(); + + // Maps are recovered at runtime from the provider's WEVT_TEMPLATE resource and are not persisted to the cache. + modelBuilder.Entity() + .Ignore(e => e.Maps); } private static bool IsType(string? actual, string expected) => diff --git a/src/EventLogExpert.Provider/Resolution/ProviderDetails.cs b/src/EventLogExpert.Provider/Resolution/ProviderDetails.cs index 51f68d9d6..3c85ffdd6 100644 --- a/src/EventLogExpert.Provider/Resolution/ProviderDetails.cs +++ b/src/EventLogExpert.Provider/Resolution/ProviderDetails.cs @@ -1,6 +1,8 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using System.Collections.ObjectModel; + namespace EventLogExpert.Provider.Resolution; public sealed class ProviderDetails @@ -34,6 +36,9 @@ public IReadOnlyList Events public IDictionary Keywords { get; set; } = new Dictionary(); + public IReadOnlyDictionary Maps { get; set; } = + ReadOnlyDictionary.Empty; + public IReadOnlyList Messages { get => _messagesView ?? []; diff --git a/src/EventLogExpert.Provider/Resolution/ValueMapDefinition.cs b/src/EventLogExpert.Provider/Resolution/ValueMapDefinition.cs new file mode 100644 index 000000000..79473b535 --- /dev/null +++ b/src/EventLogExpert.Provider/Resolution/ValueMapDefinition.cs @@ -0,0 +1,161 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Provider.Resolution; + +public readonly record struct ValueMapEntry(uint Value, string Name); + +/// +/// Decodes a numeric event property to its manifest display text: a valueMap is an exact-match enum, a bitMap is +/// the comma-joined names of every set flag. +/// +public sealed class ValueMapDefinition(bool isBitMap, IReadOnlyList entries) +{ + private readonly IReadOnlyList _entries = entries ?? []; + + public IReadOnlyList Entries => _entries; + + public bool IsBitMap { get; } = isBitMap; + + public bool TryDecode(object? value, out string decoded) + { + decoded = string.Empty; + + return TryGetUnsignedBits(value, out ulong bits) && TryDecodeBits(bits, out decoded); + } + + public bool TryDecodeBits(ulong bits, out string decoded) + { + decoded = string.Empty; + + if (_entries.Count == 0) + { + return false; + } + + return IsBitMap ? TryDecodeBitMap(bits, out decoded) : TryDecodeValueMap(bits, out decoded); + } + + private static bool TryGetUnsignedBits(object? value, out ulong bits) + { + switch (value) + { + case byte byteValue: bits = byteValue; return true; + case sbyte sbyteValue: bits = (byte)sbyteValue; return true; + case ushort ushortValue: bits = ushortValue; return true; + case short shortValue: bits = (ushort)shortValue; return true; + case uint uintValue: bits = uintValue; return true; + case int intValue: bits = (uint)intValue; return true; + case ulong ulongValue: bits = ulongValue; return true; + case long longValue: bits = (ulong)longValue; return true; + default: bits = 0; return false; + } + } + + private bool TryDecodeBitMap(ulong bits, out string decoded) + { + if (bits == 0) + { + for (int i = 0; i < _entries.Count; i++) + { + if (_entries[i].Value == 0) + { + decoded = _entries[i].Name; + + return true; + } + } + + decoded = string.Empty; + + return false; + } + + int matchedCount = 0; + int totalLength = 0; + string firstMatch = string.Empty; + + for (int i = 0; i < _entries.Count; i++) + { + ValueMapEntry entry = _entries[i]; + + // Zero-valued flags only apply when the input itself is zero; skip them in the OR-test. + if (entry.Value == 0 || (bits & entry.Value) != entry.Value) + { + continue; + } + + if (matchedCount == 0) + { + firstMatch = entry.Name; + } + else + { + totalLength++; // separator before every matched name after the first + } + + totalLength += entry.Name.Length; + matchedCount++; + } + + if (matchedCount == 0) + { + decoded = string.Empty; + + return false; + } + + if (matchedCount == 1) + { + decoded = firstMatch; + + return true; + } + + decoded = string.Create(totalLength, (self: this, bits), static (span, state) => + { + IReadOnlyList entries = state.self._entries; + int position = 0; + int matched = 0; + + for (int i = 0; i < entries.Count; i++) + { + ValueMapEntry entry = entries[i]; + + if (entry.Value == 0 || (state.bits & entry.Value) != entry.Value) + { + continue; + } + + // Separate by match index, not buffer position: an empty leading Name leaves position at 0. + if (matched > 0) + { + span[position++] = ','; + } + + entry.Name.CopyTo(span[position..]); + position += entry.Name.Length; + matched++; + } + }); + + return true; + } + + private bool TryDecodeValueMap(ulong bits, out string decoded) + { + foreach (ValueMapEntry entry in _entries) + { + if (entry.Value == bits) + { + decoded = entry.Name; + + return true; + } + } + + decoded = string.Empty; + + return false; + } +} diff --git a/tests/Shared/EventLogExpert.Eventing.TestUtils/EventLogExpert.Eventing.TestUtils.csproj b/tests/Shared/EventLogExpert.Eventing.TestUtils/EventLogExpert.Eventing.TestUtils.csproj index 615c6c577..f378aa577 100644 --- a/tests/Shared/EventLogExpert.Eventing.TestUtils/EventLogExpert.Eventing.TestUtils.csproj +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/EventLogExpert.Eventing.TestUtils.csproj @@ -13,4 +13,8 @@ + + + + diff --git a/tests/Shared/EventLogExpert.Eventing.TestUtils/EventUtils.cs b/tests/Shared/EventLogExpert.Eventing.TestUtils/EventUtils.cs index 9cc300805..49056c6ef 100644 --- a/tests/Shared/EventLogExpert.Eventing.TestUtils/EventUtils.cs +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/EventUtils.cs @@ -135,11 +135,31 @@ public static MessageModel CreateMessageModel( Text = text }; + public static ProviderDetails CreateProvider( + string name, + IReadOnlyList? messages = null, + IReadOnlyList? events = null, + IDictionary? keywords = null, + IDictionary? opcodes = null, + IDictionary? tasks = null, + string? resolvedFromOwningPublisher = null) => + new() + { + ProviderName = name, + Messages = messages ?? [], + Parameters = [], + Events = events ?? [], + Keywords = keywords ?? new Dictionary(), + Opcodes = opcodes ?? new Dictionary(), + Tasks = tasks ?? new Dictionary(), + ResolvedFromOwningPublisher = resolvedFromOwningPublisher + }; + /// Creates a modern event with a template and description for property resolution tests. - public static (ProviderDetails Details, EventRecord Record) CreateModernEvent( + internal static (ProviderDetails Details, EventRecord Record) CreateModernEvent( string description, string template, - IReadOnlyList properties, + IReadOnlyList properties, ushort id = 1000, byte version = 0) => ( @@ -172,24 +192,4 @@ public static (ProviderDetails Details, EventRecord Record) CreateModernEvent( Properties = properties } ); - - public static ProviderDetails CreateProvider( - string name, - IReadOnlyList? messages = null, - IReadOnlyList? events = null, - IDictionary? keywords = null, - IDictionary? opcodes = null, - IDictionary? tasks = null, - string? resolvedFromOwningPublisher = null) => - new() - { - ProviderName = name, - Messages = messages ?? [], - Parameters = [], - Events = events ?? [], - Keywords = keywords ?? new Dictionary(), - Opcodes = opcodes ?? new Dictionary(), - Tasks = tasks ?? new Dictionary(), - ResolvedFromOwningPublisher = resolvedFromOwningPublisher - }; } diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/EventMessageProviderTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/EventMessageProviderTests.cs new file mode 100644 index 000000000..05e651d22 --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/EventMessageProviderTests.cs @@ -0,0 +1,86 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata; + +public sealed class EventMessageProviderTests +{ + [Fact] + public void InjectMapAttribute_DataSourcePrefix_InjectsIntoTheRealDataElement() + { + string template = ""; + + string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); + + Assert.Equal( + "", + result); + } + + [Fact] + public void InjectMapAttribute_DataSourceWithSameName_IsNotMatched() + { + string template = ""; + + string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); + + Assert.Equal(template, result); + Assert.DoesNotContain("map=", result); + } + + [Fact] + public void InjectMapAttribute_FieldNotPresent_ReturnsTemplateUnchanged() + { + string template = ""; + + string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); + + Assert.Equal(template, result); + } + + [Fact] + public void InjectMapAttribute_InsertsMapAfterMatchingDataField() + { + string template = ""; + + string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); + + Assert.Equal( + "", + result); + } + + [Fact] + public void InjectMapAttribute_PrefixFieldName_DoesNotMisfire() + { + string template = ""; + + string result = EventMessageProvider.InjectMapAttribute(template, "Bus", "BusMap"); + + Assert.Equal(template, result); + } + + [Fact] + public void InjectMapAttribute_SecondDataField_InjectsIntoTheNamedElement() + { + string template = ""; + + string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); + + Assert.Equal( + "", + result); + } + + [Fact] + public void InjectMapAttribute_StructWithSameName_IsNotMatched() + { + string template = ""; + + string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); + + Assert.Equal(template, result); + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/WevtTemplateReaderTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/WevtTemplateReaderTests.cs new file mode 100644 index 000000000..c53d16e1c --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/WevtTemplateReaderTests.cs @@ -0,0 +1,151 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata; +using System.Buffers.Binary; +using System.Text; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata; + +public sealed class WevtTemplateReaderTests +{ + private const uint EntryMessageId = 0x2000; + private const uint EntryValue = 10; + private const ushort EventId = 170; + private const int EventTableOffset = 128; + private const byte EventVersion = 0; + private const string FieldName = "BusType"; + private const int FieldNameOffset = 384; + private const string MapName = "BusTypeMap"; + private const int MapNameOffset = 512; + private const int MapOffset = 448; + private const int ProviderDataOffset = 64; + private const int TemplateItemOffset = 320; + private const int TemplateOffset = 256; + + private static readonly Guid s_publisherGuid = new("11112222-3333-4444-5555-666677778888"); + + [Fact] + public void TryParse_BitMap_SetsIsBitMap() + { + byte[] resource = BuildResource("BMAP"); + + WevtTemplateData? result = WevtTemplateReader.TryParse(resource, s_publisherGuid, logger: null); + + Assert.NotNull(result); + Assert.True(result!.Maps[MapName].IsBitMap); + } + + [Fact] + public void TryParse_EmptyBuffer_ReturnsNull() => + Assert.Null(WevtTemplateReader.TryParse([], s_publisherGuid, logger: null)); + + [Fact] + public void TryParse_MapValueCountExceedsCap_ReturnsNull() + { + byte[] resource = BuildResource("VMAP"); + BinaryPrimitives.WriteUInt32LittleEndian(resource.AsSpan(MapOffset + 16), uint.MaxValue); + + Assert.Null(WevtTemplateReader.TryParse(resource, s_publisherGuid, logger: null)); + } + + [Fact] + public void TryParse_TruncatedResource_ReturnsNull() + { + byte[] resource = BuildResource("VMAP"); + + Assert.Null(WevtTemplateReader.TryParse(resource[..200], s_publisherGuid, logger: null)); + } + + [Fact] + public void TryParse_UnknownProviderGuid_ReturnsNull() + { + byte[] resource = BuildResource("VMAP"); + + Assert.Null(WevtTemplateReader.TryParse(resource, Guid.NewGuid(), logger: null)); + } + + [Fact] + public void TryParse_ValueMap_RecoversEntriesAndFieldAssociation() + { + byte[] resource = BuildResource("VMAP"); + + WevtTemplateData? result = WevtTemplateReader.TryParse(resource, s_publisherGuid, logger: null); + + Assert.NotNull(result); + Assert.True(result!.Maps.TryGetValue(MapName, out WevtRawMap? map)); + Assert.False(map!.IsBitMap); + ValueMapEntryAssertSingle(map, EntryValue, EntryMessageId); + + Assert.True(result.EventFieldMaps.TryGetValue( + new WevtEventKey(EventId, EventVersion), + out IReadOnlyDictionary? fieldMaps)); + Assert.Equal(MapName, fieldMaps![FieldName]); + } + + [Fact] + public void TryParse_WrongRootSignature_ReturnsNull() + { + byte[] resource = BuildResource("VMAP"); + resource[0] = (byte)'X'; + + Assert.Null(WevtTemplateReader.TryParse(resource, s_publisherGuid, logger: null)); + } + + private static byte[] BuildResource(string mapSignature) + { + byte[] buffer = new byte[560]; + + WriteAscii(buffer, 0, "CRIM"); + WriteUInt32(buffer, 12, 1); + s_publisherGuid.ToByteArray().CopyTo(buffer, 16); + WriteUInt32(buffer, 32, ProviderDataOffset); + + WriteAscii(buffer, ProviderDataOffset, "WEVT"); + WriteUInt32(buffer, ProviderDataOffset + 12, 1); + WriteUInt32(buffer, ProviderDataOffset + 20, EventTableOffset); + + WriteAscii(buffer, EventTableOffset, "EVNT"); + WriteUInt32(buffer, EventTableOffset + 8, 1); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(EventTableOffset + 16), EventId); + buffer[EventTableOffset + 16 + 2] = EventVersion; + WriteUInt32(buffer, EventTableOffset + 16 + 20, TemplateOffset); + + WriteAscii(buffer, TemplateOffset, "TEMP"); + WriteUInt32(buffer, TemplateOffset + 8, 1); + WriteUInt32(buffer, TemplateOffset + 16, TemplateItemOffset); + + WriteUInt32(buffer, TemplateItemOffset + 8, MapOffset); + WriteUInt32(buffer, TemplateItemOffset + 16, FieldNameOffset); + WriteName(buffer, FieldNameOffset, FieldName); + + WriteAscii(buffer, MapOffset, mapSignature); + WriteUInt32(buffer, MapOffset + 8, MapNameOffset); + WriteUInt32(buffer, MapOffset + 16, 1); + WriteUInt32(buffer, MapOffset + 20, EntryValue); + WriteUInt32(buffer, MapOffset + 24, EntryMessageId); + WriteName(buffer, MapNameOffset, MapName); + + return buffer; + } + + private static void ValueMapEntryAssertSingle(WevtRawMap map, uint value, uint messageId) + { + WevtRawMapEntry entry = Assert.Single(map.Entries); + Assert.Equal(value, entry.Value); + Assert.Equal(messageId, entry.MessageId); + } + + private static void WriteAscii(byte[] buffer, int offset, string value) => + Encoding.ASCII.GetBytes(value).CopyTo(buffer, offset); + + private static void WriteName(byte[] buffer, int offset, string value) + { + byte[] characters = Encoding.Unicode.GetBytes(value); + WriteUInt32(buffer, offset, (uint)(4 + characters.Length)); + characters.CopyTo(buffer, offset + 4); + } + + private static void WriteUInt32(byte[] buffer, int offset, uint value) => + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset), value); +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Readers/EventPropertyTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Readers/EventPropertyTests.cs new file mode 100644 index 000000000..4724ab36f --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Readers/EventPropertyTests.cs @@ -0,0 +1,87 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Readers; +using System.Security.Principal; + +namespace EventLogExpert.Eventing.Tests.Readers; + +public sealed class EventPropertyTests +{ + [Fact] + public void Equals_ComparesKindAndValue() + { + Assert.Equal((EventProperty)5, (EventProperty)5); + Assert.Equal((EventProperty)"abc", (EventProperty)"abc"); + + // Same bit value but different Kind (Int32 vs UInt32) must not compare equal. + Assert.NotEqual((EventProperty)5, (EventProperty)5u); + Assert.NotEqual((EventProperty)5, (EventProperty)6); + Assert.NotEqual((EventProperty)"abc", (EventProperty)"xyz"); + } + + [Fact] + public void Kind_ReflectsTheConstructingType() + { + Assert.Equal(EventPropertyKind.SByte, ((EventProperty)(sbyte)1).Kind); + Assert.Equal(EventPropertyKind.Byte, ((EventProperty)(byte)1).Kind); + Assert.Equal(EventPropertyKind.Int16, ((EventProperty)(short)1).Kind); + Assert.Equal(EventPropertyKind.UInt16, ((EventProperty)(ushort)1).Kind); + Assert.Equal(EventPropertyKind.Int32, ((EventProperty)1).Kind); + Assert.Equal(EventPropertyKind.UInt32, ((EventProperty)1u).Kind); + Assert.Equal(EventPropertyKind.Int64, ((EventProperty)1L).Kind); + Assert.Equal(EventPropertyKind.UInt64, ((EventProperty)1UL).Kind); + Assert.Equal(EventPropertyKind.Single, ((EventProperty)1.0f).Kind); + Assert.Equal(EventPropertyKind.Double, ((EventProperty)1.0d).Kind); + Assert.Equal(EventPropertyKind.Boolean, ((EventProperty)true).Kind); + Assert.Equal(EventPropertyKind.DateTime, ((EventProperty)new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)).Kind); + Assert.Equal(EventPropertyKind.SizeT, ((EventProperty)(nuint)1).Kind); + Assert.Equal(EventPropertyKind.Reference, ((EventProperty)"text").Kind); + Assert.Equal(EventPropertyKind.Reference, ((EventProperty)(byte[])[1, 2]).Kind); + Assert.Equal(EventPropertyKind.Reference, ((EventProperty)Guid.Empty).Kind); + Assert.Equal(EventPropertyKind.Reference, ((EventProperty)new SecurityIdentifier("S-1-5-18")).Kind); + Assert.Equal(EventPropertyKind.Reference, EventProperty.FromReference(new uint[] { 1 }).Kind); + } + + [Fact] + public void TryGetUnsignedBits_CrossWidthIntegrals_ExtractEqualNativeValue() + { + // The manifest valueMap contract treats (byte)10, (short)10, 10, 10UL, ... as the same key. + Assert.Equal(10UL, Bits((byte)10)); + Assert.Equal(10UL, Bits((sbyte)10)); + Assert.Equal(10UL, Bits((short)10)); + Assert.Equal(10UL, Bits((ushort)10)); + Assert.Equal(10UL, Bits(10)); + Assert.Equal(10UL, Bits(10u)); + Assert.Equal(10UL, Bits(10L)); + Assert.Equal(10UL, Bits(10UL)); + } + + [Fact] + public void TryGetUnsignedBits_NonIntegralKinds_ReturnFalse() + { + Assert.False(((EventProperty)1.5f).TryGetUnsignedBits(out _)); + Assert.False(((EventProperty)1.5d).TryGetUnsignedBits(out _)); + Assert.False(((EventProperty)(nuint)10).TryGetUnsignedBits(out _)); + Assert.False(((EventProperty)true).TryGetUnsignedBits(out _)); + Assert.False(((EventProperty)new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TryGetUnsignedBits(out _)); + Assert.False(((EventProperty)"10").TryGetUnsignedBits(out _)); + Assert.False(((EventProperty)(byte[])[1, 2]).TryGetUnsignedBits(out _)); + } + + [Fact] + public void TryGetUnsignedBits_SignedNegative_MasksToNativeUnsignedWidth() + { + Assert.Equal(0xFFUL, Bits((sbyte)-1)); + Assert.Equal(0xFFFFUL, Bits((short)-1)); + Assert.Equal(0xFFFFFFFFUL, Bits(-1)); + Assert.Equal(0xFFFFFFFFFFFFFFFFUL, Bits(-1L)); + } + + private static ulong Bits(EventProperty property) + { + Assert.True(property.TryGetUnsignedBits(out ulong bits)); + + return bits; + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverBaseTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverBaseTests.cs index 9961dc915..4af9ecae5 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverBaseTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverBaseTests.cs @@ -404,6 +404,83 @@ public void ResolveEvent_WithBasicEventRecord_ShouldReturnDisplayEventModel() Assert.Equal("Warning", displayEvent.Level); } + [Fact] + public void ResolveEvent_WithBitMap_ShouldDecodeFlagsToCommaJoinedNames() + { + // Arrange - Kernel-Boot VsmPolicy: 643 = 512 | 128 | 2 | 1 decodes to the joined flag names. + var (details, eventRecord) = EventUtils.CreateModernEvent( + "VSM policies: %1", + """ + + """, + [643u]); + + details.Maps = new Dictionary + { + ["VsmPolicyMap"] = new ValueMapDefinition( + isBitMap: true, + entries: + [ + new ValueMapEntry(1, "VBS Enabled"), + new ValueMapEntry(2, "VSM Required"), + new ValueMapEntry(128, "Hvci"), + new ValueMapEntry(512, "Boot Chain Signer Soft Enforced") + ]) + }; + + var resolver = new TestEventResolver([details]); + + // Act + var displayEvent = resolver.ResolveEvent(eventRecord); + + // Assert + Assert.NotNull(displayEvent); + Assert.Contains( + "VSM policies: VBS Enabled,VSM Required,Hvci,Boot Chain Signer Soft Enforced", + displayEvent.Description); + } + + [Fact] + public void ResolveEvent_WithBoolean_ShouldFormatLowercase() + { + var (trueDetails, trueRecord) = EventUtils.CreateModernEvent( + "Flag: %1", + """""", + [true]); + + var (falseDetails, falseRecord) = EventUtils.CreateModernEvent( + "Flag: %1", + """""", + [false]); + + var trueEvent = new TestEventResolver([trueDetails]).ResolveEvent(trueRecord); + var falseEvent = new TestEventResolver([falseDetails]).ResolveEvent(falseRecord); + + Assert.NotNull(trueEvent); + Assert.NotNull(falseEvent); + Assert.Contains("Flag: true", trueEvent.Description); + Assert.Contains("Flag: false", falseEvent.Description); + } + + [Fact] + public void ResolveEvent_WithByteArrayAndHexOutType_ShouldStillFormatAsHexString() + { + // A byte[] is always rendered via Convert.ToHexString; a hex outType must not change that. + var (details, eventRecord) = EventUtils.CreateModernEvent( + "Data: %1", + """""", + [new byte[] { 0xAB, 0xCD }]); + + var resolver = new TestEventResolver([details]); + + var displayEvent = resolver.ResolveEvent(eventRecord); + + Assert.NotNull(displayEvent); + Assert.Contains("ABCD", displayEvent.Description); + } + [Fact] public void ResolveEvent_WithCache_ShouldUseCachedStrings() { @@ -924,6 +1001,24 @@ public void ResolveEvent_WithEmptyProviderDetailsAndSingleProperty_ShouldDumpPro Assert.Equal("The entire description is this single string.", displayEvent.Description); } + [Fact] + public void ResolveEvent_WithEvtHandleProperty_RendersViaToStringWithoutThrowing() + { + // EvtHandle is one of the ConvertVariant outputs; it is a reference rendered via .ToString(). + var (details, eventRecord) = EventUtils.CreateModernEvent( + "Handle: %1", + """""", + [EventProperty.FromReference(new EvtHandle(IntPtr.Zero, ownsHandle: false))]); + + var resolver = new TestEventResolver([details]); + + var displayEvent = resolver.ResolveEvent(eventRecord); + + Assert.NotNull(displayEvent); + Assert.DoesNotContain("Failed to resolve", displayEvent.Description); + Assert.Contains(nameof(EvtHandle), displayEvent.Description); + } + [Fact] public void ResolveEvent_WithFormattingCharacters_ShouldCleanupDescription() { @@ -964,6 +1059,46 @@ public void ResolveEvent_WithFormattingCharacters_ShouldCleanupDescription() Assert.Contains("\t", displayEvent.Description); } + [Fact] + public void ResolveEvent_WithGuid_ShouldFormatWithBraces() + { + var (details, eventRecord) = EventUtils.CreateModernEvent( + "Id: %1", + """""", + [new Guid("12345678-1234-1234-1234-123456789abc")]); + + var resolver = new TestEventResolver([details]); + + var displayEvent = resolver.ResolveEvent(eventRecord); + + Assert.NotNull(displayEvent); + Assert.Contains("{12345678-1234-1234-1234-123456789abc}", displayEvent.Description); + } + + [Fact] + public void ResolveEvent_WithGuidProperty_ShouldRenderWithBraces() + { + // Arrange - Windows renders GUID insertions wrapped in braces. + var volumeId = new Guid("4cff5b8e-e659-4f3a-8b2f-1a2b3c4d5e6f"); + var (details, eventRecord) = EventUtils.CreateModernEvent( + "Volume Id: %1", + """ + + """, + [volumeId]); + + var resolver = new TestEventResolver([details]); + + // Act + var displayEvent = resolver.ResolveEvent(eventRecord); + + // Assert + Assert.NotNull(displayEvent); + Assert.Contains("Volume Id: {4cff5b8e-e659-4f3a-8b2f-1a2b3c4d5e6f}", displayEvent.Description); + } + [Fact] public void ResolveEvent_WithHexOutTypeAfterMissingOutType_ShouldFormatCorrectly() { @@ -977,7 +1112,7 @@ public void ResolveEvent_WithHexOutTypeAfterMissingOutType_ShouldFormatCorrectly """, - ["TestApp", 255]); + ["TestApp", 255u]); var resolver = new TestEventResolver([details]); @@ -1045,7 +1180,7 @@ public void ResolveEvent_WithHiddenLengthField_ShouldAlignOutTypesCorrectly() """, - ["TestName", new byte[] { 0xAB, 0xCD }, 255]); + ["TestName", new byte[] { 0xAB, 0xCD }, 255u]); var resolver = new TestEventResolver([details]); @@ -1056,7 +1191,7 @@ public void ResolveEvent_WithHiddenLengthField_ShouldAlignOutTypesCorrectly() Id = 1000, Version = 0, LogName = Constants.ApplicationLogName, - Properties = ["TestName", new byte[] { 0xAB, 0xCD }, 255] + Properties = ["TestName", new byte[] { 0xAB, 0xCD }, 255u] }; // Act @@ -1069,6 +1204,40 @@ public void ResolveEvent_WithHiddenLengthField_ShouldAlignOutTypesCorrectly() Assert.Contains("TestName", displayEvent.Description); } + [Fact] + public void ResolveEvent_WithHighBitUInt32_ShouldFormatAsUnsignedDecimal() + { + var (details, eventRecord) = EventUtils.CreateModernEvent( + "Value: %1", + """""", + [uint.MaxValue]); + + var resolver = new TestEventResolver([details]); + + var displayEvent = resolver.ResolveEvent(eventRecord); + + Assert.NotNull(displayEvent); + Assert.Contains("4294967295", displayEvent.Description); + } + + [Fact] + public void ResolveEvent_WithHighBitUInt64_ShouldFormatAsUnsignedDecimal() + { + // Regression guard: ulong.MaxValue is packed as -1L in the property bit field; the no-outType + // decimal path must render the unsigned value, never "-1". + var (details, eventRecord) = EventUtils.CreateModernEvent( + "Value: %1", + """""", + [ulong.MaxValue]); + + var resolver = new TestEventResolver([details]); + + var displayEvent = resolver.ResolveEvent(eventRecord); + + Assert.NotNull(displayEvent); + Assert.Contains("18446744073709551615", displayEvent.Description); + } + [Fact] public void ResolveEvent_WithHResultOutType_ShouldResolveDynamically() { @@ -1415,6 +1584,29 @@ public void ResolveEvent_WithLogNameMismatch_ShouldNotMatchWhenAmbiguous() Assert.DoesNotContain("Channel B", displayEvent.Description); } + [Fact] + public void ResolveEvent_WithMapAttributeButNoMapDefinition_ShouldFallBackToRawValue() + { + // Arrange - the template references a map, but no definition is loaded (DB/MTA provider path). + var (details, eventRecord) = EventUtils.CreateModernEvent( + "Bus Type: %1", + """ + + """, + [10u]); + + var resolver = new TestEventResolver([details]); + + // Act + var displayEvent = resolver.ResolveEvent(eventRecord); + + // Assert + Assert.NotNull(displayEvent); + Assert.Contains("Bus Type: 10", displayEvent.Description); + } + [Fact] public void ResolveEvent_WithMismatchedPropertyCount_ShouldNotMatchWrongTemplate() { @@ -2034,6 +2226,39 @@ public void ResolveEvent_WithMultipleProperties_ShouldFormatAllProperties() Assert.Contains("true", displayEvent.Description); } + [Fact] + public void ResolveEvent_WithNegativeInt32_ShouldFormatAsSignedDecimal() + { + var (details, eventRecord) = EventUtils.CreateModernEvent( + "Value: %1", + """""", + [-1]); + + var resolver = new TestEventResolver([details]); + + var displayEvent = resolver.ResolveEvent(eventRecord); + + Assert.NotNull(displayEvent); + Assert.Contains("Value: -1", displayEvent.Description); + } + + [Fact] + public void ResolveEvent_WithNegativeSByteAndNTStatusOutType_ShouldFallBackToSignedDecimal() + { + // sbyte is excluded from NTStatus resolution and must render as its signed decimal value. + var (details, eventRecord) = EventUtils.CreateModernEvent( + "Status: %1", + """""", + [(sbyte)-1]); + + var resolver = new TestEventResolver([details]); + + var displayEvent = resolver.ResolveEvent(eventRecord); + + Assert.NotNull(displayEvent); + Assert.Contains("Status: -1", displayEvent.Description); + } + [Fact] public void ResolveEvent_WithNewlineSeparatedDataAttributes_ShouldCountAllElements() { @@ -3043,6 +3268,48 @@ public void ResolveEvent_WithQualifiersPresentAndShortOnlyManifest_ShouldFallBac Assert.Contains("Short-only manifest entry", displayEvent.Description); } + [Fact] + public void ResolveEvent_WithReferenceArrayShapes_RenderViaToString() + { + // Array / Handle / Xml / AnsiString variants all share the reference default branch (-> ToString), + // matching the pre-unboxing behavior. uint[]/int[]/ushort[] reach EventProperty via FromReference + // (the path RenderEventProperties uses for them); string[] has a typed implicit operator. + var (uintArrayDetails, uintArrayRecord) = EventUtils.CreateModernEvent( + "Value: %1", + """""", + [EventProperty.FromReference(new uint[] { 1, 2, 3 })]); + + var (stringArrayDetails, stringArrayRecord) = EventUtils.CreateModernEvent( + "Value: %1", + """""", + [(string[])["a", "b"]]); + + var uintArrayEvent = new TestEventResolver([uintArrayDetails]).ResolveEvent(uintArrayRecord); + var stringArrayEvent = new TestEventResolver([stringArrayDetails]).ResolveEvent(stringArrayRecord); + + Assert.NotNull(uintArrayEvent); + Assert.NotNull(stringArrayEvent); + Assert.Contains("System.UInt32[]", uintArrayEvent.Description); + Assert.Contains("System.String[]", stringArrayEvent.Description); + } + + [Fact] + public void ResolveEvent_WithSByteAndNTStatusOutType_ShouldFallBackToDecimal() + { + // NTStatus resolution excludes sbyte; an sbyte property must render as its decimal value. + var (details, eventRecord) = EventUtils.CreateModernEvent( + "Status: %1", + """""", + [(sbyte)5]); + + var resolver = new TestEventResolver([details]); + + var displayEvent = resolver.ResolveEvent(eventRecord); + + Assert.NotNull(displayEvent); + Assert.Contains("Status: 5", displayEvent.Description); + } + [Fact] public void ResolveEvent_WithSeverityLevel_ShouldResolveLevelString() { @@ -3155,6 +3422,45 @@ public void ResolveEvent_WithShortCastFallbackAndHighEventId_ShouldMatchUsingUns Assert.Contains("High EventID match", displayEvent.Description); } + [Fact] + public void ResolveEvent_WithSid_ShouldFormatAsSddl() + { + var (details, eventRecord) = EventUtils.CreateModernEvent( + "User: %1", + """""", + [new SecurityIdentifier("S-1-5-18")]); + + var resolver = new TestEventResolver([details]); + + var displayEvent = resolver.ResolveEvent(eventRecord); + + Assert.NotNull(displayEvent); + Assert.Contains("S-1-5-18", displayEvent.Description); + } + + [Fact] + public void ResolveEvent_WithSingleAndDouble_ShouldRoundTripThroughBitField() + { + // Regression guard: Single/Double are stored via BitConverter reinterpret, not a numeric cast. + var (singleDetails, singleRecord) = EventUtils.CreateModernEvent( + "Value: %1", + """""", + [3.5f]); + + var (doubleDetails, doubleRecord) = EventUtils.CreateModernEvent( + "Value: %1", + """""", + [2.5d]); + + var singleEvent = new TestEventResolver([singleDetails]).ResolveEvent(singleRecord); + var doubleEvent = new TestEventResolver([doubleDetails]).ResolveEvent(doubleRecord); + + Assert.NotNull(singleEvent); + Assert.NotNull(doubleEvent); + Assert.Contains("3.5", singleEvent.Description); + Assert.Contains("2.5", doubleEvent.Description); + } + [Fact] public void ResolveEvent_WithSinglePropertyAndNoTemplate_ShouldUsePropertyAsDescription() { @@ -3186,6 +3492,22 @@ public void ResolveEvent_WithSinglePropertyAndNoTemplate_ShouldUsePropertyAsDesc Assert.Equal("This is the description from property", displayEvent.Description); } + [Fact] + public void ResolveEvent_WithSizeT_ShouldFormatAsDecimal() + { + var (details, eventRecord) = EventUtils.CreateModernEvent( + "Value: %1", + """""", + [(nuint)4096]); + + var resolver = new TestEventResolver([details]); + + var displayEvent = resolver.ResolveEvent(eventRecord); + + Assert.NotNull(displayEvent); + Assert.Contains("4096", displayEvent.Description); + } + [Fact] public void ResolveEvent_WithSplitKeywordsBetweenPrimaryAndSupplemental_ShouldMergeWithPrimaryWins() { @@ -3534,6 +3856,52 @@ public void ResolveEvent_WithTrailingPercentN_ShouldNotThrowIndexOutOfRange() Assert.EndsWith("\r\n", displayEvent.Description); } + [Fact] + public void ResolveEvent_WithUtcDateTime_ShouldFormatRoundTrip() + { + var (details, eventRecord) = EventUtils.CreateModernEvent( + "Time: %1", + """""", + [new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc)]); + + var resolver = new TestEventResolver([details]); + + var displayEvent = resolver.ResolveEvent(eventRecord); + + Assert.NotNull(displayEvent); + Assert.Contains("2024-01-01T12:00:00", displayEvent.Description); + } + + [Fact] + public void ResolveEvent_WithValueMap_ShouldDecodeEnumValueToName() + { + // Arrange - Ntfs BusType: the raw value 10 decodes to "SAS" via a valueMap. + var (details, eventRecord) = EventUtils.CreateModernEvent( + "Bus Type: %1", + """ + + """, + [10u]); + + details.Maps = new Dictionary + { + ["BusTypeMap"] = new ValueMapDefinition( + isBitMap: false, + entries: [new ValueMapEntry(10, "SAS")]) + }; + + var resolver = new TestEventResolver([details]); + + // Act + var displayEvent = resolver.ResolveEvent(eventRecord); + + // Assert + Assert.NotNull(displayEvent); + Assert.Contains("Bus Type: SAS", displayEvent.Description); + } + [Fact] public void ResolveEvent_WithXmlProperty_ShouldPreserveXml() { diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/TemplateAnalyzerTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/TemplateAnalyzerTests.cs new file mode 100644 index 000000000..7443e582a --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/TemplateAnalyzerTests.cs @@ -0,0 +1,47 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Resolvers; + +namespace EventLogExpert.Eventing.Tests.Resolvers; + +public sealed class TemplateAnalyzerTests +{ + [Fact] + public void Analyze_ExtractsMapAttribute_InDocumentOrder() + { + var analyzer = new TemplateAnalyzer(); + + TemplateMetadata metadata = analyzer.Analyze( + ""); + + Assert.Equal(["BusTypeMap", ""], metadata.AllMaps); + Assert.Equal(["BusTypeMap", ""], metadata.VisibleMaps); + } + + [Fact] + public void Analyze_LengthProviderNode_ExcludedFromVisibleMaps() + { + var analyzer = new TemplateAnalyzer(); + + TemplateMetadata metadata = analyzer.Analyze( + ""); + + Assert.Equal(["", "PayloadMap"], metadata.AllMaps); + Assert.Equal(["PayloadMap"], metadata.VisibleMaps); + } + + [Fact] + public void Analyze_NoMapAttribute_YieldsEmptyMapStrings() + { + var analyzer = new TemplateAnalyzer(); + + TemplateMetadata metadata = analyzer.Analyze( + ""); + + Assert.Equal([""], metadata.AllMaps); + } +} diff --git a/tests/Unit/EventLogExpert.Provider.Tests/Resolution/ValueMapDefinitionTests.cs b/tests/Unit/EventLogExpert.Provider.Tests/Resolution/ValueMapDefinitionTests.cs new file mode 100644 index 000000000..b094d40dc --- /dev/null +++ b/tests/Unit/EventLogExpert.Provider.Tests/Resolution/ValueMapDefinitionTests.cs @@ -0,0 +1,255 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Provider.Resolution; + +namespace EventLogExpert.Provider.Tests.Resolution; + +public sealed class ValueMapDefinitionTests +{ + [Fact] + public void BitMap_EmptyLeadingName_StillEmitsSeparator() + { + // A flag whose manifest message trims to empty yields an empty Name (reachable via + // EventMessageProvider's TrimEnd after its IsNullOrEmpty guard). The separator must be emitted + // per match index like string.Join - never leaving an unwritten '\0' in the string.Create buffer. + var definition = new ValueMapDefinition( + isBitMap: true, + entries: [new ValueMapEntry(1, string.Empty), new ValueMapEntry(2, "B")]); + + bool decoded = definition.TryDecode(3u, out string result); + + Assert.True(decoded); + Assert.Equal(",B", result); + Assert.DoesNotContain('\0', result); + } + + [Fact] + public void BitMap_MultipleFlags_ReturnsCommaJoinedNamesInEntryOrder() + { + // 643 = 512 | 128 | 2 | 1 (matches the Kernel-Boot VsmPolicy example). + var definition = new ValueMapDefinition( + isBitMap: true, + entries: + [ + new ValueMapEntry(1, "VBS Enabled"), + new ValueMapEntry(2, "VSM Required"), + new ValueMapEntry(4, "Unused"), + new ValueMapEntry(128, "Hvci"), + new ValueMapEntry(512, "Boot Chain Signer Soft Enforced") + ]); + + bool decoded = definition.TryDecode(643u, out string result); + + Assert.True(decoded); + Assert.Equal("VBS Enabled,VSM Required,Hvci,Boot Chain Signer Soft Enforced", result); + } + + [Fact] + public void BitMap_NoBitsMatch_ReturnsFalse() + { + var definition = new ValueMapDefinition( + isBitMap: true, + entries: [new ValueMapEntry(1, "A"), new ValueMapEntry(2, "B")]); + + bool decoded = definition.TryDecode(4u, out string result); + + Assert.False(decoded); + Assert.Equal(string.Empty, result); + } + + [Fact] + public void BitMap_PartialMatchWithUndefinedBit_ReturnsMatchedNamesOnly() + { + // 5 = 1 | 4; only bit 1 is defined, so (matching EvtFormatMessage) the undefined 0x4 bit is dropped. + var definition = new ValueMapDefinition( + isBitMap: true, + entries: [new ValueMapEntry(1, "A"), new ValueMapEntry(2, "B")]); + + bool decoded = definition.TryDecode(5u, out string result); + + Assert.True(decoded); + Assert.Equal("A", result); + } + + [Fact] + public void BitMap_SingleFlag_ReturnsName() + { + var definition = new ValueMapDefinition( + isBitMap: true, + entries: [new ValueMapEntry(1, "Enabled"), new ValueMapEntry(2, "Required")]); + + bool decoded = definition.TryDecode(2u, out string result); + + Assert.True(decoded); + Assert.Equal("Required", result); + } + + [Fact] + public void BitMap_Zero_WithoutZeroEntry_ReturnsFalse() + { + var definition = new ValueMapDefinition( + isBitMap: true, + entries: [new ValueMapEntry(1, "A"), new ValueMapEntry(2, "B")]); + + bool decoded = definition.TryDecode(0u, out string result); + + Assert.False(decoded); + Assert.Equal(string.Empty, result); + } + + [Fact] + public void BitMap_Zero_WithZeroEntry_ReturnsZeroName() + { + var definition = new ValueMapDefinition( + isBitMap: true, + entries: [new ValueMapEntry(0, "None"), new ValueMapEntry(1, "A")]); + + bool decoded = definition.TryDecode(0u, out string result); + + Assert.True(decoded); + Assert.Equal("None", result); + } + + [Fact] + public void BitMap_ZeroValuedEntry_SkippedForNonZeroInput() + { + // A zero-valued flag must not be emitted for a non-zero input (a & 0 == 0 would always "match"). + var definition = new ValueMapDefinition( + isBitMap: true, + entries: [new ValueMapEntry(0, "None"), new ValueMapEntry(1, "A")]); + + bool decoded = definition.TryDecode(1u, out string result); + + Assert.True(decoded); + Assert.Equal("A", result); + } + + [Fact] + public void EmptyEntries_ReturnsFalse() + { + var definition = new ValueMapDefinition(isBitMap: false, entries: []); + + bool decoded = definition.TryDecode(10u, out string result); + + Assert.False(decoded); + Assert.Equal(string.Empty, result); + } + + [Fact] + public void IsBitMap_ReflectsConstructorArgument() + { + Assert.True(new ValueMapDefinition(isBitMap: true, entries: []).IsBitMap); + Assert.False(new ValueMapDefinition(isBitMap: false, entries: []).IsBitMap); + } + + [Fact] + public void TryDecodeBits_BitMap_JoinsMatchedFlagNames() + { + var definition = new ValueMapDefinition( + isBitMap: true, + entries: [new ValueMapEntry(1, "A"), new ValueMapEntry(2, "B"), new ValueMapEntry(4, "C")]); + + Assert.True(definition.TryDecodeBits(0b101, out string result)); + Assert.Equal("A,C", result); + } + + [Fact] + public void TryDecodeBits_EmptyEntries_ReturnsFalse() + { + var definition = new ValueMapDefinition(isBitMap: false, entries: []); + + Assert.False(definition.TryDecodeBits(10UL, out string result)); + Assert.Equal(string.Empty, result); + } + + [Fact] + public void TryDecodeBits_ValueMap_LooksUpByExactBits() + { + var definition = new ValueMapDefinition( + isBitMap: false, + entries: [new ValueMapEntry(10, "SAS")]); + + Assert.True(definition.TryDecodeBits(10UL, out string result)); + Assert.Equal("SAS", result); + Assert.False(definition.TryDecodeBits(99UL, out _)); + } + + [Fact] + public void ValueMap_ExactMatch_ReturnsName() + { + // Matches the Ntfs BusType example: 10 -> SAS. + var definition = new ValueMapDefinition( + isBitMap: false, + entries: + [ + new ValueMapEntry(9, "iSCSI"), + new ValueMapEntry(10, "SAS"), + new ValueMapEntry(11, "SATA") + ]); + + bool decoded = definition.TryDecode(10u, out string result); + + Assert.True(decoded); + Assert.Equal("SAS", result); + } + + [Fact] + public void ValueMap_IntegralTypes_AreAccepted() + { + var definition = new ValueMapDefinition( + isBitMap: false, + entries: [new ValueMapEntry(10, "SAS")]); + + Assert.True(definition.TryDecode((byte)10, out string fromByte)); + Assert.Equal("SAS", fromByte); + + Assert.True(definition.TryDecode((short)10, out string fromShort)); + Assert.Equal("SAS", fromShort); + + Assert.True(definition.TryDecode(10, out string fromInt)); + Assert.Equal("SAS", fromInt); + + Assert.True(definition.TryDecode(10UL, out string fromUlong)); + Assert.Equal("SAS", fromUlong); + } + + [Fact] + public void ValueMap_NoMatch_ReturnsFalse() + { + var definition = new ValueMapDefinition( + isBitMap: false, + entries: [new ValueMapEntry(10, "SAS")]); + + bool decoded = definition.TryDecode(99u, out string result); + + Assert.False(decoded); + Assert.Equal(string.Empty, result); + } + + [Fact] + public void ValueMap_NonIntegralValue_ReturnsFalse() + { + var definition = new ValueMapDefinition( + isBitMap: false, + entries: [new ValueMapEntry(10, "SAS")]); + + Assert.False(definition.TryDecode("10", out _)); + Assert.False(definition.TryDecode(null, out _)); + Assert.False(definition.TryDecode(10.0, out _)); + } + + [Fact] + public void ValueMap_SignedNegativeOne_MatchesUnsignedMaxEntry() + { + // A 32-bit -1 must decode against an unsigned 0xFFFFFFFF entry, not sign-extend to 64 bits. + var definition = new ValueMapDefinition( + isBitMap: false, + entries: [new ValueMapEntry(0xFFFFFFFF, "Unknown")]); + + bool decoded = definition.TryDecode(-1, out string result); + + Assert.True(decoded); + Assert.Equal("Unknown", result); + } +}