Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,17 +84,37 @@ public bool StrictlyMatchesPropertyCount(ReadOnlySpan<char> template, int eventP
return MatchesPropertyCount(template, eventPropertyCount);
}

// Windows synthesizes "%1".."%N" <data> 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<string> 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;
}
Expand Down Expand Up @@ -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;
Comment thread
jschick04 marked this conversation as resolved.
write++;
Expand All @@ -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<char> template)
{
List<(string name, string outType, string map)> elements = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -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" <data> 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("<template><data name=\"ProcessName\"/><data name=\"UserName\"/></template>");

Assert.Equal(["ProcessName", "UserName"], schema.AllNames);
}

[Fact]
public void AllSyntheticNames_NormalizedToParameterLabels()
{
TemplateFieldSchema schema = SchemaFor("<template><data name=\"%1\"/><data name=\"%2\"/><data name=\"%18\"/></template>");

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("<template><data name=\"%1\"/><data name=\"%1\"/></template>");

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("<template><data name=\"%1\"/><data name=\"Real\"/></template>");

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("<template><data name=\"%1\"/><data name=\"\"/></template>");

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($"<template><data name=\"{name}\"/></template>");

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(
"<template><data name=\"%1\" inType=\"win:UInt32\"/><data name=\"%2\" length=\"%1\"/></template>");

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 = "<template><data name=\"%1\"/><data name=\"%2\"/></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("<template><data name=\"%\u0661\"/></template>"); // Arabic-Indic digit one

Assert.Equal(["%\u0661"], schema.AllNames);
}

private static TemplateFieldSchema SchemaFor(string template) => new TemplateAnalyzer().GetTemplateInfo(template).Schema;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Loading