From d577a7cad8ee5086041c9a8a39657594bf9827de Mon Sep 17 00:00:00 2001 From: jschick04 Date: Thu, 25 Jun 2026 09:50:08 -0500 Subject: [PATCH 01/33] Hash event templates by render-equivalent field tuples --- .../CreateDatabase/CreateDatabaseOperation.cs | 107 +++++++++- .../Resolvers/TemplateAnalyzer.cs | 74 ++----- ...nicalizer.cs => ProviderContentEncoder.cs} | 18 +- .../Hashing/VersionKeyCalculator.cs | 6 +- .../Resolution/ProviderContentMerge.cs | 2 +- .../Resolution/TemplateField.cs | 55 +++++ .../Resolution/TemplateFieldReader.cs | 195 ++++++++++++++++++ .../Resolution/TemplateSignature.cs | 83 ++++++++ .../Hashing/VersionKeyCalculatorTests.cs | 35 +++- .../Maintenance/ProviderDetailsMergerTests.cs | 36 ++++ .../Resolution/TemplateSignatureTests.cs | 114 ++++++++++ 11 files changed, 646 insertions(+), 79 deletions(-) rename src/EventLogExpert.Provider.Database/Hashing/{ProviderContentCanonicalizer.cs => ProviderContentEncoder.cs} (92%) create mode 100644 src/EventLogExpert.Provider/Resolution/TemplateField.cs create mode 100644 src/EventLogExpert.Provider/Resolution/TemplateFieldReader.cs create mode 100644 src/EventLogExpert.Provider/Resolution/TemplateSignature.cs create mode 100644 tests/Unit/EventLogExpert.Provider.Tests/Resolution/TemplateSignatureTests.cs diff --git a/src/EventLogExpert.DatabaseTools/CreateDatabase/CreateDatabaseOperation.cs b/src/EventLogExpert.DatabaseTools/CreateDatabase/CreateDatabaseOperation.cs index e99c3f3f..c8ed4e33 100644 --- a/src/EventLogExpert.DatabaseTools/CreateDatabase/CreateDatabaseOperation.cs +++ b/src/EventLogExpert.DatabaseTools/CreateDatabase/CreateDatabaseOperation.cs @@ -73,6 +73,11 @@ public async Task ExecuteAsync( // already-hashed row in a multi-file source): both re-hash to the same VersionKey, so the second would // otherwise collide on the composite primary key. Track stamped identities and skip duplicates first-wins. var stampedIdentities = new HashSet(); +#if DEBUG + // CI-only tripwire: fail the build if a (Name, VersionKey) collision's rows are not content-equivalent (hash and + // merge drift). No release retention. + var firstByIdentity = new Dictionary(); +#endif // Defer creating the DbContext (and therefore the .db file on disk) until we have // at least one provider to persist. This prevents leaving an empty database behind @@ -96,7 +101,20 @@ public async Task ExecuteAsync( // already-hashed source; computes the key for freshly-resolved (live) providers. details.VersionKey = VersionKeyCalculator.Compute(details); - if (!stampedIdentities.Add(ProviderIdentity.Of(details))) { continue; } + var identity = ProviderIdentity.Of(details); + + if (!stampedIdentities.Add(identity)) + { +#if DEBUG + AssertContentEquivalent(firstByIdentity[identity], details); +#endif + + continue; + } + +#if DEBUG + firstByIdentity[identity] = details; +#endif if (hostOsProvenance is not null) { @@ -206,4 +224,91 @@ private async Task FlushHeaderAndBufferAsync( dbContext.ChangeTracker.Clear(); buffer.Clear(); } + +#if DEBUG + private static void AssertContentEquivalent(ProviderDetails first, ProviderDetails duplicate) + { + if (ContentEquivalent(first, duplicate)) { return; } + + throw new InvalidOperationException( + $"Provider '{duplicate.ProviderName}' produced two rows sharing VersionKey '{duplicate.VersionKey}' that " + + $"are not content-equivalent. The content hash and {nameof(ProviderContentMerge)} have drifted - a field " + + $"is hashed for identity but not compared for equivalence (or vice versa)."); + } + + private static bool ContentEquivalent(ProviderDetails first, ProviderDetails duplicate) => + ModelsEquivalent(first.Events, duplicate.Events, static model => ProviderContentMerge.IdentityOf(model), ProviderContentMerge.EventsAreEquivalent) && + ModelsEquivalent(first.Messages, duplicate.Messages, static model => ProviderContentMerge.IdentityOf(model), ProviderContentMerge.MessagesAreEquivalent) && + ModelsEquivalent(first.Parameters, duplicate.Parameters, static model => ProviderContentMerge.IdentityOf(model), ProviderContentMerge.MessagesAreEquivalent) && + MapsEquivalent(first.Maps, duplicate.Maps) && + DictionaryEqual(first.Keywords, duplicate.Keywords) && + DictionaryEqual(first.Opcodes, duplicate.Opcodes) && + DictionaryEqual(first.Tasks, duplicate.Tasks) && + string.Equals( + first.ResolvedFromOwningPublisher ?? string.Empty, + duplicate.ResolvedFromOwningPublisher ?? string.Empty, + StringComparison.Ordinal); + + private static bool DictionaryEqual(IDictionary first, IDictionary second) + where TKey : notnull + { + if (first.Count != second.Count) { return false; } + + foreach ((TKey key, string value) in first) + { + if (!second.TryGetValue(key, out string? other) || !string.Equals(value, other, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + private static bool MapsEquivalent( + IReadOnlyDictionary first, + IReadOnlyDictionary second) + { + if (first.Count != second.Count) { return false; } + + foreach ((string key, ValueMapDefinition map) in first) + { + if (!second.TryGetValue(key, out ValueMapDefinition? other) || !ProviderContentMerge.MapsAreEquivalent(map, other)) + { + return false; + } + } + + return true; + } + + private static bool ModelsEquivalent( + IReadOnlyList first, + IReadOnlyList second, + Func identityOf, + Func areEquivalent) + where TIdentity : notnull + { + // Compare DISTINCT identities both ways (the hash drops exact-duplicate rows, so raw counts can differ). + var firstByIdentity = new Dictionary(first.Count); + + foreach (TModel model in first) { firstByIdentity[identityOf(model)] = model; } + + var secondByIdentity = new Dictionary(second.Count); + + foreach (TModel model in second) { secondByIdentity[identityOf(model)] = model; } + + if (firstByIdentity.Count != secondByIdentity.Count) { return false; } + + foreach ((TIdentity identity, TModel model) in firstByIdentity) + { + if (!secondByIdentity.TryGetValue(identity, out TModel? other) || !areEquivalent(model, other)) + { + return false; + } + } + + return true; + } +#endif } diff --git a/src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs b/src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs index 13a0b758..18d66ae5 100644 --- a/src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs +++ b/src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs @@ -1,6 +1,7 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using EventLogExpert.Provider.Resolution; using System.Collections.Concurrent; using System.Collections.Immutable; using System.Runtime.InteropServices; @@ -180,81 +181,30 @@ private static TemplateMetadata BuildMetadata( return new TemplateMetadata(visibleCount, allOutTypes, visibleOutTypes, allMaps, visibleMaps); } - private static string? ExtractAttribute(ReadOnlySpan element, ReadOnlySpan attributePrefix) - { - int index = element.IndexOf(attributePrefix, StringComparison.Ordinal); - - if (index == -1) { return null; } - - index += attributePrefix.Length; - int endIndex = element[index..].IndexOf('"'); - - return endIndex != -1 ? new string(element.Slice(index, endIndex)) : null; - } - private static TemplateMetadata Parse(ReadOnlySpan template) { 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; - - while (searchStart < template.Length) + foreach (TemplateField field in new TemplateFieldReader(template)) { - int dataIndex = template[searchStart..].IndexOf(dataTag, StringComparison.OrdinalIgnoreCase); - - if (dataIndex == -1) { break; } - - dataIndex += searchStart; - - // Verify the character after "' - // to avoid matching tags like ""); + elements.Add(( + field.Name.IsEmpty ? string.Empty : new string(field.Name), + field.OutType.IsEmpty ? string.Empty : new string(field.OutType), + field.Map.IsEmpty ? string.Empty : new string(field.Map))); - if (elementEnd == -1) + if (!field.Length.IsEmpty) { - elementEnd = template[dataIndex..].IndexOf('>'); + lengthProviderNames.Add(new string(field.Length)); } - - if (elementEnd == -1) { break; } - - elementEnd += dataIndex; - - ReadOnlySpan element = template[dataIndex..elementEnd]; - - string name = ExtractAttribute(element, nameAttr) ?? string.Empty; - string outType = ExtractAttribute(element, outTypeAttr) ?? string.Empty; - string map = ExtractAttribute(element, mapAttr) ?? string.Empty; - elements.Add((name, outType, map)); - - string? lengthRef = ExtractAttribute(element, lengthAttr); - - if (lengthRef is not null) - { - lengthProviderNames.Add(lengthRef); - } - - searchStart = elementEnd + 1; } return BuildMetadata(elements, lengthProviderNames); diff --git a/src/EventLogExpert.Provider.Database/Hashing/ProviderContentCanonicalizer.cs b/src/EventLogExpert.Provider.Database/Hashing/ProviderContentEncoder.cs similarity index 92% rename from src/EventLogExpert.Provider.Database/Hashing/ProviderContentCanonicalizer.cs rename to src/EventLogExpert.Provider.Database/Hashing/ProviderContentEncoder.cs index 7c2a6932..21e32a3e 100644 --- a/src/EventLogExpert.Provider.Database/Hashing/ProviderContentCanonicalizer.cs +++ b/src/EventLogExpert.Provider.Database/Hashing/ProviderContentEncoder.cs @@ -22,20 +22,16 @@ namespace EventLogExpert.ProviderDatabase.Hashing; /// order is unspecified); event keyword lists are sorted AND de-duplicated (the merger compares them as a set); event, /// message, and parameter entries are encoded to self-delimiting blobs that are sorted ordinally with exact duplicates /// dropped (manifest list order is not a stability contract); ValueMap entries keep their ORIGINAL order (bitmap -/// decoding is order-dependent, so order is content). Strings are preserved EXACTLY - no Unicode or whitespace -/// normalization - so the hash stays injective over the persisted bytes; the database's fail-hard rule requires that -/// identical hashes imply identical content. +/// decoding is order-dependent, so order is content). Strings are preserved EXACTLY, except an event's Template, which +/// is encoded by as render-relevant field tuples so templates that render identically +/// collapse across live and offline builds. /// -internal static class ProviderContentCanonicalizer +internal static class ProviderContentEncoder { - /// - /// Bumping this re-keys every provider on purpose (e.g. after a canonicalization fix). Pair it with the - /// vk1: tag in so providers hashed under different schemes never silently - /// share a key. - /// + // Bump to deliberately re-key every provider after a canonicalization change. private const byte SchemeVersion = 1; - public static byte[] Canonicalize(ProviderDetails provider) + internal static byte[] Encode(ProviderDetails provider) { var buffer = new ArrayBufferWriter(); @@ -72,7 +68,7 @@ private static byte[] EncodeEvent(EventModel model) foreach (var keyword in keywords) { WriteInt64(buffer, keyword); } - WriteString(buffer, model.Template); + TemplateSignature.AppendTo(buffer, model.Template.AsSpan()); WriteString(buffer, model.Description); WriteString(buffer, model.LogName); diff --git a/src/EventLogExpert.Provider.Database/Hashing/VersionKeyCalculator.cs b/src/EventLogExpert.Provider.Database/Hashing/VersionKeyCalculator.cs index 6ef13c9f..8227a84f 100644 --- a/src/EventLogExpert.Provider.Database/Hashing/VersionKeyCalculator.cs +++ b/src/EventLogExpert.Provider.Database/Hashing/VersionKeyCalculator.cs @@ -9,8 +9,8 @@ namespace EventLogExpert.ProviderDatabase.Hashing; /// /// Computes a provider's content : a hash of its canonical rendering -/// payload (). Two providers with identical payloads - across machines or -/// OS builds - get the same key and collapse to one database row; genuinely different payloads get different keys and +/// payload (). Two providers with identical payloads - across machines or OS +/// builds - get the same key and collapse to one database row; genuinely different payloads get different keys and /// coexist as separate versions of the same provider name. Stamped when a provider is first ingested from a live scan /// (CreateDatabaseOperation); the merge and diff operations copy already-stamped rows unchanged. The composite /// (ProviderName, VersionKey) primary key can therefore hold distinct versions of one name. @@ -27,7 +27,7 @@ public static class VersionKeyCalculator public static string Compute(ProviderDetails provider) { - var canonical = ProviderContentCanonicalizer.Canonicalize(provider); + var canonical = ProviderContentEncoder.Encode(provider); var hash = SHA256.HashData(canonical); return SchemePrefix + ToBase32Lower(hash); diff --git a/src/EventLogExpert.Provider/Resolution/ProviderContentMerge.cs b/src/EventLogExpert.Provider/Resolution/ProviderContentMerge.cs index c3244f5f..aa54c8e5 100644 --- a/src/EventLogExpert.Provider/Resolution/ProviderContentMerge.cs +++ b/src/EventLogExpert.Provider/Resolution/ProviderContentMerge.cs @@ -63,7 +63,7 @@ public static bool EventsAreEquivalent(EventModel left, EventModel right) => left.Opcode == right.Opcode && left.Task == right.Task && KeywordsEqual(left.Keywords, right.Keywords) && - string.Equals(left.Template, right.Template, StringComparison.Ordinal) && + TemplateSignature.Equal(left.Template.AsSpan(), right.Template.AsSpan()) && string.Equals(left.Description, right.Description, StringComparison.Ordinal); /// Extracts the of a message row. diff --git a/src/EventLogExpert.Provider/Resolution/TemplateField.cs b/src/EventLogExpert.Provider/Resolution/TemplateField.cs new file mode 100644 index 00000000..215f0255 --- /dev/null +++ b/src/EventLogExpert.Provider/Resolution/TemplateField.cs @@ -0,0 +1,55 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Provider.Resolution; + +/// +/// One template <data> node as spans: parsed attributes, or a raw element span when it cannot be +/// canonically parsed. +/// +public readonly ref struct TemplateField +{ + private TemplateField(ReadOnlySpan raw) + { + IsRaw = true; + Raw = raw; + } + + private TemplateField( + ReadOnlySpan name, + ReadOnlySpan inType, + ReadOnlySpan outType, + ReadOnlySpan length, + ReadOnlySpan map) + { + Name = name; + InType = inType; + OutType = outType; + Length = length; + Map = map; + } + + public bool IsRaw { get; } + + public ReadOnlySpan InType { get; } + + public ReadOnlySpan Length { get; } + + public ReadOnlySpan Map { get; } + + public ReadOnlySpan Name { get; } + + public ReadOnlySpan OutType { get; } + + public ReadOnlySpan Raw { get; } + + public static TemplateField Parsed( + ReadOnlySpan name, + ReadOnlySpan inType, + ReadOnlySpan outType, + ReadOnlySpan length, + ReadOnlySpan map) => + new(name, inType, outType, length, map); + + public static TemplateField RawElement(ReadOnlySpan element) => new(element); +} diff --git a/src/EventLogExpert.Provider/Resolution/TemplateFieldReader.cs b/src/EventLogExpert.Provider/Resolution/TemplateFieldReader.cs new file mode 100644 index 00000000..326f36ae --- /dev/null +++ b/src/EventLogExpert.Provider/Resolution/TemplateFieldReader.cs @@ -0,0 +1,195 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Provider.Resolution; + +/// +/// Allocation-free enumerator over a template's <data> nodes - the shared decomposition used by the hash, +/// the merge, and the resolver. Non-canonical elements fail closed to a raw node; count and <struct> are +/// excluded as render-dead. +/// +public ref struct TemplateFieldReader(ReadOnlySpan template) +{ + private const string DataTag = " _remaining = template; + + public TemplateField Current { get; private set; } + + public readonly TemplateFieldReader GetEnumerator() => this; + + public bool MoveNext() + { + ReadOnlySpan span = _remaining; + int searchStart = 0; + + while (searchStart < span.Length) + { + int relative = span[searchStart..].IndexOf(DataTag, StringComparison.OrdinalIgnoreCase); + + if (relative == -1) { break; } + + int dataIndex = searchStart + relative; + int afterTag = dataIndex + DataTag.Length; + + // Reject "'. + if (afterTag < span.Length) + { + char next = span[afterTag]; + + if (next is not (' ' or '\t' or '\r' or '\n' or '/' or '>')) + { + searchStart = afterTag; + + continue; + } + } + + ReadOnlySpan fromData = span[dataIndex..]; + int closeIndex = FindElementEnd(fromData); + + if (closeIndex == -1) + { + // Unterminated element: fail closed to a raw node rather than dropping the remainder. + Current = TemplateField.RawElement(fromData); + _remaining = default; + + return true; + } + + int elementEnd = closeIndex > 0 && fromData[closeIndex - 1] == '/' ? closeIndex - 1 : closeIndex; + Current = ParseElement(fromData[..elementEnd]); + _remaining = span[(dataIndex + closeIndex + 1)..]; + + return true; + } + + _remaining = default; + + return false; + } + + private static int FindElementEnd(ReadOnlySpan fromData) + { + char openQuote = '\0'; + + for (int i = DataTag.Length; i < fromData.Length; i++) + { + char c = fromData[i]; + + if (openQuote != '\0') + { + if (c == openQuote) { openQuote = '\0'; } + } + else if (c is '"' or '\'') + { + openQuote = c; + } + else if (c == '>') + { + return i; + } + } + + return -1; + } + + private static bool IsSignatureAttribute(ReadOnlySpan attributeName) => + attributeName.SequenceEqual("name") || + attributeName.SequenceEqual("inType") || + attributeName.SequenceEqual("outType") || + attributeName.SequenceEqual("length") || + attributeName.SequenceEqual("map"); + + private static TemplateField ParseElement(ReadOnlySpan element) + { + ReadOnlySpan name = default; + ReadOnlySpan inType = default; + ReadOnlySpan outType = default; + ReadOnlySpan length = default; + ReadOnlySpan map = default; + + // Single forward pass over the element's attributes. + int pos = DataTag.Length; + + while (pos < element.Length) + { + if (element[pos] is ' ' or '\t' or '\r' or '\n' or '/') + { + pos++; + + continue; + } + + int nameStart = pos; + + while (pos < element.Length && element[pos] is not ('=' or ' ' or '\t' or '\r' or '\n' or '/')) { pos++; } + + ReadOnlySpan attributeName = element[nameStart..pos]; + bool signature = IsSignatureAttribute(attributeName); + + while (pos < element.Length && element[pos] is ' ' or '\t' or '\r' or '\n') { pos++; } + + if (pos >= element.Length || element[pos] != '=') + { + // A signature attribute with no value is non-canonical - fail closed. + if (signature) { return TemplateField.RawElement(element); } + + continue; + } + + pos++; + + while (pos < element.Length && element[pos] is ' ' or '\t' or '\r' or '\n') { pos++; } + + if (pos >= element.Length || element[pos] != '"') + { + // Single-quoted / unquoted / missing value: fail closed for a signature attribute, otherwise skip it. + if (signature) { return TemplateField.RawElement(element); } + + pos = SkipValue(element, pos); + + continue; + } + + pos++; + int valueStart = pos; + + while (pos < element.Length && element[pos] != '"') { pos++; } + + ReadOnlySpan value = element[valueStart..pos]; + + if (pos < element.Length) { pos++; } + + if (!signature) { continue; } + + if (attributeName.SequenceEqual("name")) { name = value; } + else if (attributeName.SequenceEqual("inType")) { inType = value; } + else if (attributeName.SequenceEqual("outType")) { outType = value; } + else if (attributeName.SequenceEqual("length")) { length = value; } + else if (attributeName.SequenceEqual("map")) { map = value; } + } + + return TemplateField.Parsed(name, inType, outType, length, map); + } + + private static int SkipValue(ReadOnlySpan element, int pos) + { + if (pos >= element.Length) { return pos; } + + char quote = element[pos]; + + if (quote is '"' or '\'') + { + pos++; + + while (pos < element.Length && element[pos] != quote) { pos++; } + + return pos < element.Length ? pos + 1 : pos; + } + + while (pos < element.Length && element[pos] is not (' ' or '\t' or '\r' or '\n')) { pos++; } + + return pos; + } +} diff --git a/src/EventLogExpert.Provider/Resolution/TemplateSignature.cs b/src/EventLogExpert.Provider/Resolution/TemplateSignature.cs new file mode 100644 index 00000000..4cb48eba --- /dev/null +++ b/src/EventLogExpert.Provider/Resolution/TemplateSignature.cs @@ -0,0 +1,83 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Buffers; +using System.Buffers.Binary; +using System.Text; + +namespace EventLogExpert.Provider.Resolution; + +/// +/// Canonical byte encoding of a template's render-relevant fields; the content hash and the merge compare +/// templates by this same encoding (insensitive to whitespace, attribute order, and serialization) so they cannot +/// drift. +/// +public static class TemplateSignature +{ + private const byte ParsedNode = 0; + + private const byte RawNode = 1; + + public static void AppendTo(IBufferWriter buffer, ReadOnlySpan template) + { + var counter = new TemplateFieldReader(template); + int count = 0; + + while (counter.MoveNext()) { count++; } + + WriteInt32(buffer, count); + + foreach (TemplateField field in new TemplateFieldReader(template)) + { + if (field.IsRaw) + { + WriteByte(buffer, RawNode); + WriteString(buffer, field.Raw); + + continue; + } + + WriteByte(buffer, ParsedNode); + WriteString(buffer, field.Name); + WriteString(buffer, field.InType); + WriteString(buffer, field.OutType); + WriteString(buffer, field.Length); + WriteString(buffer, field.Map); + } + } + + public static bool Equal(ReadOnlySpan left, ReadOnlySpan right) + { + var leftBuffer = new ArrayBufferWriter(); + var rightBuffer = new ArrayBufferWriter(); + + AppendTo(leftBuffer, left); + AppendTo(rightBuffer, right); + + return leftBuffer.WrittenSpan.SequenceEqual(rightBuffer.WrittenSpan); + } + + private static void WriteByte(IBufferWriter buffer, byte value) + { + Span span = buffer.GetSpan(1); + span[0] = value; + buffer.Advance(1); + } + + private static void WriteInt32(IBufferWriter buffer, int value) + { + BinaryPrimitives.WriteInt32LittleEndian(buffer.GetSpan(sizeof(int)), value); + buffer.Advance(sizeof(int)); + } + + private static void WriteString(IBufferWriter buffer, ReadOnlySpan value) + { + int byteCount = Encoding.UTF8.GetByteCount(value); + WriteInt32(buffer, byteCount); + + if (byteCount == 0) { return; } + + Encoding.UTF8.GetBytes(value, buffer.GetSpan(byteCount)); + buffer.Advance(byteCount); + } +} diff --git a/tests/Unit/EventLogExpert.Provider.Database.Tests/Hashing/VersionKeyCalculatorTests.cs b/tests/Unit/EventLogExpert.Provider.Database.Tests/Hashing/VersionKeyCalculatorTests.cs index 216b4999..6153b0d8 100644 --- a/tests/Unit/EventLogExpert.Provider.Database.Tests/Hashing/VersionKeyCalculatorTests.cs +++ b/tests/Unit/EventLogExpert.Provider.Database.Tests/Hashing/VersionKeyCalculatorTests.cs @@ -92,6 +92,39 @@ public void Compute_EventListOrder_DoesNotChangeKey() Assert.Equal(VersionKeyCalculator.Compute(first), VersionKeyCalculator.Compute(second)); } + [Fact] + public void Compute_EventTemplateDifferentOutType_ChangesKey() + { + var first = EventUtils.CreateProvider("P", + events: [EventUtils.CreateEventModel(1, template: "")]); + var second = EventUtils.CreateProvider("P", + events: [EventUtils.CreateEventModel(1, template: "")]); + + Assert.NotEqual(VersionKeyCalculator.Compute(first), VersionKeyCalculator.Compute(second)); + } + + [Fact] + public void Compute_EventTemplateNullVersusEmpty_DoesNotChangeKey() + { + // A null template and an empty template both mean "no fields" and render identically, so they share a key. + var nullTemplate = EventUtils.CreateProvider("P", events: [EventUtils.CreateEventModel(1, template: null)]); + var emptyTemplate = EventUtils.CreateProvider("P", events: [EventUtils.CreateEventModel(1, template: "")]); + + Assert.Equal(VersionKeyCalculator.Compute(nullTemplate), VersionKeyCalculator.Compute(emptyTemplate)); + } + + [Fact] + public void Compute_EventTemplateWhitespaceAndAttributeOrderOnly_DoesNotChangeKey() + { + // Live and offline producers serialize templates differently; identical render fields must collapse to one key. + var first = EventUtils.CreateProvider("P", + events: [EventUtils.CreateEventModel(1, template: "")]); + var second = EventUtils.CreateProvider("P", + events: [EventUtils.CreateEventModel(1, template: "")]); + + Assert.Equal(VersionKeyCalculator.Compute(first), VersionKeyCalculator.Compute(second)); + } + [Fact] public void Compute_ExcludesTopLevelAndMessageProviderName() { @@ -191,7 +224,7 @@ public void Compute_StartsWithSchemePrefix() public void EventModelProperties_AreAllAccountedForInTheHash() { // Drift guard: adding or removing an EventModel property fails this. When it does, update EncodeEvent in - // ProviderContentCanonicalizer AND ProviderContentMerge.EventsAreEquivalent in lockstep, then this set. + // ProviderContentEncoder AND ProviderContentMerge.EventsAreEquivalent in lockstep, then this set. var properties = typeof(EventModel).GetProperties().Select(property => property.Name).OrderBy(name => name, StringComparer.Ordinal); Assert.Equal( diff --git a/tests/Unit/EventLogExpert.Provider.Database.Tests/Maintenance/ProviderDetailsMergerTests.cs b/tests/Unit/EventLogExpert.Provider.Database.Tests/Maintenance/ProviderDetailsMergerTests.cs index cd4ea7f6..dc86f5bd 100644 --- a/tests/Unit/EventLogExpert.Provider.Database.Tests/Maintenance/ProviderDetailsMergerTests.cs +++ b/tests/Unit/EventLogExpert.Provider.Database.Tests/Maintenance/ProviderDetailsMergerTests.cs @@ -396,6 +396,42 @@ public void MergeCaseInsensitiveDuplicates_ResolvedFromOwningPublisher_TreatsEmp Assert.Equal("PublisherX", merged[0].ResolvedFromOwningPublisher); } + [Fact] + public void MergeCaseInsensitiveDuplicates_SameEventIdentityDifferentTemplateFields_Throws() + { + var rows = new List + { + EventUtils.CreateProvider("Same-Provider", + events: [EventUtils.CreateEventModel(100, logName: "App", template: "")]), + EventUtils.CreateProvider("same-provider", + events: [EventUtils.CreateEventModel(100, logName: "App", template: "")]) + }; + + var ex = Assert.Throws(() => + ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath)); + + Assert.Contains("Event", ex.Reason); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_TemplatesDifferByteWiseButRenderIdentically_MergesWithoutThrowing() + { + // A live capture and an offline rebuild serialize the same event template differently. The merge compares + // templates by render-equivalence, so the rows collapse instead of being rejected as a conflict. + var rows = new List + { + EventUtils.CreateProvider("Same-Provider", + events: [EventUtils.CreateEventModel(100, logName: "App", template: "")]), + EventUtils.CreateProvider("same-provider", + events: [EventUtils.CreateEventModel(100, logName: "App", template: "")]) + }; + + var merged = ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath); + + var row = Assert.Single(merged); + Assert.Single(row.Events); + } + [Fact] public void MergeCaseInsensitiveDuplicates_TreatsEventsWithSameKeywordsInDifferentOrderAsEqual() { diff --git a/tests/Unit/EventLogExpert.Provider.Tests/Resolution/TemplateSignatureTests.cs b/tests/Unit/EventLogExpert.Provider.Tests/Resolution/TemplateSignatureTests.cs new file mode 100644 index 00000000..95a24989 --- /dev/null +++ b/tests/Unit/EventLogExpert.Provider.Tests/Resolution/TemplateSignatureTests.cs @@ -0,0 +1,114 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Provider.Resolution; + +namespace EventLogExpert.Provider.Tests.Resolution; + +public sealed class TemplateSignatureTests +{ + [Fact] + public void Equal_CountAttributeDiffersOnly_AreEqual() + { + // count is render-dead today and intentionally excluded from the signature. + const string WithoutCount = ""; + const string WithCount = ""; + + Assert.True(TemplateSignature.Equal(WithoutCount, WithCount)); + } + + [Fact] + public void Equal_DifferentFieldOrder_AreNotEqual() + { + const string Ab = ""; + const string Ba = ""; + + Assert.False(TemplateSignature.Equal(Ab, Ba)); + } + + [Fact] + public void Equal_DifferentInType_AreNotEqual() + { + // inType is part of provider content even though the formatter ignores it, so it is kept (conservative). + const string AsUInt = ""; + const string AsInt = ""; + + Assert.False(TemplateSignature.Equal(AsUInt, AsInt)); + } + + [Fact] + public void Equal_DifferentOutType_AreNotEqual() + { + const string AsString = ""; + const string AsHex = ""; + + Assert.False(TemplateSignature.Equal(AsString, AsHex)); + } + + [Fact] + public void Equal_DistinctNonCanonicalElements_FailClosedAndDoNotCollapse() + { + // Single-quoted attributes cannot be canonically extracted, so each element falls back to its raw substring - + // two genuinely different elements must NOT collapse to one empty signature. + const string SingleQuotedA = ""; + const string SingleQuotedB = ""; + + Assert.False(TemplateSignature.Equal(SingleQuotedA, SingleQuotedB)); + } + + [Fact] + public void Equal_NullEmptyAndDataLessTemplate_AreEqual() + { + Assert.True(TemplateSignature.Equal(default, "".AsSpan())); + Assert.True(TemplateSignature.Equal(default, "".AsSpan())); + } + + [Fact] + public void Equal_QuotedAngleBracketInValue_DoesNotTruncateOrCollapseDistinctTemplates() + { + // A '>' or '/>' inside a quoted attribute value must NOT terminate the element early - the trailing + // attributes must still be read, so templates that differ only after such a value stay distinct. + const string WithString = ""; + const string WithHex = ""; + + Assert.False(TemplateSignature.Equal(WithString, WithHex)); + Assert.False(TemplateSignature.Equal(WithString, "")); + } + + [Fact] + public void Equal_StructGroupingDiffersOnly_AreEqual() + { + // grouping is render-dead today and intentionally excluded; the flat data nodes are the signature. + const string Flat = ""; + const string Grouped = ""; + + Assert.True(TemplateSignature.Equal(Flat, Grouped)); + } + + [Fact] + public void Equal_WhitespaceAndAttributeOrderDifferAtSameFields_AreEqual() + { + const string Compact = ""; + const string SpacedAndReordered = ""; + + Assert.True(TemplateSignature.Equal(Compact, SpacedAndReordered)); + } + + [Fact] + public void EventsAreEquivalent_ComparesTemplatesByTheSameSignature() + { + // Locks the merge equivalence to TemplateSignature so the content hash and the merge can never disagree. + const string Compact = ""; + const string Spaced = ""; + const string Different = ""; + + EventModel baseEvent = MakeEvent(Compact); + + Assert.Equal(TemplateSignature.Equal(Compact, Spaced), ProviderContentMerge.EventsAreEquivalent(baseEvent, MakeEvent(Spaced))); + Assert.Equal(TemplateSignature.Equal(Compact, Different), ProviderContentMerge.EventsAreEquivalent(baseEvent, MakeEvent(Different))); + Assert.True(ProviderContentMerge.EventsAreEquivalent(baseEvent, MakeEvent(Spaced))); + Assert.False(ProviderContentMerge.EventsAreEquivalent(baseEvent, MakeEvent(Different))); + + static EventModel MakeEvent(string template) => new() { Id = 1, Keywords = [], Template = template }; + } +} From 8894f39c1e31c2dead09743f3f85afc78fb661f1 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Thu, 25 Jun 2026 13:55:46 -0500 Subject: [PATCH 02/33] Route modern provider loading through a shared ProviderDetails assembler --- .../PublisherMetadata/EventMessageProvider.cs | 197 +------------ .../ProviderDetailsAssembler.cs | 274 ++++++++++++++++++ .../PublisherMetadata/ProviderMetadata.cs | 164 +++++++++++ .../PublisherMetadata/RawProviderContent.cs | 60 ++++ ...ts.cs => ProviderDetailsAssemblerTests.cs} | 16 +- 5 files changed, 509 insertions(+), 202 deletions(-) create mode 100644 src/EventLogExpert.Eventing/PublisherMetadata/ProviderDetailsAssembler.cs create mode 100644 src/EventLogExpert.Eventing/PublisherMetadata/RawProviderContent.cs rename tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/{EventMessageProviderTests.cs => ProviderDetailsAssemblerTests.cs} (74%) diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/EventMessageProvider.cs b/src/EventLogExpert.Eventing/PublisherMetadata/EventMessageProvider.cs index 95fd5382..9eac1a55 100644 --- a/src/EventLogExpert.Eventing/PublisherMetadata/EventMessageProvider.cs +++ b/src/EventLogExpert.Eventing/PublisherMetadata/EventMessageProvider.cs @@ -29,43 +29,6 @@ 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, @@ -86,38 +49,6 @@ 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; } @@ -149,63 +80,15 @@ private ProviderDetails LoadMessagesFromModernProvider(ProviderMetadata provider { _logger?.Debug($"{nameof(LoadMessagesFromModernProvider)} called for provider {_providerName}"); - var provider = new ProviderDetails { ProviderName = _providerName }; - if (!providerMetadata.IsLocaleMetadata && !s_allProviderNames.Contains(_providerName)) { _logger?.Debug($"{_providerName} modern provider is not present. Returning empty provider."); - return provider; - } - - try - { - provider.Events = providerMetadata.Events.Select(e => new EventModel - { - Description = e.Description, - Id = e.Id, - Keywords = e.Keywords.ToArray(), - Level = e.Level, - LogName = e.LogName, - Opcode = e.Opcode, - Task = e.Task, - Template = e.Template, - Version = e.Version - }).ToArray(); - } - catch (Exception ex) - { - _logger?.Debug($"Failed to load Events for modern provider: {_providerName}. Exception:\n{ex}"); - } - - try - { - provider.Keywords = providerMetadata.Keywords; - } - catch (Exception ex) - { - _logger?.Debug($"Failed to load Keywords for modern provider: {_providerName}. Exception:\n{ex}"); - } - - try - { - provider.Opcodes = providerMetadata.Opcodes; - } - catch (Exception ex) - { - _logger?.Debug($"Failed to load Opcodes for modern provider: {_providerName}. Exception:\n{ex}"); - } - - try - { - provider.Tasks = providerMetadata.Tasks; - } - catch (Exception ex) - { - _logger?.Debug($"Failed to load Tasks for modern provider: {_providerName}. Exception:\n{ex}"); + return new ProviderDetails { ProviderName = _providerName }; } - PopulateValueMaps(provider, providerMetadata); + ProviderDetails provider = + ProviderDetailsAssembler.Assemble(providerMetadata.ToRawContent(_providerName, _logger), _logger); _logger?.Debug($"Returning {provider.Events.Count} events for provider {_providerName}"); @@ -273,80 +156,6 @@ private ProviderDetails LoadProviderDetailsCore(HashSet? visited) return provider; } - 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; - } - // Stamps the provider with the newest 4-part numeric file version across its resolved message DLLs - the // per-provider recency signal. Uses FileVersionInfo NUMERIC parts, not the FileVersion string: inbox DLLs carry // a trailing " (WinBuild.160101.0800)" that Version.Parse rejects, which would null the ordinal for nearly every diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/ProviderDetailsAssembler.cs b/src/EventLogExpert.Eventing/PublisherMetadata/ProviderDetailsAssembler.cs new file mode 100644 index 00000000..b8f94dbf --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/ProviderDetailsAssembler.cs @@ -0,0 +1,274 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Provider.Resolution; + +namespace EventLogExpert.Eventing.PublisherMetadata; + +/// +/// Builds a from source-agnostic . The native +/// publisher-metadata path feeds this today; the offline WEVT parser feeds the same assembler later, so a single +/// resolution + projection path serves both and the two cannot drift. Each section is assembled under its own +/// try/catch so a resolution failure empties only that section, matching the per-section behavior of the original +/// inline load path. +/// +internal static class ProviderDetailsAssembler +{ + internal static ProviderDetails Assemble(RawProviderContent content, ITraceLogger? logger) + { + var provider = new ProviderDetails { ProviderName = content.ProviderName }; + + try + { + provider.Events = BuildEvents(content); + } + catch (Exception ex) + { + logger?.Debug($"Failed to load Events for modern provider: {content.ProviderName}. Exception:\n{ex}"); + } + + try + { + provider.Keywords = BuildNamedDictionary(content.Keywords, content.ResolveMessage, static value => unchecked((long)value)); + } + catch (Exception ex) + { + logger?.Debug($"Failed to load Keywords for modern provider: {content.ProviderName}. Exception:\n{ex}"); + } + + try + { + provider.Opcodes = BuildNamedDictionary(content.Opcodes, content.ResolveMessage, static value => unchecked((int)((uint)value >> 16))); + } + catch (Exception ex) + { + logger?.Debug($"Failed to load Opcodes for modern provider: {content.ProviderName}. Exception:\n{ex}"); + } + + try + { + provider.Tasks = BuildNamedDictionary(content.Tasks, content.ResolveMessage, static value => unchecked((int)(uint)value)); + } + catch (Exception ex) + { + logger?.Debug($"Failed to load Tasks for modern provider: {content.ProviderName}. Exception:\n{ex}"); + } + + PopulateValueMaps(provider, content, logger); + + return provider; + } + + 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; + } + } + + private static EventModel[] BuildEvents(RawProviderContent content) + { + var events = new EventModel[content.Events.Count]; + + for (int i = 0; i < content.Events.Count; i++) + { + RawProviderEvent raw = content.Events[i]; + + events[i] = new EventModel + { + // No-message events resolve to string.Empty (not null) to match the native path; the encoder hashes + // null and empty differently. + Description = raw.MessageId == uint.MaxValue ? string.Empty : content.ResolveMessage(raw.MessageId) ?? string.Empty, + Id = raw.Id, + Keywords = ExpandKeywords(raw.KeywordsMask), + Level = raw.Level, + LogName = content.Channels.GetValueOrDefault(raw.ChannelId), + Opcode = raw.Opcode, + Task = raw.Task, + Template = raw.Template, + Version = raw.Version + }; + } + + return events; + } + + private static Dictionary BuildNamedDictionary( + IReadOnlyList entries, + Func resolveMessage, + Func keyProjector) + where TKey : notnull + { + var dictionary = new Dictionary(entries.Count); + + foreach (RawNamedValue entry in entries) + { + // Message-id wins over the inline name when a real id exists (mirrors the native getters); the resolver + // coalesce only guards the offline message-table resolver, which can return null - native never does. The + // inline name is preserved as-is (the native getters TryAdd the raw name, which may be null/empty). + string? name = entry.MessageId == uint.MaxValue + ? entry.InlineName + : resolveMessage(entry.MessageId) ?? string.Empty; + + dictionary.TryAdd(keyProjector(entry.Value), name!); + } + + return dictionary; + } + + /// Expands a u64 keyword mask MSB-first into individual set-bit values, matching the live event Keywords. + private static long[] ExpandKeywords(ulong keywordsMask) + { + List keywords = []; + + ulong mask = 0x8000000000000000; + + for (int i = 0; i < 64; i++) + { + if ((keywordsMask & mask) > 0) + { + keywords.Add(unchecked((long)mask)); + } + + mask >>= 1; + } + + return keywords.ToArray(); + } + + 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 static void PopulateValueMaps(ProviderDetails provider, RawProviderContent content, ITraceLogger? logger) + { + try + { + if (content.PublisherGuid == Guid.Empty) { return; } + + if (string.IsNullOrEmpty(content.ResourceFilePath)) { return; } + + WevtTemplateData? templateData = WevtTemplateReader.TryRead(content.ResourceFilePath, content.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, content.ResolveMessage, content.ProviderName, logger); + + 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: {content.ProviderName}. Exception:\n{ex}"); + } + } + + private static ValueMapDefinition? ResolveMap( + WevtRawMap rawMap, + Func resolveMessage, + string providerName, + ITraceLogger? logger) + { + List entries = new(rawMap.Entries.Count); + + foreach (WevtRawMapEntry entry in rawMap.Entries) + { + if (entry.MessageId == uint.MaxValue) { continue; } + + string? name; + + try + { + name = resolveMessage(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; + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/ProviderMetadata.cs b/src/EventLogExpert.Eventing/PublisherMetadata/ProviderMetadata.cs index ffd08b6e..29dc7ac3 100644 --- a/src/EventLogExpert.Eventing/PublisherMetadata/ProviderMetadata.cs +++ b/src/EventLogExpert.Eventing/PublisherMetadata/ProviderMetadata.cs @@ -365,6 +365,88 @@ and not StackOverflowException internal string FormatMessageById(uint messageId) => NativeMethods.FormatMessage(_publisherMetadataHandle, messageId); + internal RawProviderContent ToRawContent(string providerName, ITraceLogger? logger) + { + List keywords; + + try + { + keywords = ReadNamedValues( + EvtPublisherMetadataPropertyId.Keywords, + EvtPublisherMetadataPropertyId.KeywordName, + EvtPublisherMetadataPropertyId.KeywordValue, + EvtPublisherMetadataPropertyId.KeywordMessageID, + static value => (ulong)value); + } + catch (Exception ex) + { + logger?.Debug($"Failed to read Keywords for provider {providerName}. Exception:\n{ex}"); + keywords = []; + } + + List opcodes; + + try + { + opcodes = ReadNamedValues( + EvtPublisherMetadataPropertyId.Opcodes, + EvtPublisherMetadataPropertyId.OpcodeName, + EvtPublisherMetadataPropertyId.OpcodeValue, + EvtPublisherMetadataPropertyId.OpcodeMessageID, + static value => (uint)value); + } + catch (Exception ex) + { + logger?.Debug($"Failed to read Opcodes for provider {providerName}. Exception:\n{ex}"); + opcodes = []; + } + + List tasks; + + try + { + tasks = ReadNamedValues( + EvtPublisherMetadataPropertyId.Tasks, + EvtPublisherMetadataPropertyId.TaskName, + EvtPublisherMetadataPropertyId.TaskValue, + EvtPublisherMetadataPropertyId.TaskMessageID, + static value => (uint)value); + } + catch (Exception ex) + { + logger?.Debug($"Failed to read Tasks for provider {providerName}. Exception:\n{ex}"); + tasks = []; + } + + IReadOnlyDictionary channels; + IReadOnlyList events; + + try + { + channels = ReadChannelsRaw(); + events = ReadEventsRaw(); + } + catch (Exception ex) + { + logger?.Debug($"Failed to read Events for provider {providerName}. Exception:\n{ex}"); + channels = ReadOnlyDictionary.Empty; + events = []; + } + + return new RawProviderContent + { + ProviderName = providerName, + PublisherGuid = PublisherGuid, + ResourceFilePath = ResourceFilePath, + ResolveMessage = FormatMessageById, + Channels = channels, + Events = events, + Keywords = keywords, + Opcodes = opcodes, + Tasks = tasks + }; + } + private static object GetEventMetadataProperty(EvtHandle metadataHandle, EvtEventMetadataPropertyId propertyId) { IntPtr buffer = IntPtr.Zero; @@ -560,4 +642,86 @@ private EvtHandle GetPublisherMetadataPropertyHandle(EvtPublisherMetadataPropert Marshal.FreeHGlobal(buffer); } } + + private Dictionary ReadChannelsRaw() + { + using EvtHandle channelRefHandle = + GetPublisherMetadataPropertyHandle(EvtPublisherMetadataPropertyId.ChannelReferences); + + int size = NativeMethods.GetObjectArraySize(channelRefHandle); + + Dictionary channels = new(size); + + for (int i = 0; i < size; i++) + { + uint channelId = (uint)NativeMethods.GetObjectArrayProperty( + channelRefHandle, + i, + EvtPublisherMetadataPropertyId.ChannelReferenceID); + + string channelName = (string)NativeMethods.GetObjectArrayProperty( + channelRefHandle, + i, + EvtPublisherMetadataPropertyId.ChannelReferencePath); + + channels.TryAdd(channelId, channelName); + } + + return channels; + } + + private List ReadEventsRaw() + { + List events = []; + + using EvtHandle handle = NativeMethods.EvtOpenEventMetadataEnum(_publisherMetadataHandle, 0); + + if (handle.IsInvalid) { return events; } + + while (true) + { + using EvtHandle? metadataHandle = NextEventMetadata(handle, 0); + + if (metadataHandle is null) { break; } + + uint id = (uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.ID); + byte version = (byte)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Version); + byte channelId = (byte)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Channel); + byte level = (byte)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Level); + byte opcode = (byte)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Opcode); + short task = (short)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Task); + ulong keywords = (ulong)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Keyword); + string template = (string)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Template); + uint messageId = (uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.MessageID); + + events.Add(new RawProviderEvent(id, version, channelId, level, opcode, task, keywords, template, messageId)); + } + + return events; + } + + private List ReadNamedValues( + EvtPublisherMetadataPropertyId tableId, + EvtPublisherMetadataPropertyId nameId, + EvtPublisherMetadataPropertyId valueId, + EvtPublisherMetadataPropertyId messageIdId, + Func unboxValue) + { + using EvtHandle handle = GetPublisherMetadataPropertyHandle(tableId); + + int size = NativeMethods.GetObjectArraySize(handle); + + List entries = new(size); + + for (int i = 0; i < size; i++) + { + string name = (string)NativeMethods.GetObjectArrayProperty(handle, i, nameId); + ulong value = unboxValue(NativeMethods.GetObjectArrayProperty(handle, i, valueId)); + uint messageId = (uint)NativeMethods.GetObjectArrayProperty(handle, i, messageIdId); + + entries.Add(new RawNamedValue(value, messageId, name)); + } + + return entries; + } } diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/RawProviderContent.cs b/src/EventLogExpert.Eventing/PublisherMetadata/RawProviderContent.cs new file mode 100644 index 00000000..ab090ba8 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/RawProviderContent.cs @@ -0,0 +1,60 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Collections.ObjectModel; + +namespace EventLogExpert.Eventing.PublisherMetadata; + +/// +/// A single provider event in its raw, source-agnostic form: the unresolved fields read from a provider source +/// (the native publisher metadata today, the offline WEVT parser later). and the keyword mask +/// are left unresolved so the shared performs the same resolution for every +/// source. +/// +internal sealed record RawProviderEvent( + uint Id, + byte Version, + byte ChannelId, + byte Level, + byte Opcode, + short Task, + ulong KeywordsMask, + string Template, + uint MessageId); + +/// +/// A raw keyword / opcode / task entry: the numeric value plus the two name sources (an inline name and a message +/// id) the assembler resolves into the display name. carries the native value widened to 64 bits; +/// the assembler applies the per-table key projection. +/// +internal sealed record RawNamedValue(ulong Value, uint MessageId, string? InlineName); + +/// +/// The source-agnostic raw content of a provider, produced by a provider source and consumed by +/// to build a +/// . The native path ( +/// ) produces this today; the offline WEVT parser produces the same shape +/// later, so both feed one assembler. +/// +internal sealed class RawProviderContent +{ + /// Channel reference id to log name; the per-event channel byte is looked up here for the event's log name. + public IReadOnlyDictionary Channels { get; init; } = ReadOnlyDictionary.Empty; + + public IReadOnlyList Events { get; init; } = []; + + public IReadOnlyList Keywords { get; init; } = []; + + public IReadOnlyList Opcodes { get; init; } = []; + + public required string ProviderName { get; init; } + + public required Guid PublisherGuid { get; init; } + + /// Resolves a message id to its text, or null when unresolved (native FormatMessage never returns null). + public required Func ResolveMessage { get; init; } + + public required string ResourceFilePath { get; init; } + + public IReadOnlyList Tasks { get; init; } = []; +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/EventMessageProviderTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/ProviderDetailsAssemblerTests.cs similarity index 74% rename from tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/EventMessageProviderTests.cs rename to tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/ProviderDetailsAssemblerTests.cs index 05e651d2..5be65fdc 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/EventMessageProviderTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/ProviderDetailsAssemblerTests.cs @@ -5,14 +5,14 @@ namespace EventLogExpert.Eventing.Tests.PublisherMetadata; -public sealed class EventMessageProviderTests +public sealed class ProviderDetailsAssemblerTests { [Fact] public void InjectMapAttribute_DataSourcePrefix_InjectsIntoTheRealDataElement() { string template = ""; - string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); + string result = ProviderDetailsAssembler.InjectMapAttribute(template, "BusType", "BusTypeMap"); Assert.Equal( "", @@ -24,7 +24,7 @@ public void InjectMapAttribute_DataSourceWithSameName_IsNotMatched() { string template = ""; - string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); + string result = ProviderDetailsAssembler.InjectMapAttribute(template, "BusType", "BusTypeMap"); Assert.Equal(template, result); Assert.DoesNotContain("map=", result); @@ -35,7 +35,7 @@ public void InjectMapAttribute_FieldNotPresent_ReturnsTemplateUnchanged() { string template = ""; - string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); + string result = ProviderDetailsAssembler.InjectMapAttribute(template, "BusType", "BusTypeMap"); Assert.Equal(template, result); } @@ -45,7 +45,7 @@ public void InjectMapAttribute_InsertsMapAfterMatchingDataField() { string template = ""; - string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); + string result = ProviderDetailsAssembler.InjectMapAttribute(template, "BusType", "BusTypeMap"); Assert.Equal( "", @@ -57,7 +57,7 @@ public void InjectMapAttribute_PrefixFieldName_DoesNotMisfire() { string template = ""; - string result = EventMessageProvider.InjectMapAttribute(template, "Bus", "BusMap"); + string result = ProviderDetailsAssembler.InjectMapAttribute(template, "Bus", "BusMap"); Assert.Equal(template, result); } @@ -67,7 +67,7 @@ public void InjectMapAttribute_SecondDataField_InjectsIntoTheNamedElement() { string template = ""; - string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); + string result = ProviderDetailsAssembler.InjectMapAttribute(template, "BusType", "BusTypeMap"); Assert.Equal( "", @@ -79,7 +79,7 @@ public void InjectMapAttribute_StructWithSameName_IsNotMatched() { string template = ""; - string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); + string result = ProviderDetailsAssembler.InjectMapAttribute(template, "BusType", "BusTypeMap"); Assert.Equal(template, result); } From 8307e606050030a00cc15574605df3e61a86bec2 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Thu, 25 Jun 2026 15:18:07 -0500 Subject: [PATCH 03/33] Remove redundant ProviderMetadata getters superseded by the assembler --- .../PublisherMetadata/EventMetadata.cs | 81 ----- .../PublisherMetadata/ProviderMetadata.cs | 252 +------------- .../EventMessageProviderIntegrationTests.cs | 51 ++- .../ProviderMetadataTests.cs | 322 ++---------------- .../ProviderDetailsAssemblerTests.cs | 137 ++++++++ 5 files changed, 196 insertions(+), 647 deletions(-) delete mode 100644 src/EventLogExpert.Eventing/PublisherMetadata/EventMetadata.cs diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/EventMetadata.cs b/src/EventLogExpert.Eventing/PublisherMetadata/EventMetadata.cs deleted file mode 100644 index 02d01931..00000000 --- a/src/EventLogExpert.Eventing/PublisherMetadata/EventMetadata.cs +++ /dev/null @@ -1,81 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -namespace EventLogExpert.Eventing.PublisherMetadata; - -internal sealed record EventMetadata -{ - private readonly byte _channelId; - private readonly long _keywords; - private readonly ProviderMetadata _provider; - - internal EventMetadata( - uint id, - byte version, - byte channelId, - byte level, - byte opcode, - short task, - long keywords, - string template, - string description, - ProviderMetadata provider) - { - Id = id; - Version = version; - _channelId = channelId; - Level = level; - Opcode = opcode; - Task = task; - _keywords = keywords; - Template = template; - Description = description; - _provider = provider; - } - - internal string Description { get; } - - internal long Id { get; } - - internal IEnumerable Keywords - { - get - { - List keywords = []; - - ulong mask = 0x8000000000000000; - - for (int i = 0; i < 64; i++) - { - if (((ulong)_keywords & mask) > 0) - { - keywords.Add(unchecked((long)mask)); - } - - mask >>= 1; - } - - return keywords; - } - } - - internal byte Level { get; } - - internal string? LogName - { - get - { - _provider.Channels.TryGetValue(_channelId, out string? logName); - - return logName; - } - } - - internal int Opcode { get; } - - internal int Task { get; } - - internal string Template { get; } - - internal byte Version { get; } -} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/ProviderMetadata.cs b/src/EventLogExpert.Eventing/PublisherMetadata/ProviderMetadata.cs index 29dc7ac3..24450f45 100644 --- a/src/EventLogExpert.Eventing/PublisherMetadata/ProviderMetadata.cs +++ b/src/EventLogExpert.Eventing/PublisherMetadata/ProviderMetadata.cs @@ -12,19 +12,13 @@ namespace EventLogExpert.Eventing.PublisherMetadata; /// Provides metadata about an event log provider. /// /// This class does not implement . The underlying is a -/// that cleans itself up via its own finalizer. Instances are cached in -/// and are intended to be long-lived. +/// that cleans itself up via its own finalizer. Instances are short-lived: each one is +/// created for a single provider load, consumed once through , and then discarded. /// internal sealed class ProviderMetadata { - private readonly Lock _providerLock = new(); private readonly EvtHandle _publisherMetadataHandle; - private ReadOnlyDictionary? _channels; - private ReadOnlyDictionary? _keywords; - private ReadOnlyDictionary? _opcodes; - private ReadOnlyDictionary? _tasks; - private ProviderMetadata(string providerName, string? metadataPath = null) { _publisherMetadataHandle = NativeMethods.EvtOpenPublisherMetadata(EventLogSession.GlobalSession.Handle, providerName, metadataPath, 0, 0); @@ -36,252 +30,10 @@ private ProviderMetadata(string providerName, string? metadataPath = null) } } - public IDictionary Channels - { - get - { - if (_channels is not null) { return _channels; } - - _providerLock.Enter(); - - try - { - using EvtHandle channelRefHandle = - GetPublisherMetadataPropertyHandle(EvtPublisherMetadataPropertyId.ChannelReferences); - - int size = NativeMethods.GetObjectArraySize(channelRefHandle); - - Dictionary channels = new(size); - - for (int i = 0; i < size; i++) - { - uint channelId = (uint)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.ChannelReferenceID); - - string channelName = (string)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.ChannelReferencePath); - - channels.TryAdd(channelId, channelName); - } - - _channels = channels.AsReadOnly(); - - return _channels; - } - finally - { - _providerLock.Exit(); - } - } - } - - public IEnumerable Events - { - get - { - List events = []; - - using EvtHandle handle = NativeMethods.EvtOpenEventMetadataEnum(_publisherMetadataHandle, 0); - int error = Marshal.GetLastWin32Error(); - - if (handle.IsInvalid) - { - Error = NativeErrorResolver.GetErrorMessage((uint)HResultConverter.HResultFromWin32(error)); - - return events.AsReadOnly(); - } - - while (true) - { - using EvtHandle? metadataHandle = NextEventMetadata(handle, 0); - - if (metadataHandle is null) { break; } - - uint id = (uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.ID); - byte version = (byte)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Version); - byte channelId = (byte)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Channel); - byte level = (byte)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Level); - byte opcode = (byte)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Opcode); - short task = (short)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Task); - long keywords = (long)(ulong)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Keyword); - string template = (string)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Template); - int messageId = (int)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.MessageID); - - string message = messageId == -1 ? - string.Empty : - NativeMethods.FormatMessage(_publisherMetadataHandle, (uint)messageId); - - events.Add(new EventMetadata(id, version, channelId, level, opcode, task, keywords, template, message, this)); - } - - return events.AsReadOnly(); - } - } - - public IDictionary Keywords - { - get - { - if (_keywords is not null) { return _keywords; } - - _providerLock.Enter(); - - try - { - using EvtHandle channelRefHandle = - GetPublisherMetadataPropertyHandle(EvtPublisherMetadataPropertyId.Keywords); - - int size = NativeMethods.GetObjectArraySize(channelRefHandle); - - Dictionary keywords = new(size); - - for (int i = 0; i < size; i++) - { - string name = (string)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.KeywordName); - - long value = (long)(ulong)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.KeywordValue); - - int messageId = (int)(uint)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.KeywordMessageID); - - string displayName = messageId == -1 ? - name : - NativeMethods.FormatMessage(_publisherMetadataHandle, (uint)messageId); - - keywords.TryAdd(value, displayName); - } - - _keywords = keywords.AsReadOnly(); - - return _keywords; - } - finally - { - _providerLock.Exit(); - } - } - } - public string MessageFilePath => Environment.ExpandEnvironmentVariables(GetPublisherMetadataProperty(EvtPublisherMetadataPropertyId.MessageFilePath)); - public IDictionary Opcodes - { - get - { - if (_opcodes is not null) { return _opcodes; } - - _providerLock.Enter(); - - try - { - using EvtHandle channelRefHandle = - GetPublisherMetadataPropertyHandle(EvtPublisherMetadataPropertyId.Opcodes); - - int size = NativeMethods.GetObjectArraySize(channelRefHandle); - - Dictionary opcodes = new(size); - - for (int i = 0; i < size; i++) - { - string name = (string)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.OpcodeName); - - uint value = (uint)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.OpcodeValue); - - int messageId = (int)(uint)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.OpcodeMessageID); - - string displayName = messageId == -1 ? - name : - NativeMethods.FormatMessage(_publisherMetadataHandle, (uint)messageId); - - opcodes.TryAdd((int)(value >> 16), displayName); - } - - _opcodes = opcodes.AsReadOnly(); - - return _opcodes; - } - finally - { - _providerLock.Exit(); - } - } - } - public string ParameterFilePath => Environment.ExpandEnvironmentVariables(GetPublisherMetadataProperty(EvtPublisherMetadataPropertyId.ParameterFilePath)); - public IDictionary Tasks - { - get - { - if (_tasks is not null) { return _tasks; } - - _providerLock.Enter(); - - try - { - using EvtHandle channelRefHandle = - GetPublisherMetadataPropertyHandle(EvtPublisherMetadataPropertyId.Tasks); - - int size = NativeMethods.GetObjectArraySize(channelRefHandle); - - Dictionary tasks = new(size); - - for (int i = 0; i < size; i++) - { - string name = (string)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.TaskName); - - int value = (int)(uint)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.TaskValue); - - int messageId = (int)(uint)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.TaskMessageID); - - string displayName = messageId == -1 ? - name : - NativeMethods.FormatMessage(_publisherMetadataHandle, (uint)messageId); - - tasks.TryAdd(value, displayName); - } - - _tasks = tasks.AsReadOnly(); - - return _tasks; - } - finally - { - _providerLock.Exit(); - } - } - } - internal string? Error { get; private set; } internal bool IsLocaleMetadata { get; private init; } diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/EventMessageProviderIntegrationTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/EventMessageProviderIntegrationTests.cs index 55476638..f98b3931 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/EventMessageProviderIntegrationTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/EventMessageProviderIntegrationTests.cs @@ -69,22 +69,6 @@ public void LoadProviderDetails_ShouldLogProviderLoadingAttempt() mockLogger.Received().Debug(Arg.Any()); } - [Fact] - public void LoadProviderDetails_WhenCalledMultipleTimes_ShouldReturnConsistentResults() - { - // Arrange - EventMessageProvider provider = new(Constants.TestProviderName); - - // Act - var details1 = provider.LoadProviderDetails(); - var details2 = provider.LoadProviderDetails(); - - // Assert - Assert.NotNull(details1); - Assert.NotNull(details2); - Assert.Equal(details1.ProviderName, details2.ProviderName); - } - [Fact] public void LoadProviderDetails_WhenCalled_ShouldHaveNonNullCollections() { @@ -118,6 +102,22 @@ public void LoadProviderDetails_WhenCalled_ShouldReturnProviderDetails() Assert.Equal(Constants.TestProviderName, details.ProviderName); } + [Fact] + public void LoadProviderDetails_WhenCalledMultipleTimes_ShouldReturnConsistentResults() + { + // Arrange + EventMessageProvider provider = new(Constants.TestProviderName); + + // Act + var details1 = provider.LoadProviderDetails(); + var details2 = provider.LoadProviderDetails(); + + // Assert + Assert.NotNull(details1); + Assert.NotNull(details2); + Assert.Equal(details1.ProviderName, details2.ProviderName); + } + [Fact] public void LoadProviderDetails_WhenChannelOwningPublisherUnknown_ShouldReturnEmptyDetailsWithoutFallback() { @@ -203,6 +203,25 @@ public void LoadProviderDetails_WhenProviderNotFound_ShouldReturnDetailsWithProv Assert.Equal(Constants.TestProviderName, details.ProviderName); } + [Fact] + public void LoadProviderDetails_WhenStableProvider_ShouldResolveNamedValues() + { + // End-to-end: the modern path (ToRawContent -> ProviderDetailsAssembler) must resolve the raw keyword/opcode/task + // message ids into non-empty display names for a real provider, not merely read the raw rows. + EventMessageProvider provider = new(Constants.SecurityAuditingLogName); + + var details = provider.LoadProviderDetails(); + + Assert.NotNull(details); + Assert.SkipUnless(!details.IsEmpty, "Test requires the Microsoft-Windows-Security-Auditing provider on the host."); + + var resolvedNames = details.Keywords.Values + .Concat(details.Opcodes.Values) + .Concat(details.Tasks.Values); + + Assert.Contains(resolvedNames, name => !string.IsNullOrEmpty(name)); + } + private static bool TryFindChannelWithDistinctOwningPublisher(out string? channelName, out string? owningPublisher) { foreach (var candidate in EventLogSession.GlobalSession.GetLogNames()) diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/ProviderMetadataTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/ProviderMetadataTests.cs index a0e5cdae..b648ed60 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/ProviderMetadataTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/ProviderMetadataTests.cs @@ -6,53 +6,11 @@ using EventLogExpert.Logging.Abstractions; using EventLogExpert.Logging.Abstractions.Handlers; using NSubstitute; -using System.Collections.ObjectModel; namespace EventLogExpert.Eventing.IntegrationTests.PublisherMetadata; public sealed class ProviderMetadataTests { - [Fact] - public async Task Channels_WhenAccessedConcurrently_ShouldReturnValidData() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var tasks = new[] - { - Task.Run(() => metadata?.Channels), - Task.Run(() => metadata?.Channels), - Task.Run(() => metadata?.Channels) - }; - - await Task.WhenAll(tasks); - - // Assert - var results = tasks.Select(t => t.Result).ToList(); - Assert.All(results, r => - { - Assert.NotNull(r); - Assert.NotEmpty(r); - }); - } - - [Fact] - public void Channels_WhenCalledMultipleTimes_ShouldReturnSameInstance() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var channels1 = metadata?.Channels; - var channels2 = metadata?.Channels; - - // Assert - Assert.NotNull(channels1); - Assert.NotNull(channels2); - Assert.Same(channels1, channels2); - } - [Fact] public void Channels_WhenProviderHasChannels_ShouldHaveValidKeys() { @@ -60,7 +18,7 @@ public void Channels_WhenProviderHasChannels_ShouldHaveValidKeys() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var channels = metadata?.Channels; + var channels = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Channels; // Assert Assert.NotNull(channels); @@ -73,21 +31,6 @@ public void Channels_WhenProviderHasChannels_ShouldHaveValidKeys() }); } - [Fact] - public void Channels_WhenProviderHasNoDuplicateChannelIds_ShouldNotLoseData() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var channels = metadata?.Channels; - - // Assert - Assert.NotNull(channels); - var uniqueIds = channels.Keys.Distinct().Count(); - Assert.Equal(channels.Count, uniqueIds); - } - [Fact] public void Channels_WhenValidProvider_ShouldContainData() { @@ -95,27 +38,13 @@ public void Channels_WhenValidProvider_ShouldContainData() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var channels = metadata?.Channels; + var channels = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Channels; // Assert Assert.NotNull(channels); Assert.NotEmpty(channels); } - [Fact] - public void Channels_WhenValidProvider_ShouldReturnReadOnlyDictionary() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var channels = metadata?.Channels; - - // Assert - Assert.NotNull(channels); - Assert.IsAssignableFrom>(channels); - } - [Theory] [InlineData(Constants.SecurityAuditingLogName)] [InlineData(Constants.KernelGeneralLogName)] @@ -258,16 +187,15 @@ public void Events_WhenProviderHasEvents_ShouldHaveValidEventMetadata() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var events = metadata?.Events?.ToList(); + var events = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Events; // Assert Assert.NotNull(events); if (events.Count == 0) { return; } - var firstEvent = events.First(); - Assert.True(firstEvent.Id >= 0); - Assert.True(firstEvent.Version >= 0); + var firstEvent = events[0]; + Assert.True(firstEvent.Id > 0); } [Fact] @@ -277,7 +205,7 @@ public void Events_WhenValidProvider_ShouldContainEventMetadata() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var events = metadata?.Events?.ToList(); + var events = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Events; // Assert Assert.NotNull(events); @@ -285,10 +213,10 @@ public void Events_WhenValidProvider_ShouldContainEventMetadata() if (events.Count == 0) { return; } Assert.All(events, - e => + providerEvent => { - Assert.NotNull(e); - Assert.True(e.Id > 0); + Assert.NotNull(providerEvent); + Assert.True(providerEvent.Id > 0); }); } @@ -299,53 +227,13 @@ public void Events_WhenValidProvider_ShouldContainEvents() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var events = metadata?.Events?.ToList(); + var events = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Events; // Assert Assert.NotNull(events); Assert.NotEmpty(events); } - [Fact] - public async Task Keywords_WhenAccessedConcurrently_ShouldReturnValidData() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.PowerShellLogName); - - // Act - var tasks = new[] - { - Task.Run(() => metadata?.Keywords), - Task.Run(() => metadata?.Keywords), - Task.Run(() => metadata?.Keywords) - }; - - await Task.WhenAll(tasks); - - // Assert - var results = tasks.Select(t => t.Result).ToList(); - Assert.All(results, r => Assert.NotNull(r)); - // Verify all results have the same count (cached properly) - var firstCount = results[0]?.Count ?? 0; - Assert.All(results, r => Assert.Equal(firstCount, r?.Count ?? 0)); - } - - [Fact] - public void Keywords_WhenCalledMultipleTimes_ShouldReturnSameInstance() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var keywords1 = metadata?.Keywords; - var keywords2 = metadata?.Keywords; - - // Assert - Assert.NotNull(keywords1); - Assert.NotNull(keywords2); - Assert.Same(keywords1, keywords2); - } - [Fact] public void Keywords_WhenProviderHasKeywords_ShouldContainData() { @@ -353,7 +241,7 @@ public void Keywords_WhenProviderHasKeywords_ShouldContainData() var metadata = ProviderMetadata.Create(Constants.PowerShellLogName); // Act - var keywords = metadata?.Keywords; + var keywords = metadata?.ToRawContent(Constants.PowerShellLogName, null).Keywords; // Assert Assert.NotNull(keywords); @@ -372,47 +260,19 @@ public void Keywords_WhenProviderHasKeywords_ShouldHaveValidValues() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var keywords = metadata?.Keywords; + var keywords = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Keywords; // Assert Assert.NotNull(keywords); + // Each raw entry carries a name source: an inline name, or a message id to resolve. Assert.All(keywords, keyword => { - Assert.False(string.IsNullOrEmpty(keyword.Value)); + Assert.True(keyword.MessageId != uint.MaxValue || !string.IsNullOrEmpty(keyword.InlineName)); }); } - [Fact] - public void Keywords_WhenProviderHasNoDuplicateKeywordValues_ShouldNotLoseData() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var keywords = metadata?.Keywords; - - // Assert - Assert.NotNull(keywords); - var uniqueValues = keywords.Keys.Distinct().Count(); - Assert.Equal(keywords.Count, uniqueValues); - } - - [Fact] - public void Keywords_WhenValidProvider_ShouldReturnReadOnlyDictionary() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var keywords = metadata?.Keywords; - - // Assert - Assert.NotNull(keywords); - Assert.IsAssignableFrom>(keywords); - } - [Fact] public void MessageFilePath_WhenCalledMultipleTimes_ShouldReturnConsistentPath() { @@ -488,62 +348,6 @@ public void MessageFilePath_WhenValidProvider_ShouldReturnPath() Assert.NotNull(messageFilePath); } - [Fact] - public async Task Opcodes_WhenAccessedConcurrently_ShouldReturnValidData() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var tasks = new[] - { - Task.Run(() => metadata?.Opcodes), - Task.Run(() => metadata?.Opcodes), - Task.Run(() => metadata?.Opcodes) - }; - - await Task.WhenAll(tasks); - - // Assert - var results = tasks.Select(t => t.Result).ToList(); - Assert.All(results, r => - { - Assert.NotNull(r); - Assert.NotEmpty(r); - }); - } - - [Fact] - public void Opcodes_WhenCalledMultipleTimes_ShouldReturnSameInstance() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var opcodes1 = metadata?.Opcodes; - var opcodes2 = metadata?.Opcodes; - - // Assert - Assert.NotNull(opcodes1); - Assert.NotNull(opcodes2); - Assert.Same(opcodes1, opcodes2); - } - - [Fact] - public void Opcodes_WhenProviderHasNoDuplicateOpcodeValues_ShouldNotLoseData() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var opcodes = metadata?.Opcodes; - - // Assert - Assert.NotNull(opcodes); - var uniqueValues = opcodes.Keys.Distinct().Count(); - Assert.Equal(opcodes.Count, uniqueValues); - } - [Fact] public void Opcodes_WhenProviderHasOpcodes_ShouldHaveValidValues() { @@ -551,15 +355,16 @@ public void Opcodes_WhenProviderHasOpcodes_ShouldHaveValidValues() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var opcodes = metadata?.Opcodes; + var opcodes = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Opcodes; // Assert Assert.NotNull(opcodes); + // Each raw entry carries a name source: an inline name, or a message id to resolve. Assert.All(opcodes, opcode => { - Assert.False(string.IsNullOrEmpty(opcode.Value)); + Assert.True(opcode.MessageId != uint.MaxValue || !string.IsNullOrEmpty(opcode.InlineName)); }); } @@ -570,27 +375,13 @@ public void Opcodes_WhenValidProvider_ShouldContainData() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var opcodes = metadata?.Opcodes; + var opcodes = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Opcodes; // Assert Assert.NotNull(opcodes); Assert.NotEmpty(opcodes); } - [Fact] - public void Opcodes_WhenValidProvider_ShouldReturnReadOnlyDictionary() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var opcodes = metadata?.Opcodes; - - // Assert - Assert.NotNull(opcodes); - Assert.IsAssignableFrom>(opcodes); - } - [Fact] public void ParameterFilePath_WhenCalledMultipleTimes_ShouldReturnConsistentPath() { @@ -638,62 +429,6 @@ public void ParameterFilePath_WhenValidProvider_ShouldReturnPath() Assert.NotNull(parameterFilePath); } - [Fact] - public async Task Tasks_WhenAccessedConcurrently_ShouldReturnValidData() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var tasks = new[] - { - Task.Run(() => metadata?.Tasks), - Task.Run(() => metadata?.Tasks), - Task.Run(() => metadata?.Tasks) - }; - - await Task.WhenAll(tasks); - - // Assert - var results = tasks.Select(t => t.Result).ToList(); - Assert.All(results, r => - { - Assert.NotNull(r); - Assert.NotEmpty(r); - }); - } - - [Fact] - public void Tasks_WhenCalledMultipleTimes_ShouldReturnSameInstance() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var tasks1 = metadata?.Tasks; - var tasks2 = metadata?.Tasks; - - // Assert - Assert.NotNull(tasks1); - Assert.NotNull(tasks2); - Assert.Same(tasks1, tasks2); - } - - [Fact] - public void Tasks_WhenProviderHasNoDuplicateTaskValues_ShouldNotLoseData() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var tasks = metadata?.Tasks; - - // Assert - Assert.NotNull(tasks); - var uniqueValues = tasks.Keys.Distinct().Count(); - Assert.Equal(tasks.Count, uniqueValues); - } - [Fact] public void Tasks_WhenProviderHasTasks_ShouldHaveValidValues() { @@ -701,15 +436,16 @@ public void Tasks_WhenProviderHasTasks_ShouldHaveValidValues() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var tasks = metadata?.Tasks; + var tasks = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Tasks; // Assert Assert.NotNull(tasks); + // Each raw entry carries a name source: an inline name, or a message id to resolve. Assert.All(tasks, task => { - Assert.False(string.IsNullOrEmpty(task.Value)); + Assert.True(task.MessageId != uint.MaxValue || !string.IsNullOrEmpty(task.InlineName)); }); } @@ -720,24 +456,10 @@ public void Tasks_WhenValidProvider_ShouldContainData() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var tasks = metadata?.Tasks; + var tasks = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Tasks; // Assert Assert.NotNull(tasks); Assert.NotEmpty(tasks); } - - [Fact] - public void Tasks_WhenValidProvider_ShouldReturnReadOnlyDictionary() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var tasks = metadata?.Tasks; - - // Assert - Assert.NotNull(tasks); - Assert.IsAssignableFrom>(tasks); - } } diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/ProviderDetailsAssemblerTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/ProviderDetailsAssemblerTests.cs index 5be65fdc..8312e4b7 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/ProviderDetailsAssemblerTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/ProviderDetailsAssemblerTests.cs @@ -7,6 +7,122 @@ namespace EventLogExpert.Eventing.Tests.PublisherMetadata; public sealed class ProviderDetailsAssemblerTests { + [Fact] + public void Assemble_DuplicateProjectedOpcodeKey_KeepsFirstAndPreservesDistinctKeys() + { + // Opcode keys are the value shifted right 16 bits: 0x00010000 and 0x0001FFFF both project to 1; 0x00020000 to 2. + // First write wins for the colliding key, and the distinct key survives (the dedup the native getters used to do). + var content = CreateContent( + resolveMessage: _ => null, + opcodes: + [ + new RawNamedValue(0x00010000, uint.MaxValue, "First"), + new RawNamedValue(0x0001FFFF, uint.MaxValue, "Second"), + new RawNamedValue(0x00020000, uint.MaxValue, "Third") + ]); + + var details = ProviderDetailsAssembler.Assemble(content, null); + + Assert.Equal(2, details.Opcodes.Count); + Assert.Equal("First", details.Opcodes[1]); + Assert.Equal("Third", details.Opcodes[2]); + } + + [Fact] + public void Assemble_Event_ExpandsKeywordMaskAndResolvesDescriptionAndLogName() + { + var content = CreateContent( + resolveMessage: id => id == 50 ? "Event description" : null, + channels: new Dictionary { [16] = "Operational" }, + events: + [ + new RawProviderEvent( + Id: 4624, + Version: 1, + ChannelId: 16, + Level: 0, + Opcode: 0, + Task: 0, + KeywordsMask: 0x8000000000000001, + Template: "