diff --git a/src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs b/src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs index 1bffcaf1..4c284d6b 100644 --- a/src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs +++ b/src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs @@ -84,17 +84,37 @@ public bool StrictlyMatchesPropertyCount(ReadOnlySpan template, int eventP return MatchesPropertyCount(template, eventPropertyCount); } + // Windows synthesizes "%1".."%N" names for classic positional insertion strings; no real names exist + // and Event Viewer shows those fields positionally. Only a template whose EVERY node is such a placeholder is + // treated as classic and relabeled "Parameter N" (in BuildInfo); any real name or unnamed/raw node leaves it + // untouched (fail closed). + private static bool AllFieldNamesSynthetic(List<(string name, string outType, string map)> elements) + { + bool anySynthetic = false; + + foreach (var (name, _, _) in elements) + { + if (!IsSyntheticFieldName(name)) { return false; } + + anySynthetic = true; + } + + return anySynthetic; + } + private static TemplateInfo BuildInfo( List<(string name, string outType, string map)> elements, HashSet lengthProviderNames) { + bool normalize = AllFieldNamesSynthetic(elements); + var allNamesArray = new string[elements.Count]; var allOutTypesArray = new string[elements.Count]; var allMapsArray = new string[elements.Count]; for (int i = 0; i < elements.Count; i++) { - allNamesArray[i] = elements[i].name; + allNamesArray[i] = NormalizedName(elements[i].name, normalize); allOutTypesArray[i] = elements[i].outType; allMapsArray[i] = elements[i].map; } @@ -126,11 +146,13 @@ private static TemplateInfo BuildInfo( var visibleMapsArray = new string[visibleCount]; int write = 0; - foreach (var (name, outType, map) in elements) + for (int i = 0; i < elements.Count; i++) { + var (name, outType, map) = elements[i]; + if (string.IsNullOrEmpty(name) || !lengthProviderNames.Contains(name)) { - visibleNamesArray[write] = name; + visibleNamesArray[write] = allNamesArray[i]; visibleOutTypesArray[write] = outType; visibleMapsArray[write] = map; write++; @@ -146,6 +168,25 @@ private static TemplateInfo BuildInfo( return new TemplateInfo(visibleMetadata, new TemplateFieldSchema(allNames, visibleNames)); } + private static string FormatSyntheticFieldName(string name) => string.Concat("Parameter ", name.AsSpan(1)); + + // Canonical positive ordinal: '%' + a leading-zero-free ASCII-digit run ("%1".."%N", not "%0"/"%01"). ASCII + // range checks (not char.IsDigit) keep Unicode digits out of both the gate and the label. + private static bool IsSyntheticFieldName(string name) + { + if (name.Length < 2 || name[0] != '%' || name[1] is < '1' or > '9') { return false; } + + for (int i = 2; i < name.Length; i++) + { + if (name[i] is < '0' or > '9') { return false; } + } + + return true; + } + + private static string NormalizedName(string name, bool normalize) => + normalize && IsSyntheticFieldName(name) ? FormatSyntheticFieldName(name) : name; + private static TemplateInfo Parse(ReadOnlySpan template) { List<(string name, string outType, string map)> elements = []; diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/SyntheticFieldNameNormalizationTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/SyntheticFieldNameNormalizationTests.cs new file mode 100644 index 00000000..1d1f9971 --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/SyntheticFieldNameNormalizationTests.cs @@ -0,0 +1,136 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Eventing.Resolvers; +using EventLogExpert.Eventing.TestUtils; + +namespace EventLogExpert.Eventing.Tests.Resolvers; + +// Windows synthesizes "%1".."%N" names for classic positional insertion strings (e.g. CAPI2 4192); real +// names do not exist. They are presented as "Parameter N" - the label Event Viewer uses for such unmapped data - at +// template-analysis time so DetailsPane, filter picklists, and column storage all read the friendly label. +public sealed class SyntheticFieldNameNormalizationTests +{ + [Fact] + public void AllRealNames_AreLeftUnchanged() + { + TemplateFieldSchema schema = SchemaFor(""); + + Assert.Equal(["ProcessName", "UserName"], schema.AllNames); + } + + [Fact] + public void AllSyntheticNames_NormalizedToParameterLabels() + { + TemplateFieldSchema schema = SchemaFor(""); + + Assert.Equal(["Parameter 1", "Parameter 2", "Parameter 18"], schema.AllNames); + Assert.Equal(["Parameter 1", "Parameter 2", "Parameter 18"], schema.VisibleNames); + Assert.True(schema.TryGetIndex(FieldNameOrdering.All, "Parameter 1", out int index)); + Assert.Equal(0, index); + Assert.False(schema.TryGetIndex(FieldNameOrdering.All, "%1", out _)); + } + + [Fact] + public void DuplicateSyntheticNames_AreNormalized_FirstIndexWins() + { + // Distinct %N are injective to distinct labels; a repeated %1 keeps first-wins lookup exactly as raw "%1" did. + TemplateFieldSchema schema = SchemaFor(""); + + Assert.Equal(["Parameter 1", "Parameter 1"], schema.AllNames); + Assert.True(schema.TryGetIndex(FieldNameOrdering.All, "Parameter 1", out int index)); + Assert.Equal(0, index); + } + + [Fact] + public void MixedTemplate_AnyRealName_LeavesEveryNameUntouched() + { + // The gate models "Windows synthesized ALL names"; one real name means it is not a classic positional template. + TemplateFieldSchema schema = SchemaFor(""); + + Assert.Equal(["%1", "Real"], schema.AllNames); + } + + [Fact] + public void UnnamedNodePresent_FailsClosed_LeavesPlaceholdersUntouched() + { + // Fail closed: an unnamed/positional-only node means the template is not entirely placeholders, so nothing + // is relabeled - the "%1" is left as-is rather than partially normalizing a non-classic template. + TemplateFieldSchema schema = SchemaFor(""); + + Assert.Equal(["%1", ""], schema.AllNames); + } + + [Theory] + [InlineData("%1", "Parameter 1")] + [InlineData("%9", "Parameter 9")] + [InlineData("%10", "Parameter 10")] + [InlineData("%18", "Parameter 18")] + [InlineData("%0", "%0")] // zero is a message terminator, never a 1-based parameter + [InlineData("%01", "%01")] // leading zero is non-canonical + [InlineData("%1a", "%1a")] // trailing non-digit + [InlineData("% 1", "% 1")] // embedded space + [InlineData("%-1", "%-1")] // sign + [InlineData("%", "%")] // bare percent + [InlineData("Named", "Named")] // an ordinary field name + public void OnlyCanonicalPositivePlaceholders_AreNormalized(string name, string expected) + { + TemplateFieldSchema schema = SchemaFor($""); + + Assert.Equal([expected], schema.AllNames); + } + + [Fact] + public void SealedColumnStore_RoundTripsSyntheticSchemaAsParameterName() + { + // Full chain: TemplateAnalyzer normalizes -> the schema is interned into the SEALED store -> the reader + // reconstructs the friendly name from the pool, and the raw "%1" key no longer resolves. + var @event = EventDataTestFactory.CreateEventWithData(("%1", "MsSense.exe"), ("%2", "CodeSigning")); + + IEventColumnReader reader = EventColumnStore.Build(new[] { @event }, generation: 0, contentVersion: 0) + .CreateReader(EventLogId.Create()); + + Assert.True(reader.TryGetEventData(reader.LocatorAt(0), "Parameter 1", out EventFieldValue first)); + Assert.Equal("MsSense.exe", first.AsString()); + Assert.True(reader.TryGetEventData(reader.LocatorAt(0), "Parameter 2", out EventFieldValue second)); + Assert.Equal("CodeSigning", second.AsString()); + Assert.False(reader.TryGetEventData(reader.LocatorAt(0), "%1", out _)); + } + + [Fact] + public void SyntheticLengthProviderTemplate_PreservesVisibleAllSplit() + { + // The length provider (%1) is excluded from Visible by its ORIGINAL name; both orderings carry the friendly label. + TemplateFieldSchema schema = SchemaFor( + ""); + + Assert.Equal(["Parameter 1", "Parameter 2"], schema.AllNames); + Assert.Equal(["Parameter 2"], schema.VisibleNames); + } + + [Fact] + public void SyntheticNames_NameOutTypeLockstepUnchanged() + { + var analyzer = new TemplateAnalyzer(); + const string Template = ""; + + TemplateFieldSchema schema = analyzer.GetTemplateInfo(Template).Schema; + TemplateMetadata metadata = analyzer.Analyze(Template); + + Assert.Equal(metadata.AllOutTypes.Length, schema.AllNames.Length); + Assert.Equal(metadata.VisibleOutTypes.Length, schema.VisibleNames.Length); + } + + [Fact] + public void UnicodeDigitPlaceholder_IsNotNormalized() + { + // The gate scans ASCII '0'..'9' only, so a Unicode digit neither passes the gate nor reaches the label. + TemplateFieldSchema schema = SchemaFor(""); // Arabic-Indic digit one + + Assert.Equal(["%\u0661"], schema.AllNames); + } + + private static TemplateFieldSchema SchemaFor(string template) => new TemplateAnalyzer().GetTemplateInfo(template).Schema; +} diff --git a/tests/Unit/EventLogExpert.Filtering.Tests/EventData/EventDataPicklistTests.cs b/tests/Unit/EventLogExpert.Filtering.Tests/EventData/EventDataPicklistTests.cs index 78d050a8..3ac768be 100644 --- a/tests/Unit/EventLogExpert.Filtering.Tests/EventData/EventDataPicklistTests.cs +++ b/tests/Unit/EventLogExpert.Filtering.Tests/EventData/EventDataPicklistTests.cs @@ -46,6 +46,16 @@ public void GetEventDataFieldNames_SortsOrdinal() Assert.Equal(["A", "a", "b"], EventPropertyValuesCache.GetEventDataFieldNames(snapshot, events)); } + [Fact] + public void GetEventDataFieldNames_ReturnsParameterLabelsForSyntheticPlaceholders() + { + // The synthetic "%1".."%N" names (e.g. CAPI2 4192) reach the field picker as the friendly "Parameter N". + var snapshot = new object(); + var events = new[] { EventDataTestFactory.CreateEventWithData(("%1", "MsSense.exe"), ("%2", "CodeSigning")) }; + + Assert.Equal(["Parameter 1", "Parameter 2"], EventPropertyValuesCache.GetEventDataFieldNames(snapshot, events)); + } + [Fact] public void GetEventDataFieldValues_DifferentFields_ProduceDifferentLists() { diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/DetailsPane/DetailsReaderFormatterTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/DetailsPane/DetailsReaderFormatterTests.cs index 9b3ef5cd..bd9a2644 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/DetailsPane/DetailsReaderFormatterTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/DetailsPane/DetailsReaderFormatterTests.cs @@ -242,6 +242,15 @@ public void BuildModel_StructuredTokenFields_AreMonospaced() Assert.False(model.EventData[3].IsMonospace); } + [Fact] + public void BuildModel_SyntheticPercentName_UsesParameterLabel() + { + // A Windows-synthesized "%1" placeholder (e.g. CAPI2 4192) surfaces as "Parameter 1", matching Event Viewer. + DetailsField field = Assert.Single(Model(EventDataTestFactory.CreateEventWithData(("%1", "MsSense.exe"))).EventData); + + Assert.Equal("Parameter 1", field.Label); + } + [Theory] [InlineData("")] [InlineData("warning")]